让你的 Agent 远程运维 RunPod 和 Railway 上的程序

自己写的 harness,怎么安全地去 RunPod、Railway 这类远端主机上看日志、重启、排查、修 bug。本文把远程运维拆成控制面、数据面、代码面三条通道,分别讲接法、权限和坑,最后给一份可以直接改的 Python 骨架。调研于 2026 年 9 月 21 日;平台接口变化很快,关键结论都附了来源。

你的 harness(可信区)

  • 模型调用与 agent loop
  • 工具注册、目标白名单、审批
  • 凭据:API token、SSH 私钥
  • 审计日志

模型只看得到工具,看不到凭据。

控制面平台 API、CLI、MCP
部署、重启、回滚、环境变量、日志、扩缩容。大部分运维动作只走这一条。
数据面SSH 进容器
查进程、磁盘、显存和依赖,跑诊断脚本。按需打开,默认只读。
代码面Git PR、CI、自动部署
真正的修复落在代码和配置里,可审计,可回滚。

先说结论

把“远程维护”拆成三个平面

让 agent 去远端维护程序,要做的事按“走什么通道、动的是什么”分成三类。分开设计的原因很实际:三类的凭据、风险和审批规则都不一样,全塞进一个 bash 工具就没法管。

平面通道典型动作RailwayRunPod默认策略
控制面平台 API、CLI、MCP查状态、看日志、重启、重新部署、回滚、改环境变量、扩缩容GraphQL API、railway CLI、MCPREST v2、runpodctl、MCP读:自动写:审批
数据面SSH 进容器看进程、磁盘、显存,读配置和本地日志,跑诊断脚本、数据库迁移railway sshssh.railway.comFull SSH(公网 IP)白名单:自动其余:审批
代码面Git PR、CI、自动部署修 bug、改配置、升级依赖、改 Dockerfile 或模板GitHub 自动部署推新镜像后更新 pod 或 endpoint 配置PR + CI + 人审

五种接入架构

这五种不互斥。比较稳的组合是“远端即工具目标”加“只走 GitOps”打底,GPU 上的重调试再加“agent 驻留远端”。

架构做法适合代价
远端即工具目标
推荐默认
harness 在你这边,把平台 API 和 SSH 包成 typed tools,模型只调用工具绝大多数运维:看日志、重启、回滚、只读排查每次执行都走网络;长任务要靠 tmux 或后台进程加轮询
远端常驻执行代理在容器里跑一个小的执行服务或 remote MCP server(HTTP 加 token 或 mTLS),提供执行、文件和日志流需要流式输出、长任务,或者平台不给 SSH多一个要加固的服务,它本身就是后门,鉴权和出网都要管住
agent 驻留远端把 Claude Code、Codex、OpenCode 等放进 RunPod pod 或 Railway 的 sandbox / cloud agent,你的 harness 通过 HTTP / SSE 远程驱动。Rivet 的 Sandbox Agent SDK 把多家 coding agent 统一成一套 HTTP APIGPU 上的重调试,需要完整本地环境的长任务驻留 agent 的模型凭据进了远端环境,要配凭据代理和出网限制;控制粒度变粗
只走 GitOpsagent 只能读日志、提 PR;平台自动部署,健康检查失败就回滚生产环境、多人协作、需要审计排查慢;容器内的状态问题(磁盘满、进程卡死、显存泄漏)看不到
托管 loop 或平台 agentClaude Managed Agents 的 self-hosted environment:Anthropic 跑 loop,你在自己的机器上跑 worker 执行工具;或者把 Railway Agent 当子 agent 调不想自己维护某一段 loop控制力下降,和“自研 harness”的初衷部分冲突

可以参照 OpenAI 给出的对比:Claude Agent SDK 的常见用法是把 harness 连同 agent loop 一起放进沙箱;新版 OpenAI Agents SDK 则把 harness 留在可信运行时,沙箱只是它调用的执行面。远程运维更适合后者,因为 token 和审批逻辑不该和被维护的程序住在一起。

Railway:接口很全,权限要自己收

Railway 在 2026 年把 agent 当成一等用户,CLI、hosted MCP、agent skills、云端 agent、SSH 入口都齐了。对自研 harness 最有用的是下面几块。

能力怎么接给 agent 用时注意
GraphQL API端点 https://backboard.railway.com/graphql/v2,就是控制台自己用的那套 API。token 有三种:Account(你名下全部资源)、Workspace(单个工作区)、Project(单个项目里的单个环境,用 Project-Access-Token 请求头),另有 OAuth优先给 agent 发 project token。鉴权失败也可能是 HTTP 200 加 errors 数组,必须检查 errors。mutation 不保证 exactly-once,收到 200 就别重试
部署操作官方文档覆盖:列部署;构建、运行时、HTTP 三种日志;redeploy;restart(不重新构建);rollback(只能回到 canRollback: true 的部署);stop;cancel部署状态里有 CRASHEDWAITING(等待审批)等,适合当 agent 的判断依据
限流每小时:Hobby 1000 次,Pro 10000 次。每秒:Hobby 10 次,Pro 50 次轮询要节流,遇到 429 按 Retry-After 等待
MCPremote:mcp.railway.com,OAuth 登录,本地不落 token 文件。local:railway mcp 复用 railway login 的凭据;连不上远端时用 railway mcp local自带一个做多步操作的 railway-agent 工具。工具面很宽,建议只挂在只读或 staging 会话里
CLIrailway logsredeployrestartvariablemetricsscaleapi依赖本地 link 状态的任务用 CLI 更顺;在 harness 里当子进程调用要设超时
SSHrailway ssh -s <服务> -e <环境> -- <命令> 非交互执行,被管道调用时自动不分配 PTY;也能直接 ssh <服务域名或 service instance ID>@ssh.railway.com;支持 scp / sftp;--session 用 tmux 断线续连-L 端口转发只能到容器自己的 loopback 和项目私网。project token 不能管理 SSH key;workspace key 能 SSH 进该工作区的所有服务,所以把 agent 能碰的服务放进独立工作区
Railway Agentrailway agent -p "…" --json,背后是 POST /api/v1/agent可以当子 agent 处理 Railway 专属的多步操作,但它是黑盒,权限等于你的登录
Sandboxes
Priority Boarding
走项目私网访问真实的数据库、Redis、内部服务和变量;支持 checkpoint 和 fork;默认镜像预装 Claude Code、Codex、Cursor、Droid、OpenCode、Pi;ssh sandbox@railway.new 用 SSH key 直接登录适合当“复现车间”。能连私网就能碰到真实数据,生产项目里要谨慎
Cloud agents持久 VM 里跑 coding agent,会话可以离开再重连属于“agent 驻留远端”架构

通过 SSH 在容器里改的文件,下次部署就没了(挂载的 volume 除外)。修复要回写到代码或配置里,所以数据面只用来排查和临时止血。

RunPod:控制面在迁移,SSH 要选对

RunPod 有两种形态。Pods 是持久的 GPU / CPU 实例,可以 SSH 进去维护;Serverless 的 worker 是短暂的,维护对象是 endpoint 配置、镜像和环境变量,而不是某台机器。

能力怎么接给 agent 用时注意
REST API v2基址 https://api.runpod.io/v2Authorization: Bearer <API key>。pod 生命周期统一走 POST /v2/pods/{id}/action,body 是 {"action":"start|stop|restart|terminate"};改配置用 PATCH /v2/pods/{id};Serverless 在 /v2/serverlessv1(rest.runpod.io/v1)已弃用,官方文档一处写 2026-11-15、一处写 2026-12-01 下线,新集成直接用 v2。runpodctl 的 pod 增删启停目前仍走 v1,社区正在追问迁移时间
日志REST v2 提供 pod 日志、Serverless 日志和 worker 列表日志工具优先接这个,比 SSH 进去 tail 更稳
MCPhosted:https://mcp.getrunpod.io/,用 Runpod 账号 OAuth 登录,本地不存 key。local:npx @runpod/mcp-server,需要 RUNPOD_API_KEY。覆盖 Pods、Serverless、templates、network volumes、registry auth官方还有 skills 插件(router 加 runpod-mcp、runpodctl、flash、runpod-usage、companion-clis)。MCP 里有删除类工具,挂载时要过滤
SSH基础 SSH:所有 pod 都有,经 RunPod 代理(形如 <podid>-<hash>@ssh.runpod.io),不支持 SCP / SFTP。Full SSH:需要支持公网 IP 的实例、pod 里运行 sshd、暴露 22/tcp,支持 SCP / SFTP / rsyncagent 自动化用 Full SSH。账号里的公钥会自动注入,也可以用 SSH_PUBLIC_KEY 环境变量按 pod 覆盖,给 agent 单独一把 key
存储语义stop 会释放 GPU,保留 /workspace(volume disk),清空 container disk;container disk 重启就清;要跨 pod 持久,用 network volumeagent 写在 container disk 上的修改,stop 或 restart 后就没了。stop 要按高风险处理
API key三档:All、Restricted、Read Only。Restricted 能按每个 Serverless endpoint 单独给 None、Read Only 或 Read/Write细粒度只到 Serverless endpoint;管 pod 需要账号级的 API 读写权限,没法按 pod 限定,所以要在 harness 里做 pod ID 白名单。只看不改的会话发 Read Only key
已知问题有用户报告 v1 的 restart 接口返回 200,但 pod 没有真的重启任何变更之后都用 GET 复核状态,别只看返回码

给 agent 的原则:Pods 上的改动只落在 /workspace 或 network volume;环境依赖的变化要回写到镜像或模板,否则下一次 stop 就回到原样。

Fly.io、Render、Modal 和自有 VPS 也基本是“API / CLI / MCP 加 SSH”两条路,套用同一套工具层即可。自有 VPS 或裸机可以再加 Teleport 这类方案,用短期证书替代长期 SSH key,并录制会话。

工具怎么设计

给模型一个万能 bash 最省事,也最难管。更好的做法是把常用动作做成 typed tools,参数是 service、env、pod_id 这类 harness 能校验的枚举值,再留一个受限的 remote_exec 当逃生口。每个工具标上平面和风险等级,策略引擎按等级处理。

工具平面风险Railway 后端RunPod 后端
get_status控制最新部署与状态GET /v2/pods/{id}
tail_logs控制部署的运行时、构建、HTTP 日志REST v2 日志接口
remote_exec数据白名单:自动其余:审批railway ssh -- <cmd>Full SSH
fetch_file数据读(路径黑名单)scp / sftp 到 ssh.railway.comscp(Full SSH)
restart控制变更restart(不重新构建)action: restart
redeployrollback控制变更redeploy、rollback更新镜像或模板后重启
set_env控制变更服务变量PATCH pod 或 endpoint 的 env
open_fix_pr代码变更(CI + 人审)GitHub 自动部署构建新镜像后更新配置
stop_pod控制高风险action: stop(清空 container disk)
terminate、删卷、删服务控制不注册

安全清单

代码骨架

下面的骨架演示工具注册、风险分级、目标白名单、审批、审计和脱敏怎么串起来。SSH 用 asyncssh,HTTP 用 httpx。Railway 的日志、重启、回滚等具体 query / mutation 名称,请在 GraphiQL(railway.com/graphiql)里用 introspection 确认,这里只放官方文档里出现过的查询。

# remote_ops.py:自研 harness 的远程运维工具层骨架(Python 3.10+,示意用)
# pip install asyncssh httpx
import hashlib, json, os, re, time
from dataclasses import dataclass
from enum import Enum
from typing import Any, Awaitable, Callable

import asyncssh
import httpx


class Risk(Enum):
    READ = "read"        # 自动执行
    CHANGE = "change"    # 需要人工批准(staging 可放宽)
    # 破坏性操作(terminate、删卷、删服务)不定义成工具


# ---------- 数据面:SSH ----------
@dataclass
class SSHTarget:
    host: str        # Railway: ssh.railway.com;RunPod: pod 公网 IP
    port: int        # RunPod: 22/tcp 映射出的公网端口;Railway: 22
    username: str    # Railway: 服务域名或 service instance ID;RunPod: root
    key_path: str    # 专给 agent 的私钥,只存在于 harness 所在机器
    # 固定 host key,不要关掉校验;新 pod 首次连接先把 host key 写进这个文件
    known_hosts: str = os.path.expanduser("~/.ssh/known_hosts_agent")

    async def run(self, cmd: str, timeout: float = 60) -> dict:
        async with asyncssh.connect(
            self.host, port=self.port, username=self.username,
            client_keys=[self.key_path], known_hosts=self.known_hosts,
        ) as conn:
            r = await conn.run(cmd, check=False, timeout=timeout)
            return {"exit": r.exit_status, "stdout": r.stdout, "stderr": r.stderr}

# Railway 若用库直连遇到兼容问题,可以改成子进程调用:
#   railway ssh -s <服务> -e <环境> -- <命令>


# ---------- 控制面:Railway GraphQL ----------
class Railway:
    URL = "https://backboard.railway.com/graphql/v2"

    def __init__(self, token: str, project_token: bool = True):
        # project token 用 Project-Access-Token 头;account / workspace token 用 Bearer
        self.headers = ({"Project-Access-Token": token} if project_token
                        else {"Authorization": f"Bearer {token}"})

    async def gql(self, query: str, variables: dict | None = None) -> dict:
        async with httpx.AsyncClient(timeout=30) as c:
            r = await c.post(self.URL, headers=self.headers,
                             json={"query": query, "variables": variables or {}})
        r.raise_for_status()
        body = r.json()
        if body.get("errors"):       # 鉴权失败也可能是 HTTP 200 + errors
            raise RuntimeError(body["errors"])
        return body["data"]

    async def token_scope(self) -> dict:
        # 官方文档里的示例查询;其余字段名以 GraphiQL introspection 为准
        return await self.gql("query { projectToken { projectId environmentId } }")


# ---------- 控制面:RunPod REST v2 ----------
class RunPod:
    BASE = "https://api.runpod.io/v2"

    def __init__(self, api_key: str):
        self.headers = {"Authorization": f"Bearer {api_key}"}

    async def get_pod(self, pod_id: str) -> dict:
        async with httpx.AsyncClient(timeout=30) as c:
            r = await c.get(f"{self.BASE}/pods/{pod_id}", headers=self.headers)
        r.raise_for_status()
        return r.json()

    async def pod_action(self, pod_id: str, action: str) -> dict:
        if action not in {"start", "stop", "restart"}:   # terminate 永远不交给模型
            raise ValueError(f"action {action!r} not allowed")
        async with httpx.AsyncClient(timeout=30) as c:
            r = await c.post(f"{self.BASE}/pods/{pod_id}/action",
                             headers=self.headers, json={"action": action})
        r.raise_for_status()
        # 别只信 200:真实实现里应轮询到目标状态或超时,再返回
        return await self.get_pod(pod_id)


# ---------- 工具、策略、审计 ----------
@dataclass
class Tool:
    name: str
    risk: Risk
    fn: Callable[..., Awaitable[Any]]
    description: str
    schema: dict     # JSON Schema,转换成你所用模型 API 的工具定义

SAFE_PREFIX = ("nvidia-smi", "df -h", "free -m", "uptime", "ps aux",
               "ls ", "tail -n ", "journalctl -n ")   # 故意不放 cat
BLOCKED = (".env", ".ssh", "credentials", "id_rsa", "id_ed25519", "secret")
SHELL_META = re.compile(r"[;&|`$<>\\\n]")
SECRET = re.compile(
    r"sk-[A-Za-z0-9_-]{16,}|rpa_[A-Za-z0-9]{16,}|postgres(?:ql)?://\S+"
    r"|-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z ]*PRIVATE KEY-----")


def exec_needs_approval(cmd: str) -> bool:
    return (bool(SHELL_META.search(cmd)) or not cmd.startswith(SAFE_PREFIX)
            or any(b in cmd for b in BLOCKED))


def redact(text: str, limit: int = 20_000) -> str:
    text = SECRET.sub("[REDACTED]", text or "")
    if len(text) <= limit:
        return text
    return f"[前面省略 {len(text) - limit} 个字符]\n" + text[-limit:]


@dataclass
class Policy:
    targets: dict[str, SSHTarget]    # 登记过的远端目标
    pods: set[str]                   # 允许操作的 RunPod pod ID
    approve: Callable[[str, dict], Awaitable[bool]]   # 例如发到 Slack 等人确认
    audit_path: str = "audit.jsonl"

    async def allow(self, tool: Tool, args: dict) -> bool:
        if "target" in args and args["target"] not in self.targets:
            return False
        if "pod_id" in args and args["pod_id"] not in self.pods:
            return False
        needs = tool.risk is Risk.CHANGE or (
            tool.name == "remote_exec" and exec_needs_approval(args.get("cmd", "")))
        return await self.approve(tool.name, args) if needs else True

    def audit(self, tool: str, args: dict, allowed: bool, output: str = "") -> None:
        rec = {"ts": time.time(), "tool": tool, "args": args, "allowed": allowed,
               "sha256": hashlib.sha256(output.encode()).hexdigest()}
        with open(self.audit_path, "a", encoding="utf-8") as f:
            f.write(json.dumps(rec, ensure_ascii=False) + "\n")


async def dispatch(tools: dict[str, Tool], policy: Policy, name: str, args: dict) -> str:
    # 生产环境先用 jsonschema 按 tool.schema 校验 args,拒绝多余字段
    tool = tools.get(name)
    if tool is None:
        return "error: unknown tool"
    if not await policy.allow(tool, args):
        policy.audit(name, args, False)
        return "denied by policy"     # 明确告诉模型被拒,别静默失败
    try:
        out = json.dumps(await tool.fn(**args), ensure_ascii=False, default=str)
    except Exception as e:            # 超时、连不上也要回给模型
        out = f"error: {type(e).__name__}: {e}"
    policy.audit(name, args, True, out)
    return redact(out)


def build_tools(policy: Policy, runpod: RunPod) -> dict[str, Tool]:
    async def remote_exec(target: str, cmd: str):
        return await policy.targets[target].run(cmd, timeout=60)

    async def pod_status(pod_id: str):
        return await runpod.get_pod(pod_id)

    async def pod_restart(pod_id: str):
        return await runpod.pod_action(pod_id, "restart")

    pod_arg = {"type": "object", "required": ["pod_id"], "additionalProperties": False,
               "properties": {"pod_id": {"type": "string", "enum": sorted(policy.pods)}}}
    tools = [
        Tool("remote_exec", Risk.READ, remote_exec,
             "在登记过的远端容器里执行一条命令。只读诊断命令自动执行,其余需要人工批准。",
             {"type": "object", "required": ["target", "cmd"], "additionalProperties": False,
              "properties": {"target": {"type": "string", "enum": sorted(policy.targets)},
                             "cmd": {"type": "string", "maxLength": 500}}}),
        Tool("pod_status", Risk.READ, pod_status, "查看 RunPod pod 的状态与配置。", pod_arg),
        Tool("pod_restart", Risk.CHANGE, pod_restart,
             "重启 RunPod pod。container disk 会被清空,只有 /workspace 保留。", pod_arg),
    ]
    return {t.name: t for t in tools}

接到你的 loop 里:把每个 Tool.schema 转成所用模型 API 的工具定义;模型发出 tool call 时调用 dispatch(),把返回的字符串作为 tool result 送回去。approve 可以接 Slack 按钮、命令行确认或内部审批系统。Railway 的日志、重启、回滚按同样方式包成工具即可。

落地顺序

  1. 只读起步。get_statustail_logs 和指标,只发只读凭据(Railway project token、RunPod Read Only key)。先让 agent 学会“看”。
  2. 加变更工具和审批。restart、redeploy、rollback、set_env 全部走审批,staging 可以放开自动。
  3. 打开数据面。Full SSH 或 railway ssh,白名单只读命令自动,其余审批;截断和脱敏要在这一步之前做好。
  4. 打通修复闭环。在沙箱或 staging 复现,提 PR,CI 通过后自动部署,看日志和健康检查验证,失败就回滚。
  5. 值班化。用平台 webhook 或监控告警触发 agent;harness 要能断点续跑(事件日志持久化),审批改成异步(比如 Slack 按钮);把排障手册写成 skills 或 AGENTS.md。

沙箱在这里的位置

E2B、Daytona、Modal、Vercel Sandbox、Cloudflare Sandboxes、Runloop 这类沙箱平台,在远程运维里不是维护对象,而是车间。

来源

Railway

RunPod

架构与安全

沙箱平台