Cloud Agent 沙箱运维手册

这份手册按任务组织,不需要从头读到尾。开通主机、部署 Cloud Agent、保护保留的工作区、排查任务或网络故障时,来这里找对应的章节。控制平面负责任务 API、数据库、发布器、MCP 中继和管理设置;网关通过 HTTP 提供推理和身份认证。

先找到你要做的事:

要做的事

去看

准备主机和它的凭据

前提条件、凭据

启动或检查主机服务

自行部署运行

为不受信任的仓库开启隔离

配置 Kata

修改应用行为、集成或外观

Admin Settings

打开 PostgreSQL 管理界面

pgAdmin

浏览和筛选数据库里的记录

数据库浏览器

调整并发、沙箱设置或任务放置

需要多少 runner、任务在哪台机器上跑

在另一台机器上加一台主机

隧道与 sandbox manager 路由

升级已有的部署

升级说明

部署或回滚一个版本

滚动部署顺序、部署可靠性

保留旧数据库,或执行维护性迁移

旧迁移链转换、数据库重建、维护性迁移

保护工作区,监控磁盘用量

沙箱归档、保留的 Docker 存储、存储测量

排查连通性问题

沙箱网络、被拒绝时的提示

配置或排查工具和仓库访问

MCP 服务器、谁能对哪个仓库跑任务

回答 agent 正在等待的提问

agent 的原生提问

调查某个任务

任务的事件记录与审计

为运行时或 MCP 的改动挑选测试

运行时验证

让所有者访问他们自己的服务器

SSH 服务器

运维环境和团队环境

可复用的云环境

配置项都写在 ENVIRONMENT.md 里;管理员管理的 SSH 主机和 CloudLab 主机,见计算资源手册。排空前的检查、迁移回归测试、对端失败和队列告警,见部署可靠性。运行命令之前,先把所有 <...> 占位符替换掉。

Admin Settings

在 Admin → Settings 里管理这些内容:应用和集成的配置、发布、Web 外观、运维相关的时间间隔、上传和 skill 的限制、任务标题生成、沙箱和工作区的限制,以及沙箱归档。这些设置都存在 Agent 数据库里。

对应的 API 是 GET /admin/agent/settings 和 PUT /admin/agent/settings。请求体可以只包含要改的部分,例如 {"settings": {"max_attempts": 3}},没写的键保持原值。加上 "expected_revision",填入你读到的 revision,这样如果在那之后有人保存过设置,这次写入就会以 409 被拒绝。编辑器每次都会带上它,所以你确认应用的那次审查,绝不会覆盖审查里没显示出来的改动。写入走现有的管理员权限检查,并记录在 Audit log 里。应用设置默认是只读的,要先在某个分组上点 Edit。改好字段后点 Review changes,对照当前值和修改后的值,再点 Apply changes。整个分组会在一次请求里保存;Cancel 会丢掉这个分组的草稿。切换类别或刷新设置,都不会丢掉还没保存的修改。

凭据字段只显示有没有配置值。在编辑模式下点 Replace secret(或 Set secret)输入新值,审查时它仍然是隐藏的。替换时留空,已保存的凭据保持不变。编辑模式下,每个字段的 More actions 菜单里有 Restore default… 或 Clear secret…,都需要单独确认。如果默认值为空,恢复默认就等于清除这项设置。如果缺少必填字段,或者某个可选集成只配了一半,Administrator setup required 提示会链接到还需要处理的字段。

Admin Settings 是运维级别的权限,agent.admin 只能授予被信任来运维这套部署的人。因为它能修改会话和主机的签名密钥,以及网关公钥的地址,管理员因此可以冒充任何用户登录;它还能修改 GitHub API 的地址,而 App 的凭据、发布令牌和用户自己的 GitHub 凭据,都会发往这个地址。已保存的凭据只能写入、不能读出;网关的 dispatch 令牌和网关 API 地址的源绑定在一起:令牌还在时,不能把这个地址改到另一个源。要改的话,先清空令牌,保存地址,再重新填入令牌。

升级之前,按升级说明执行迁移,然后用现有的完整环境变量启动主控制平面监听器。它会把已登记的环境变量设置导入一次;MCP 专用监听器则等初始化完成,再读取同样的设置。这次迁移新增的运行时身份、服务密钥、sandbox manager 凭据和策略字段,只有在缺失时才会导入,遵循同样的、原子化的"已有值不覆盖"规则。

之后再部署,已保存的管理设置都会保留;重置某个字段,也绝不会恢复成 .env 里的旧值。各监听器每五秒同步一次已保存的改动。Web 服务器每次处理请求都会读取已保存的公开外观字段,读取响应和内容的时间上限是 400 毫秒。如果超时、后端不可用或响应无效,就用这个 Web 进程针对同一个控制平面地址最近一次读到的有效外观;第一次成功读取之前,用中性的默认显示。读取成功后,下一次打开页面就能看到保存的修改。这份备用数据里不包含任何会话或访问权限的判断,Web 进程重启后也不会保留。至于改动何时影响后续操作,以每项设置旁边的说明为准。

导入已登记的环境变量设置时,如果 GATEWAY_GRANT_DISPATCH_TOKEN、AGENT_ONCALL_DISPATCH_TOKEN、AGENT_CONTROL_TOKEN_SECRET 或 AGENT_SOURCE_CONTROL_ENCRYPTION_KEY 短于 32 字节,就会拒绝:监听器会在写入任何内容之前停下,并在报错里指出是哪个变量,所以回滚是安全的。第一次部署这个版本之前,先检查一下它们的长度。换掉其中任何一个都有相应的代价:dispatch 令牌要两边同时改;新的控制密钥需要重新注册主机;新的加密密钥会让已保存的用户连接全部失效。已经保存在 Admin Settings 里的值不受影响。同样的导入还会拒绝两种值:不是 https://github.com 地址的 AGENT_GITHUB_APP_INSTALL_URL,以及不是 HTTPS(localhost 上的 HTTP 除外)的 AGENT_GITLAB_OAUTH_REDIRECT_URI,因为它们的连接流程根本用不了这样的值。

新版本可能会收紧某项设置允许的取值。以前保存的值如果不再被接受,监听器也不会停:每个设置修订,每个监听器会记一次 saved setting not applied as stored: …(只写键名,绝不写值),并改用内置默认值。唯一的例外是签名密钥、加密密钥或服务令牌:如果它们短于新要求的最小长度,在被替换之前会继续使用。编辑器显示的是当前实际生效的值;保存一个有效的值,或者确认 Restore default,警告就会消失。如果保存的凭据无法解密,或者根本没有加密,监听器仍然会停止。

全新安装时,需要准备:数据库;一个固定不变、至少 32 字节的 AGENT_SETTINGS_ENCRYPTION_KEY;一个单独的随机 ADMIN_TOKEN;以及精简版 deploy/control-plane/.env.example 里的基础设施和构建字段。在注册执行主机、跑常规的部署就绪检查之前,先把 setup 界面启动起来:

AGENT_DEPLOYMENT_SHA=$(git rev-parse HEAD) docker compose -p cloudagent \
  --env-file .env -f deploy/control-plane/docker-compose.yml \
  up -d --build control-plane mcp-relay web

打开 <web base path>/setup,输入恢复令牌。这个页面不受登录检查限制,但用的是普通的管理员 API 和审计记录,没有认证的访问者既看不到也改不了设置。刷新或锁定页面,内存里的令牌就会被清掉。把网关登录、执行相关和用户凭据相关的字段填完,然后注册主机,执行平常的部署命令。可选的集成只有开始配置之后才需要填完。shared-live 部署关卡仍然要求主机通过资格验证并处于就绪状态。

已有的安装可以继续用环境变量 AGENT_SESSION_SECRET 充当设置的根密钥。在删掉这个旧的环境变量之前,先把 AGENT_SETTINGS_ENCRYPTION_KEY 设成同一个原始值。两个监听器拿到的实际密钥必须相同。它要和数据库一起备份。要改用另一把密钥,得先把已保存的设置从旧密钥重新加密成新密钥,并协调好两个监听器的上线;只改环境变量的话,已有的密文就再也读不出来了。已保存的会话签名密钥,可以独立于这把固定的根密钥单独修改。已保存的用户加密密钥和计算资源加密密钥,继续保护各自的凭据存储;没有单独的凭据重新加密流程时,API 不允许替换或重置已有的加密密钥。

数据库访问、设置的加密根密钥、应急恢复认证、主机开通、网络和监听器、主机实际使用的隔离方式、构建来源、挂载的文件,以及受信任的计算资源部署配置,仍然属于部署配置。这些初始化配置的名字会显示在页面上,值是隐藏的。见环境变量清单。

沙箱归档存储

这一节配置的是显式操作 Archive to S3 and delete 需要用的归档。自动的生命周期归档用的是 Admin → Settings → Sandbox 下另一套策略,见沙箱归档。

开启"删除已停止的沙箱"之前,先配置 Admin → Settings → Application → Sandbox archives:设置 S3 桶,以及可选的区域、兼容 S3 的 HTTPS 端点和对象前缀。对应的 AGENT_SANDBOX_ARCHIVE_S3_* 环境变量,只在这些字段刚引入时导入一次;之后再改环境变量,不会覆盖已保存的设置。桶留空的话,删除功能就不可用。

在浏览器里,桶这一栏也可以直接填 S3 URI。保存 s3://example-bucket/cloud-agent/sandbox,会在一次更新里把桶设为 example-bucket、前缀设为 cloud-agent/sandbox。如果 URI 只到桶的根目录,就会清空前缀;只填桶名,则前缀保持不变。直接调用 API 更新,或者首次从环境变量导入时,桶名和前缀必须分别填在各自的设置里。

给主控制平面配置 AWS SDK 的凭据链,最好用 IAM workload role。归档用的角色需要对配置的归档前缀有 s3:PutObject 和 s3:GetObject 权限;HeadObject 用的是 s3:GetObject。如果桶上启用了 SSE-KMS,还要授予相应的 KMS 权限,包括 kms:GenerateDataKey 和 kms:Decrypt,这样带校验和的 HEAD 请求才能核实对象。应用使用归档存储并不需要 s3:DeleteObject。这些手动删除用的凭据只配置在主控制平面上;所属的 sandbox manager 拿到的只是一个针对这次请求的预签名上传地址。基础 Compose 不会转发 AWS 凭据相关的变量。如果用环境变量提供凭据,就建一个只作用于主服务的私有覆盖文件,例如:

services:
  control-plane:
    environment:
      AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:?Set the archive access key}
      AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:?Set the archive secret key}
      AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}

覆盖文件放在仓库目录之外,并在部署根目录的私有 .env 里,把它的绝对路径设为 AGENT_CONTROL_PLANE_COMPOSE_OVERRIDE_FILE。部署脚本会确认它是一个可读的普通文件;无论源码模式还是产物模式,每次对这套服务做操作时,都会把它加在发布自带的 overlay 之后。这项设置留空,就只使用评审过的部署文件。如果用共享 profile,就把凭据文件和配置文件以只读方式挂载进这个服务,并把 AWS_SHARED_CREDENTIALS_FILE、AWS_CONFIG_FILE 和 AWS_PROFILE 设为它们在容器里的路径和要用的 profile。区域可以用已保存的归档区域设置,也可以在同一个覆盖文件里提供 AWS_DEFAULT_REGION。这些手动删除用的凭据挂载和变量,只用在主控制平面上。自动生命周期归档那套策略,另外用的是受信任的 sandbox manager 的 workload identity;两条路径都不会把 AWS 凭据交给沙箱容器、Web 应用或 MCP 中继。

主机会拿到一个 15 分钟内有效的预签名 PUT,只能上传某一个请求对象,并且字节长度、整个对象的 SHA-256 校验和以及 If-None-Match: * 都是固定的。单次 PUT 的归档上限是 5 GiB;超过上限的归档,对应的容器会保留下来。如果上传其实已经成功了,重试时可能会遇到 HTTP 412。控制平面会自己以 ChecksumMode=ENABLED 读取 HeadObject,只有拿到的 SHA-256 和字节长度与服务核实过的完全一致,才会继续删除。ETag、自带的元数据、缺失的校验和或组合校验和,都替代不了这项核实。兼容 S3 的存储必须实现这种条件 PUT 和校验和的语义。

这些归档要放在私有桶里,禁止公开访问,并配好加密、保留期和恢复时的访问权限。条件 PUT 能防止签发出去的上传权限覆盖已有的请求对象;归档角色和其他有权限的主体,也必须维持这种不可变性。桶策略可以强制要求条件写入,版本控制和 Object Lock 则能更好地防止日后有人在管理层面改动。只要删除或恢复可能还依赖某个归档,就不要让它过期。持久化的回执里记着它最初的 S3 URI、SHA-256、字节长度,以及能拿到时的对象版本,所以即使改了设置,已有删除请求的核实也不会被导向别的地方。

数据库浏览器

打开 Admin → Database,就能在仪表盘里直接查看 Agent 数据库。选一张表,查看它的列和类型,加上筛选条件,选好排序列和每页条数,然后点 Run query。用"上一页""下一页"翻看结果。多个筛选条件之间是 AND 关系;Contains 筛选按字面、不区分大小写地匹配子串。这个浏览器用的是控制平面现有的数据库连接,不用开启 pgAdmin 也能用。

内置浏览器只开放 Agent 表里评审过的元数据列。保存的凭据、用户的原始内容,以及内容不透明的配置文档,都不会显示。它只支持只读查询:筛选条件参数化、排序结果稳定、返回条数有上限,并且有语句超时。每个请求都会检查当前是否有 agent.admin 权限。查询的结果会记录在 Audit log → Database 下;审计记录里有查询的元数据,但没有筛选值,也没有返回的数据。

对应的 API:GET /admin/agent/database/tables 返回能看到的 schema,POST /admin/agent/database/query 返回筛选后的数据。要写数据库或执行任意 SQL,仍然可以用下面单独配置的 pgAdmin 服务。

pgAdmin

打开 Admin → Database → Open pgAdmin 来管理 PostgreSQL。页面会先检查这个私有服务是否可用,再显示链接。pgAdmin 的页面、静态资源、表单、下载和 SQL 请求,都通过现有的同源 API 代理转发,所以网关和独立的 edge 都不需要新的公开路由或端口。每个请求都要求当前有 agent.admin 权限。pgAdmin 保留它自己的登录和 CSRF 检查;Agent 的 cookie 和 bearer 凭据绝不会转发给上游。

pgAdmin 的页面运行在 Agent 自己的源上,而在这个源上,管理员的 Agent 会话可以访问每一条管理路由。所以代理会在每个 pgAdmin 响应上再加一份 Content-Security-Policy,和 pgAdmin 自己的策略同时生效:pgAdmin 只能在公开的 pgAdmin 路径下发请求、提交表单、打开 iframe 和启动 worker。代理还会发送 Cross-Origin-Opener-Policy: same-origin,这样 pgAdmin 页面打开别的 Agent 页面后,拿不到那个页面的引用;代理自己返回的错误页面也放在沙箱里。这样能限制被注入到 pgAdmin 里的脚本借用会话所能做的事,但它并不是一道源之间的隔离边界。pgAdmin 仍然和这个源共用浏览器存储;如果控制平面前面的 edge 在 pgAdmin 路径下返回了一个不带这些响应头的错误页面,那个页面也不受这套策略约束。只有给 pgAdmin 单独一个源,有自己的主机名、TLS 和 edge 路由,并使用 Agent 会话 cookie 以外的代理凭据,才能真正不共用会话;这个版本没有提供这种方式。在那之前,请保持 pgAdmin 镜像的固定版本和它默认的安全响应头(它自己的策略会拒绝不带本次请求 nonce 的内联脚本),只在确实要用的地方启用这个 profile,并且只把 agent.admin 授予运维人员。

在控制平面部署的 .env 里启用这个可选服务:

COMPOSE_PROFILES=pgadmin
AGENT_PGADMIN_BOOTSTRAP_URL=http://pgadmin:5050
AGENT_PGADMIN_BOOTSTRAP_DIR=/var/lib/cloud-agent/pgadmin-bootstrap

保留已有的 profile,例如写成 COMPOSE_PROFILES=edge,pgadmin。然后按常规流程部署控制平面。已有的部署可以用下面的命令启动这个服务,并让新的控制平面环境变量生效:

docker compose --env-file .env -f <selected-compose-file> up -d pgadmin control-plane

产物部署要用发布生成的 Compose 文件,自带的服务器定义会从所选的 recipe 里取。如果还没有保存过私有 pgAdmin 地址,首次 setup 也会把连接信息保存到 Admin → Settings → Database tools 里。默认的公开地址是已保存的 Web 基础地址加上 /api/admin/agent/pgadmin/;如果 API 的公开前缀不一样,请在 setup 之前设置 AGENT_PGADMIN_BOOTSTRAP_PUBLIC_URL。之后要改,就用 Admin Settings 里的 Private pgAdmin URL 和 Public pgAdmin proxy URL。已有的 AGENT_PGADMIN_URL 和 AGENT_PGADMIN_PUBLIC_URL 值,会和其他运行时设置一起导入一次。

Private pgAdmin URL 只能选部署里指定过的 pgAdmin 服务,不能填任意别的地址。自带的 http://pgadmin:5050 总是允许的;控制平面环境变量里 AGENT_PGADMIN_URL 和 AGENT_PGADMIN_BOOTSTRAP_URL 的协议、主机和端口也是允许的。保存成其他任何地址,仪表盘、打开链接和代理都会直接返回 503,根本不会去连那个地址。原因是:代理会原样转发所有方法、路径和请求体,再把回复带回来;如果管理员能随便填地址,就能借此访问云元数据端点,或者控制平面能访问到的任何其他私有服务。要在别处运行 pgAdmin,就在控制平面的环境变量里把它的地址设为 AGENT_PGADMIN_URL,重启控制平面,再在 Private pgAdmin URL 里选这个地址。导入之后,这个变量也要继续保留。

打开 Admin → Database,设置一个管理员邮箱,以及至少 12 个字符的密码。只有当前的 Agent 管理员才能完成这一步 setup。服务会一直等到首次 setup 完成;之后页面会检查服务是否就绪,并显示 Open pgAdmin。系统不自带任何管理员账号或默认密码。多人同时进行 setup,也不能替换掉第一次设置的凭据。初始凭据存在私有的 agent-pgadmin-bootstrap 卷里;部署时要保留它和 agent-pgadmin。pgAdmin 以只读方式挂载这个初始化卷。

用你设置的 pgAdmin 邮箱和密码登录。初始的服务器定义指向 postgres:5432,数据库和用户都是 cloudagent。连接时输入这套部署的 PostgreSQL 密码。如果数据库名或用户不一样,要么在初始化之前提供一个自定义的 AGENT_PGADMIN_SERVERS_FILE,要么在 pgAdmin 里修改这个连接。数据库密码不会被预先填好,也不会在仪表盘上显示。保存的账号和连接存在另一个卷 agent-pgadmin 里。setup 不会重置已有的账号,容器重启时也不会。之后的账号管理和改密码,都在 pgAdmin 里做。

如果是只有 HTTP 的本地环境,除了现有的本地 Agent cookie 设置之外,还要设置 AGENT_PGADMIN_COOKIE_SECURE=False。HTTPS 环境保持默认的 True。如果仪表盘显示不可用,就查看 docker compose logs pgadmin 和它的健康状态。首次 setup 之前,正在等待的容器是健康的,并会在日志里写 Waiting for first-time setup in Admin -> Database.。setup 之后,如果初始化存储读不了,或者 PostgreSQL 不可用,pgAdmin 就无法启动。setup 会记录 agent.database.pgadmin.setup。如果 setup 时写不进初始化卷,会记为失败;把卷修好后,在同一个页面重新 setup。打开 pgAdmin 会在 Agent 审计日志里记录 agent.database.pgadmin.open,无论是通过打开链接,还是在任意地址上开启一个新的 pgAdmin 会话的第一个请求。Agent 审计日志不记录 SQL:pgAdmin 以数据库所有者的身份连接,可以修改任何表,审计日志也不例外,所以要把 pgAdmin 访问权限等同于完整的数据库权限。代理会给 pgAdmin 的响应加上 frame-ancestors 'self'、nosniff 和 X-Frame-Options: SAMEORIGIN;pgAdmin 自己的策略仍然允许它的内联脚本。在 Admin Settings 里重置 Private pgAdmin URL,等各监听器同步之后,访问就会被关闭;去掉这个 profile 并停掉 pgAdmin,它的卷会保留下来。

账号和服务器的初始化行为,见 pgAdmin 的容器文档。

一个任务会做什么

POST /v1/agent/jobs             owner queues work
  → enrolled host claims        exact shared-live capability attestation
    → runner creates/starts     one retained Docker group and its writable layer
      → native Agent CLI        one live process/session per member chat
      → normalized events       streamed to the owner over SSE
      → model calls             billed through the gateway into api_logs
      → optional direct push    member's GH_TOKEN; skips the publisher's checks
  → turn settles                nothing is published

POST /v1/agent/threads/{id}/publish   owner asks (Create PR / Update PR)
  → quiescent group snapshot    immutable diff for the trusted publisher
  → publisher validates         .github/ block, secret scan, path escape, size
    → agent/<origin-chat-id>    one stable branch per workspace group
      → draft PR                merges follow the repository's GitHub rules

平台只在所有者要求时才发布,见 Resume、Fork 与发布。agent 自己也可以推送、创建 pull request;Watch PR and merge 会让它在 PR 满足所需的检查和评审之后合并。至于哪些改动可以在没人参与的情况下合并,由仓库的分支保护和其他 GitHub 规则决定。

下面这些凭据边界不能被破坏:

  • 长期有效的仓库凭据不会进入沙箱。 主机在沙箱组静止时取出 diff;用来发布的 GitHub 令牌由控制平面的发布器持有。runner 持有一个克隆用的凭据,但它是只读的,只限这一个仓库,并且刻意不写进 .git/config:检出时不添加 remote,凭据通过 git 的环境变量而不是命令行参数传递;如果 runner 在 .git/ 下的任何地方发现了这个令牌,就拒绝启动 agent。

  • 沙箱里唯一的仓库凭据是短期的。 配置了 GitHub App 时,所属成员会拿到 GH_TOKEN:这是一个 App 安装令牌,只对任务所在的仓库有 contents: write 和 pull_requests: write 权限,大约一小时后过期。用它推送的内容不经过发布器的补丁检查。见凭据。

  • 沙箱里还有两个范围很窄、不能互相替代的凭据。 一个是网关签发的 agr 授权,让沙箱以对话所有者的身份调用模型:它写明了允许使用的模型,只对一个成员 residency 有效,并且只有发放时的那台主机、那个成员代次一直在发心跳,它才会续期,花费记在所有者的网关每日配额上。另一个是本服务签发的、带 attempt 栅栏的 mcp 令牌,只能访问 MCP 中继,并且只能访问对话当前绑定所授权的那些服务器。没有按任务设置的花费上限;推理消耗的是所有者的网关配额。这两个凭据都不能写事件日志、上传替换的补丁,也不能把任务转入终态;能做这些事的令牌在受信任的 runner 手里。

常驻进程的推理授权,签发时使用的身份里包含成员持久的 residency 代次。对同一个代次重试签发,拿回的是同一个授权;换了进程,或者用新的代次重试 Fork,则会签发一个新授权,而不是把已撤销的旧授权找回来。替换 worker 时,会尽力撤销之前的授权,和 Fork 的清理方式一致;即使网关的撤销服务出了故障,也不影响签发新的授权。

临时工作区

用 "repo": null 创建的任务,会开始一个不关联任何仓库的对话。任何有 agent.use 的用户都能创建;不需要检查仓库授权,认领时也不带克隆凭据或 GH_TOKEN。主机会在 /workspace 新建一个 Git 仓库,在 main 上只有一个空 commit,每台主机上都完全一样(77bbec2039abcd5c7663078db4f90f3c860a751a),控制平面把这个 commit 记为任务的 base。如果请求里还写了 base_ref、base_sha 或 environment_id,会被拒绝,因为它们指的都是某个仓库里的东西。这种对话的环境由三部分组成:平台的基础环境、所有者的全局变量,以及任务自己的覆盖值。

这类对话的轮次结束时不会拍工作区快照,所以发布器拿不到任何内容,也不会创建分支或 pull request。除此之外,它就是一个普通的对话:归档、删除、Fork、Side Chat、目标、MCP、保留、检查点和 S3 归档,都和有仓库的对话一样;只是不会根据它生成项目记忆。在 GET /v1/agent/jobs、/conversations 和 /sessions 上加 temporary=true,就只列出这类对话,而且不能和 repo 一起用;项目列表和分面列表里,它们显示为 repo: null。

GET /v1/agent/jobs/{job_id}/workspace/archive 会把一个对话的整个 /workspace 打成一个 gzip 压缩的 tar 包下载下来,任何保留对话都可以,有仓库的也一样。所属主机会先把它压缩到自己的磁盘上,全部完成后才响应,所以工作区如果超过主机的 workspace_download_max_mib(默认 1024 MiB,按压缩前计算),会直接以 413 拒绝,而不是下载到一半被截断。把文件挪到 GitHub 上这件事交给 agent:连上可写端点的 GitHub MCP 服务器之后,它可以建一个仓库并推送上去。

如果配置的 GitHub App 加载不了(见凭据一节),创建临时工作区也会和其他任务一样返回 503。

自动生成任务标题

新对话一开始用提示词里第一行有意义的内容做标题,截短到 36 个字符。认领派发出去之后,还需要标题的对话,会用这个 attempt 现有的推理授权和所选模型,在后台向 GATEWAY_BASE_URL 发一个请求。这是一个独立的请求,不是一个 CLI 轮次,不会改动任务提示词或对话内容。它总是根据对话最初的提示词来概括,即使是之后某一轮重新补取标题时也一样。每次调用最多用 4,000 个输入字符和 256 个输出 token,10 秒超时,并记在同一个所有者和当前任务名下。

生成的标题用用户的语言概括任务,并保留相关的标识符。代理返回的 ⏳ The model is starting up 是模型服务器正在预热的状态,不是生成好的标题,即使网关把它当作 HTTP 200 的补全返回也一样。遇到预热响应、网络传输失败和临时性的 HTTP 错误,最多调用五次,每次之间退避等待,总时长不超过 120 秒。其他格式不对的响应,包括只有推理内容的输出,都保留原来的备用标题。如果这段时间用完了,之后的认领还可以再试;对话空闲时不会在后台重新生成。界面会在最多 130 秒内刷新符合条件的标题,不用等正在跑的轮次结束;如果对话已经空闲后标题才生成出来,会通知导航列表更新。

手动改名总是优先,哪怕改成一模一样的文字也算;每次重试都会重新检查标题归谁所有,以及 attempt 栅栏。后续消息会沿用有效的对话名称,Fork 出来的对话也会继承它。以前自动生成或继承来的、内容是预热状态的标题,可以在下一次认领时修正。其他已有的标题保持不变,不需要做任何标题数据迁移。

改写草稿提示词

POST /v1/agent/prompts/rewrite 只是帮你准备一份草稿,不会创建任务,也不会启动沙箱。调用需要所有者会话,并且当前有 agent.use 权限。请求里包括 prompt、model,可选的 clarify,以及可选的 answers(question / answer 组成的列表)。如果设了 clarify: true 并且没有答案,它会返回两到四个澄清问题;否则返回一份完整的提示词,供所有者提交之前过目。带了答案的请求,一定会给出改写结果,不会再问一轮。

每次调用都会按所有者当前的模型目录确认所选模型,并使用一个新签发的、只限这个模型、有效期 120 秒的授权。它的操作 ID 以 prompt-rewrite- 开头,所以推理仍然受所有者网关配额的约束,但和任务的用量分开统计。请求结束后授权就被撤销,从不续期。整个调用最多 90 秒;草稿和改写后的提示词都不能超过 32,000 个字符,澄清答案最多四个。模型返回的内容无效、被截断或处于预热状态时,会作为错误报告,不会覆盖原来的草稿。

对话状态和运行历史

对话界面显示四种状态:Queued、Running、Idle 和 Failed。只要有轮次在运行,就显示运行中,排队的轮次靠后;没有活跃轮次时,看最新一轮的结果来决定是失败还是空闲。正常完成的轮次和被用户停止的轮次,都会让对话变为空闲。打开以前的某一轮,不会改变这个对话状态。所有者 API 把它作为 conversation_state 返回,和所选任务自己的 state 互不影响。

每一轮都保留自己的结果:完成、失败或取消。执行历史默认折叠,只有某一轮有多个 attempt 时才会显示。管理后台的运行详情会直接显示服务器和时间,也可以查看以前的 attempt。选择历史记录只是换一份记录来看,不会重启任务,也不会换服务器。这些显示上的规则,不影响任务的调度和重试。

agent 的原生提问

Claude Code 的 AskUserQuestion、Codex 请求用户输入,以及 OpenCode/Kilo 的提问请求,都会在轮次等待时弹出一个提问对话框。选一个选项,或者在允许时填写自己的答案,提交后,同一个原生工具调用就会继续。Answer later 关闭对话框,先不回答;Review question 重新打开它。Skip 表示明确拒绝这次请求。

Claude Code 的 ExitPlanMode 也用同一个对话框,展示它的计划,请你批准。选 Approve plan 并提交,Claude 就会退出计划模式,开始动手实现。Skip 表示不批准这个计划,让 agent 继续规划;Answer later 让这个原生的批准请求一直等着,直到所有者回答。进入计划模式、读取和更新计划文件,都由 Claude 在原生会话里自己处理。

页面从常驻的 agent 那里读取待回答的问题,所以重新加载对话,还没回答的请求会再显示出来。每个答案都标明了它对应的任务、attempt 和请求。所有者 API 和主机都会核对这个身份:已停止、已完成或已被替换的 attempt,都不会接受过时的答案;问题的答案也绝不会被当作后续消息排队。

runner、Sandbox Manager 和重新构建的沙箱镜像要一起部署。http-agent-live-bridge 辅助程序固定为 4(见原生会话的上下文记忆);版本 2 及更早的辅助程序执行不了原生提问的回复,而低于固定版本的辅助程序,都过不了轮次开始时的证明检查。启动替换的 OpenCode/Kilo 进程之前,主机会把自己受信任的 bridge 装进保留容器里,没有 MCP 连接的对话也一样。这样辅助程序就更新了,工作区和原生会话状态仍然保留。

谁能对哪个仓库运行任务

一个仓库至少要有一种授权:用户自己的 GitHub 连接,或者部署的白名单。两种都没有时,创建任务一律拒绝。

用户连接。 用户通过配置好的前端回调授权 GitHub App。控制平面用授权码换取用户令牌,在 agent_github_accounts 里记下这个 GitHub 账号,并且只把 GitHub 报告的那些安装存进 agent_repo_grants。重新连接时,用户已经访问不到的安装,对应的授权会被删掉。

输入框、创建任务、认领、读取工作区和发布,都会通过 /user/installations/{id}/repositories、以用户自己的身份检查仓库。只有用户能推送的仓库才算数,因为最后的结果是一个分支和一个 pull request。每个用户的查询结果缓存一分钟。不要把这项检查换成用安装令牌列出仓库:GitHub 可能会把一个组织的安装报告给一个只能访问其中某一个仓库的协作者,而用安装令牌去列,就可能把其他仓库也暴露出来。

用户令牌只留在控制平面里,用 AGENT_SOURCE_CONTROL_ENCRYPTION_KEY 加密保存。会过期的 App 用户令牌有效期八小时,配有一个六个月内有效、只能用一次的刷新令牌。刷新会在过期前五分钟开始,并且在一个记录级租约下进行,所以同时发起的多个请求不会把同一个刷新令牌用两次。没有保存令牌的连接,包括旧式的连接和被 GitHub 拒绝的连接,都不授予任何访问权限。这时输入框会让用户重新点 Connect GitHub;如果之前授权过,GitHub 会跳过授权确认页。

连接账号和安装 App 是两个独立的步骤。如果 App 没有装在用户自己的账号上,即使某个组织的安装已经给了访问权限,连接流程也会把 GitHub 的安装页面提供给用户。用户可以选一个账号和仓库,也可以选 Not now。已经连接、但一个仓库都没有的用户,会直接被带到那个页面,而不是再走一遍 OAuth。Integrations 里的 Configure repositories 和输入框里的 Configure repository access,打开的都是同一个页面。

部署白名单。 AGENT_REPO_ALLOWLIST 用于单租户场景,针对运维方自己的仓库。它不是按用户区分的:每个能用 Cloud Agent 的用户,都可以让平台用部署的 GitHub 凭据克隆、发布到列出的每一个仓库,所以这里只能列所有用户都可以动的仓库。当 App 的 OAuth 凭据和 AGENT_GITHUB_APP_INSTALL_URL 都配好了时,输入框仍然要求先连接 GitHub,才能选择任何仓库,白名单里的也不例外;在那之前,任务会在临时工作区里运行。创建任务的接口会单独按白名单判断。

断开连接。 在 Integrations 的 Manage 菜单里点 Disconnect GitHub,会删除账号记录、加密的令牌和安装授权,并请 GitHub 撤回授权。在 GitHub 上卸载 App,会收回平台对这些安装的访问权限。

沙箱清单、资源用量与操作

管理控制台的 Sandboxes 标签页,默认列出所有记录为运行中的沙箱组,包括在轮次之间保留下来的容器。用状态筛选器,可以把已停止、启动中、停止中或失败的沙箱组也列出来。Fork 出来的成员共用同一行容器记录。清单每 30 秒刷新一次,会把 GET /admin/agent/sandboxes 的每一页都取回来,这个接口需要管理员权限。API 的默认筛选仍然是 state=running;state=all 会包括所有生命周期状态。

搜索会在已加载的全部清单里匹配会话标题、仓库、所有者、主机、agent 版本和容器标识,可以和主机、活动状态筛选一起用。点列标题切换排序;Sort by 里还可以按主机、状态、agent 和创建时间排。CPU、内存、会话数和时间都按原始数值排序,没有数据的排在最后。取消勾选 Auto-refresh,清单和展开详情的自动刷新都会暂停;手动刷新和测量数据的"多久以前"提示仍然有效。

打开一个沙箱的详情,可以看到它的成员会话、主机、运行时、容器身份和生命周期。成员详情里有会话链接和活动状态,但不会暴露原生会话的标识、凭据、提示词,或者容器的原始诊断信息。

Stop idle sandbox 会请求停掉这个共享容器,同时保留它的工作区和原生会话,留给之后的轮次用。它会作用于组里的每一个成员。API 接受请求之前,会核对当时看到的容器、代次和最后活动时间;如果沙箱组正忙、有快照或发布在等待处理、开着终端,或者终端的观察数据已经过时,都会拒绝。请求会记在管理审计日志里。主机停容器之前,自己还会再检查一遍是否空闲。如果终端数量报告为零、缺失,或者已经超过 90 秒,打开或刷新详情时会直接向所属的 sandbox manager 查询这个沙箱组的统计。这只检查选中的这个容器,并统计它所有成员的终端;无关的容器和整台主机范围的调和,都不会拖慢这项检查。完整的清单调和仍然走主机心跳那条路。停止请求在关闭准入之前,也会核实终端活动:较旧的 sandbox manager 可能只报告源会话的终端,即使另一个成员正在使用。这样也兼容了心跳里不带终端数量的 runner。报告必须和当时看到的容器、代次一致;sandbox manager 不可用或活动情况未知时,停止按钮保持禁用,并提示需要做什么来恢复。资源统计失败,不会让一份本身有效的终端报告作废。在主机的观察确认已经停止之前,页面会一直显示这个待处理的请求;离线的主机没法完成它。停止还在等待期间,新的轮次会等着。这个操作不会删除容器,也不会释放它占用的磁盘空间。

Archive to S3 and delete 会在一个已停止或已死亡、并且空闲的容器,完整的保留文件系统上传到 S3 并通过核实之后,删除这个容器。桶、区域、可选的 S3 端点和对象前缀,在 Admin → Settings → Application → Sandbox archives 里配置。权限、凭据怎么提供,以及对兼容存储的要求,见 S3 存储设置。

所属的 sandbox manager 准备归档期间,这个沙箱不接受新的轮次、Fork 和工作区操作。归档里有 manifest.json 和 container-rootfs.tar,包括工作区文件和保留的原生会话文件。超过 5 GiB 的归档,在删除之前就会被拒绝。S3 上那个不可变的对象,SHA-256 和字节长度都必须对得上,并且它的回执必须先写进数据库,主机才能删除那个审查过的、确切的已停止容器。上传失败的话,容器会保留。归档一旦核实通过,如果删除的确认结果不明确,准入会继续保持关闭,并重试同一个请求。sandbox manager 把删除回执存在自己的持久卷上,这样即使 sandbox manager 重启,确认结果也不会丢。

Delete without archive 删除的也是那个确切的、已停止或已死亡且空闲的容器,只是不上传工作区文件和原生会话历史,所以不需要 S3 桶。主机仍然会拒绝正在运行、正忙或已经变化的容器;控制平面要等主机确认删除之后,才把原生恢复标记为丢失。这台主机离线或者已经移出资源池时,详情里会写明这一点,以及要等什么才能结束:主机重新连上,或者重新注册。唯一的例外,是服务商已经确认其服务器 terminated 的主机:这台主机 10 分钟没有动静之后,就认为容器已经随机器一起没了,删除不等它也会完成,这和把这台机器上其他沙箱标记为丢失用的是同一条规则。它的轨迹和审计会记下 host_reclaimed。对话历史和生命周期审计都会保留,但没有可以拿来恢复的 S3 对象。如果已经有一个归档删除在进行中或已经核实,这个操作不会去动它;先等它完成或失败,再选这个操作。

删除由一个 worker 负责,按请求到期的先后一个一个处理。某次尝试失败了,会显示错误,并在一分钟后重试,排在所有已经在等的请求后面。所以即使有些请求的主机一直超时,它们最多也只会让更新的请求各等一次尝试,而不会把它永远卡住。

已删除对话的沙箱会被自动删除。 所有者永久删除了一个沙箱里的最后一个对话后,删除 worker 会请所属主机把它停掉,然后通过同样的两条路径安排删除:配置了桶,就先归档到 S3;没有配置,就直接丢弃。只处理在线主机上的沙箱,每分钟巡检一次,相关的事件和审计记录里,操作者记为 user-deletion-sweeper,来源记为 system。被删对话的数据库记录会保留。只要还有别的对话在用某个沙箱,就绝不会动它。升级后的第一次巡检,还会把以前所有对话都已删除的旧沙箱一并处理掉,所以会看到一批集中的停止和删除;如果这些沙箱需要保存在 S3 里,请先配置好归档桶。

Verify stopped and force delete 用来处理一个仍记为运行中的沙箱,但前提是所属的 sandbox manager 确认,那个审查过的确切容器已经停止或死亡,并且空闲。它不会去停正在运行的容器,也不会绕过主机拿不出证明的情况。核实失败时,错误会说明具体原因:缺少主机注册、声明的地址缺失或被拒绝、sandbox manager 凭据缺失或被拒绝、连接失败、超时,或者拿不到沙箱状态。重试之前,先检查提示的那项配置,或者 sandbox manager 是否就绪。如果是连接失败,请检查反向 sandbox manager 路由。控制平面的日志会记录 agent_sandbox_delete_verification_failed,带上沙箱、所属主机、状态码和失败类型,但不带 sandbox manager 的原始错误信息。

当所属主机没有心跳,或者已经超过 90 秒没有上报时,可以用 Remove stale entry。它不联系 sandbox manager,直接把这一条从默认的管理清单里移走,这样已经退役或连不上的主机,就不会留下一条永远清不掉的记录。服务器会再检查一次主机的心跳是否过期、身份是否和审查时一致;如果还有进行中的轮次、工作区操作、快照或待处理的删除,就会拒绝。这个操作会被审计,也可以撤销:用 Include removed 找到它,再点 Restore to inventory。它保留记录下来的生命周期状态、容器身份、原生状态和对话历史,不删除任何保留的容器或文件,不创建 S3 归档,也不释放主机磁盘。之后只要有一次对得上的、来自运行中主机的观察,或者有新的工作被准入,这一条就会重新显示出来。要完成 S3 归档和真正的删除,需要先恢复和 sandbox manager 的连接。

详情里会显示进度、归档位置、校验和、大小,以及出过的错误。对话历史、成员关系、执行记录和审计回执都保留在数据库里。已删除的原生会话无法再恢复;这个操作也没有提供从 S3 自动恢复的功能。Remove from inventory 是另一个单独的、可撤销的操作,只控制已停止沙箱是否显示在清单里。要查看这些被隐藏的条目,就同时勾选 Include removed sandboxes 和 All states,然后点 Restore to inventory。如果一个沙箱只是从清单里隐藏了,恢复运行它,它就会重新显示出来。

批量操作时,逐行选择,或者点 Select all visible sandboxes,选好要做的操作,再点 Review selected sandboxes。选择只对当前状态、搜索、主机和活动筛选下能看到的行生效,隐藏的行不算在内。审查时会读取每个选中沙箱的最新详情,列出所有阻碍,然后只需确认一次,就把操作应用到所有符合条件的条目上。停止、归档后删除、不归档直接删除、从清单隐藏和恢复到清单,用的检查和单个操作完全一样。最多同时发四个请求,每个结果单独显示。停止或删除的结果,只表示请求已被接受;主机那边何时完成,仍然是异步的。失败或被跳过的条目会保持选中,等你重新审查一次。审查过期或容器已经变了,请求会被拒绝,也不会自动换成新的容器身份再试。不需要批量功能的开关,也不需要额外的迁移。

CPU 用的是两次 Docker 采样之间平均占用的核数;内存是当前用量,去掉了不活跃的文件缓存。网络和块设备 I/O 显示容器的累计计数,以及两次采样之间的速率。所有测量都针对整个共享容器。详情里还会显示进程数和终端数、会话容量、进行中的操作、快照状态,以及沙箱已经存在了多久。这个"存在多久"从沙箱组创建时算起,并不是容器的运行时长。

sandbox manager 在一个有上限的后台 worker 里采集 Docker 的数据,然后随主机心跳把缓存的结果发出去。每个样本经过多次心跳转发后,仍然保留它最初的采集时间。超过 90 秒的读数会标为过时,不计入整个集群的 CPU 和内存合计;合计数会注明覆盖了多少容器。Docker 或 Kata 不提供的计数,就一直显示为不可用。光有一次最新的主机心跳,并不代表资源数据也是最新的。

沙箱操作需要迁移 0037_admin_sandbox_stop,以及配套的控制平面和主机版本。启动新版 API 之前,先执行 alembic upgrade head。从清单隐藏还需要 0044_admin_sandbox_archive;基于 S3 的容器删除需要 0045_admin_sandbox_deletion,以及配套的 sandbox manager 版本。只追加不修改的管理操作轨迹需要 0057_admin_sandbox_trajectory。资源数据的持久化需要迁移 0034_sandbox_resource_usage,以及主机运行新版的 sandbox manager。较旧的主机仍会出现在清单里,只是资源用量显示为不可用。每个沙箱上只保存最新的一次观察,所以这个视图看不到资源用量的历史。

所有者的 Usage 页面

设置里有一个 Usage 页面(/settings/usage),让每个所有者看到自己的容器、正在运行的容器现在用了多少资源,以及自己的工作在最近 7 天或 30 天(按 UTC 日计)里用了多少。它读取三个只返回所有者自己数据的接口,返回内容里都不会出现主机、容器 ID、镜像或代次:

接口

读取什么

GET /v1/agent/usage/containers

所有者还没删除的对话背后的容器,活跃的排在前面,附上资源限制,以及运行中容器的最新样本。新鲜的样本(90 秒以内)会加总成当前的 CPU 和内存用量。

GET /v1/agent/usage/resources?days=

按 UTC 日统计:根据 agent_sandbox_events 里的生命周期事件算出的运行时长,以及 agent_sandbox_usage_days 里实测的 CPU 和内存。

GET /v1/agent/usage/tokens?days=

管理后台的用量汇总,只取这个所有者的运行:按天、按模型统计的费用、token 数和轮次数。

运行时长统计的是 starting、running 和 stopping 三种状态,历史数据可以追溯到 0046_admin_sandbox_trajectory。CPU 和内存的历史从迁移 0077_sandbox_usage_days 开始:每收下一个样本,就把它距离同一个运行中容器上一个收下的样本过了多久(最多 120 秒;刚启动或者间隔更长时,只算这个样本自己的 CPU 采样窗口),以及这段时间里的 CPU 核数和内存字节数,加到这个容器在这段时间所在 UTC 日的那一行上。跨过 UTC 00:00 的时间会在那里切开,午夜之前的部分算在前一天。页面会说明测量数据覆盖了多少运行时间。磁盘用量不统计。

按 UTC 日统计是有意为之:网关的每日限额在 00:00 UTC 重置,这样页面上的"今天"和限额的"今天"是一致的。页面会说明这一点,并按读者所在的时区显示那个时刻。和网关的仪表盘一样,页面会注明模型费用只是估算,不是账单。

查看任务记录下来的事件

管理控制台在 Resources 里显示工具调用的测量数据,在 LLM requests 里显示模型请求记录。如果为了支持用户或调查滥用,需要查看原始的运行时事件,就用下面两个管理接口。见管理可观测性规格里的补充说明。

元数据。 GET /admin/agent/runs/{job_id}/activity 按执行顺序返回事件的外层信息:event_type、created_at、is_error、has_content,以及只有 tool_use 和 tool_result 才有的 tool_name 和 tool_use_id。它接受 attempt_id、cursor 和 limit(1-200,默认 100)。时间戳是事件到达控制平面的时间,所以不能拿来衡量模型延迟;一条 message 也不等于一次模型请求。这个响应里没有任何内容数据:生命周期的 phase 故意没有列入允许返回的字段,所以一次运行开头的那些事件,在这里看起来都是一模一样的 lifecycle 行。

内容。 POST /admin/agent/runs/{job_id}/events/{event_id}/content 返回单个事件保存的内容:提示词文本、工具参数、工具输出,或者原始的错误文本。返回之前会先记审计:审计存储会记录 agent.run.event_content.read,以及操作的管理员、他的 IP、任务所有者,还有任务、事件、attempt 和序号。如果审计写入失败,这个接口返回 503,什么内容都不透露。 响应最大 256 KiB;如果某条旧记录太大、只能给出预览,就会设置 truncated。

两个接口都需要管理员权限。所有者通过会话接口读取自己的事件,不需要管理员那种内容读取审计。

不要直接读取 agent_job_events.payload。 用 psql 查询能拿到同样的内容,却没有审计记录、没有大小限制,也没有字段白名单,而这正是内容接口要防止的情况。如果在真实的调查中,用 API 一个一个读事件实在太麻烦,那应该去改进工具,而不是绕过审计。

查看审计记录。 Admin → Audit log(GET /admin/agent/audit-log)按从新到旧列出管理员做过的操作:修改设置和 agent 版本、服务器操作(暂停、移除、放置、添加或释放服务器)、测量运行,以及上面说的内容读取。可以按 category(settings、servers、measurements、content)筛选,并用 before 分页,填上一页最旧的那个 id。Servers 页面的轮询读取不会被记录;在这个改动之前留下的那些记录,仍在 agent_admin_audit_log 里,但不会被列出来。页面上只用用户 ID 来标识管理员,因为身份令牌里不带显示名称。

团队

所有者和管理员在 Settings → Teams 中管理自己的团队。平台管理员可以在 Admin → Teams 下以只读方式查看所有团队:

  • 列表(GET /admin/agent/teams)按从新到旧显示团队,包括所有者、活跃成员、所有者以外的管理员,以及等待处理的加入申请。state 取 active(默认)、deleted 或 all;q 匹配团队名称的一部分、团队 id,或任何曾经是成员的人的用户 id。它用 before 分页,值是上一页显示的最后一个团队的 id;响应里的 next_before 就是下一页要用的这个 id。每一页从那个团队之后开始,而不是按行数往后数,所以管理员翻页时即使有团队被创建或删除,也不会重复显示或者漏掉。

  • 团队页面(GET /admin/agent/teams/{team_id})列出所有状态的成员,包括已离开或被移除的成员及其时间,还有未处理的加入申请,以及有多少邀请链接处于有效、已过期、已认领或已撤销状态。离开的记录会永久保留,所以页面列出全部活跃成员,以及最近离开或被移除的 50 个人;departed_member_count 是他们的总数。这样,打开一次页面要向 Gateway 查询的邮箱数量就有了上限。未处理的申请同样只列最早的 50 个,团队的 pending_request_count 是总数。GET /admin/agent/teams/{team_id}/audit-events 用 before 对团队自身的审计历史分页。

人员以个人资料名称和 Gateway 账户邮箱显示,这些信息在每次请求时读取,从不存储;Gateway 读不到的邮箱会被省略。这些页面上的任何内容都不会改变团队,所以不会留下管理员审计记录。暂时还不支持转移一个所有者已失去访问权限的团队的所有权。

MCP 服务器

MCP 调用都要经过控制平面。沙箱里只有一个限定范围的中继凭据,上游服务的凭据留在沙箱之外:

sandbox ──[residency or attempt mcp credential]──▶ MCP-only relay ──[owner credential]──▶ MCP server

对话成员的 CLI 进程会服务多个轮次,所以它持有的是所在 residency 的凭据(amr.…),这个凭据在 residency 建立时返回。每次调用时,中继会把它解析成这个对话在该 residency 下正在运行的 attempt,然后执行和轮次自身令牌一样的检查:attempt 必须是当前的、持有租约,它的任务正在运行、没有被停止,它的主机已经注册。在两个轮次之间,或者 residency 轮换、过期或被释放之后,这个凭据什么都授权不了。所以,如果后续消息的 MCP 服务器没有变化,进程会一直保留;添加或删除连接则仍会重启进程,因为 CLI 只在启动时读一次服务器列表。用轮次自身令牌启动的进程(来自还不支持 residency 凭据的旧版控制平面),仍然像以前一样在下一轮重启。

每个请求都会保留认证时拿到的 attempt 和 residency 代次。在等待 OAuth、审批或上游会话之后,中继会把两者再检查一遍;它绝不会把一个进行中的请求挪到之后的轮次。记忆写入、Web Search 准入和审批的认领,也都会在各自的数据库事务里检查 residency。迁移 0076_mcp_residency_fence 给审批记录加了一个可为空的 residency 代次,这样别的 residency 或某个 attempt 令牌,就没法用掉这份审批。已有的、基于 attempt 令牌的审批,这一项保持为空。

监听器与连接

MCP 监听器是一个单独的 FastAPI 应用,跑在 8001 端口。它只提供 MCP 工具相关的接口;任务、主机、会话、管理和推理的 API 都在别处。沙箱通过它的第二条固定中继,也就是接入其沙箱组网桥的 MCP 隧道,向 /v1/agent/mcp 认证。上游的地址和所有者的凭据都留在控制平面。

不要把工具中继放在公开的主 API 上。 公开的 edge 把 /api/* 转发到 8000 端口,而那里没有挂载任何工具路由。所以即使 mcp 令牌泄露,也没法通过这个 API 重放。浏览器里的连接管理、OAuth 回调和审批,都用 /v1/agent/mcp 这个前缀,必须留在 8000 上,所以不要在 edge 上屏蔽整个前缀。把沙箱的 MCP 隧道指向 8001 上的 MCP 专用监听器。

用户通过 /v1/agent/mcp 添加连接:可以是一个远程 URL、导入的一段 mcpServers 配置,或者一份 stdio 启动参数。配置和凭据存在 agent_mcp_connections 里;凭据用 AGENT_SOURCE_CONTROL_ENCRYPTION_KEY 加密,并使用 MCP 专用的域分隔符。发现过程会保存一份能力快照。模板只用来预填表单,并不构成部署级别的注册表或服务器目录。

MCP OAuth 用 AGENT_MCP_PUBLIC_BASE_URL 作为公开 API 的回调地址,用 AGENT_WEB_BASE_URL 作为浏览器回到 /integrations 的地址。如果是共用一个主机名的部署,可以分别设为 https://example.com/agents/api 和 https://example.com/agents。页面会显示授权结果,确认凭据确实保存下来之后才提示成功,并引导用户点 Test 来发现这个连接提供的工具。

中继、连接 API、发现流程,以及全部五个编码 agent 的适配器,都已经针对远程 MCP 实现并测试过。还没完成的 stdio 能力和可选的协议能力,在下面以及一致性记录里说明。

授权与路由

中继执行以下规则:

  • 没有任何东西能凭名字访问到。 URL 里的路径段是一个绑定别名,也就是某个对话给某个连接起的名字;而令牌指向的是一个任务,所以中继会在这个任务自己的绑定里查这个别名。别人添加的连接,不可能靠起个名字就被调出来。

  • 会话的权限就是它写明的那些。 每项能力都有一条策略:deny、confirm 或 allow。没有写到的能力一律拒绝,包括服务器在会话配置好之后才新增的能力;如果某个工具的描述或 schema 变了,对应的策略会重新交给所有者决定,而不会被当作一次早先的批准。

  • confirm 会把原始调用先扣住。 中继会记下这个待处理的调用,等所有者在对话的 Tools 面板,或者账号的 Approvals 收件箱里做决定。批准后,这个请求会在当前的 attempt 和权限检查下派发一次;CLI 直接拿到结果,不需要让模型重试。被拒绝、过期,或者授权发生了变化,请求就不会派发。

  • 凭据按用户区分,出问题就拒绝。 缺少加密密钥、密文解不开、连接过期又没法刷新,或者连接已断开,这个服务器都会从所有者能访问的列表里去掉,并在 Integrations 页面上说明原因。

  • 连接不属于任务。 每次工具调用时都会重新读取绑定,而不是把它复制到任务上,所以收窄一个会话的权限,在它的下一次调用时就生效,不用等到下一轮。

  • 流在运行期间也会被检查。 GET /v1/agent/mcp/<alias> 打开的服务器到客户端的流,是一个请求,服务器让它开多久,它就持续多久。不管服务器有没有在发送,中继每 5 秒都会再检查一遍,和派发前检查一个被挂起的调用一样:先查这一轮的栅栏,再查绑定和连接,它们必须还是打开这个流时的那两个。只要有一次检查失败,就结束这个流并关掉上游 socket;所以轮次停止、连接断开或改了目标、绑定被关掉,都会在这个间隔内让流停下来。

  • 两个端点共用同一个授权函数。 /v1/agent/mcp/<alias> 把单个连接的协议原样转给 CLI。/v1/agent/mcp 是合并后的 host:它响应 initialize,把任务的所有连接列成一个统一的目录,并把每次调用转到对应的连接。全部五个适配器拿到的都只有这个合并端点,作为一个名为 session 的条目。固定的名字和身份,正好符合运行时的白名单;如果每个连接都加一个条目,就和合并目录重复了。需要原样透传协议的客户端,仍然可以用按连接区分的那个接口。旧式的 SSE 服务器没法通过管道完成初始化,所以适配器改用合并后的 host;按连接区分的接口收到这类服务器的 GET 时,会返回 405,而不是转发它的流。原因是这个流一开头就会公布消息要发往的上游会话,而很多这类服务器不带凭据也会接受,沙箱就可能借此绕过中继直接用它。

在合并后的接口上,工具和提示词的名字是 <alias>__<name>。路由时会到目录里查这个完整的名字,绝不会按 __ 拆开,因为别名本身也可能包含这个分隔符。有歧义或太长的名字,会加上一个固定的、长度有限的后缀。资源用 mcp-resource://<connection-id>/<original-uri> 表示,这样即使上游的 URI 相同,也能区分开。读取时只去掉这个前缀,原始 URI 及其百分号编码都保持不变。资源和提示词有各自的权限范围,和工具一样都要经过同一个 decide 函数。在 Tools 面板里批准一次,无论调用来自哪个端点,都会按同样的策略放行被扣住的那个调用。

解析不了的连接会记一条日志,并在合并列表里跳过,这样其他连接照样能用。它自己那个按连接区分的接口,仍然会明确拒绝这个出问题的连接。

合并后的 host 还提供平台自己的工具,这些不来自任何连接。配置了搜索服务时,会列出 web_search;记忆工具 memory_save、memory_update 和 memory_delete 则对每个任务都会列出。这两类工具都在认领里用不涉密的标记(:platform:web-search、:platform:memory)注明,所以即使对话没有任何连接,每个支持 MCP 的运行时也会加载合并端点。记忆工具只会替带栅栏的令牌所指的那个所有者和任务写入,绝不会替参数里写的所有者或任务写入,并且只写入这个任务的仓库范围,或者所有者的用户范围。

端到端测试覆盖

tests/external/test_agent_mcp_end_to_end.py 会串起一个真实的监听器、迁移好的 Postgres 数据库、镜像里的 pi 0.73.1 CLI(或者 AGENT_PI_TEST_VERSION 指定的版本,来自管理员升级时下载的那个包),以及随附的 bridge。其中的模型和上游 MCP 服务器是多线程的 HTTP 模拟服务,所以它覆盖的是传输和授权,不是对真实服务商的验收。

记录下来的那次运行,预先准备了一个带静态凭据和发现快照的连接,以及一个持有有效 attempt 栅栏的任务。模型看到的工具名是 session__github__get_issue。上游依次收到了 server/discover、initialize 和 tools/call,每个请求带的都是连接的凭据,而不是沙箱的令牌。工具到达上游时名字是 get_issue,模型给的参数没有被改动,返回的文本结果也送回了模型。测试还确认了:当 era 探测返回 -32601 时,会退回到握手流程;带命名空间的工具名,永远不会传到上游。

这个测试几秒钟就能跑完,但需要 Postgres、npm,以及安装固定版本 CLI 的网络访问。缺少这些条件时会跳过,它属于外部测试层级。改了合并端点、host 或 bridge 之后,要专门跑一下;它能测到各层测试各自覆盖不到的衔接处。

Connector 的配置

/v1/agent/mcp/connectors 用来注册用户的机器,只返回一次注册用的 secret,并支持改名和吊销。secret 只保存哈希。它还能绑定或解绑一个 user-connector 连接。远程连接不能绑定,因为平台会直接访问那些服务器。

绑定会改变执行目标,以及两个授权版本号。吊销一个 connector,会在同一个事务里把它所有的连接解绑。目前还没有随附任何 connector 运行时:视图里显示 runtime: "unavailable",已绑定的连接在使用时会被拒绝。托管 worker 的连接同样需要有一个可用的 worker。能配置,不代表能执行。

部署与回滚

先更新 schema,再替换 API,然后处理遗留的绑定,最后部署配套的 runner 和沙箱产物。

先更新 schema。 0008_generic_mcp_connections 去掉了 UNIQUE(user_id, server),把 server 改名为 template,加上了连接的传输方式、位置、端点或启动参数,以及认证配置,并把旧的 agent_threads.mcp_servers 列表迁移成显式的绑定。它还保留了一个空的、限制写入的 mcp_servers 列,以及一个自动生成、永远为 NULL 的 server 列,这样在主机逐步上线期间,旧版 API 仍能列出对话、生成输入框的配置,并接受普通的排队提交。这两列都不能用来授权 MCP 访问。已有的选择会保留在新的绑定里;要执行它们,需要新版的 API 和中继。新建的连接绝不会交给旧的、按服务商名字授权的逻辑。

0011_mcp_rolling_compat 会给已经执行过最初那个破坏性 0008 的数据库,补上同样的那几个不起作用的列。替换 API 之前,先把迁移执行到 head。这是一次向前的修复:连接、凭据和权限都会保留,不需要降级数据库。

处理遗留的绑定。 这次修订只在一种情况下映射旧的名字:对话所有者在这个服务商上恰好只有一个连接。其他所有情况,都以 no_connection 或 ambiguous_connection 写进 agent_mcp_legacy_bindings。把这些记录当作一份清单来逐个处理,它列出的是需要手动重新选择工具的会话。这些记录在处理之前会一直保留;有歧义的选择和找不到连接的情况,都不会被悄悄丢掉,也不会被分配给别的连接。

沙箱和 runner 的产物要来自同一个发布。Codex 在第一次带 MCP 启动之前,会先装好它那份 root 所有的系统白名单。沙箱里带的是 Kilo 7.4.17,用 build-kilo.sh 旁边记录的源码版本和补丁构建;要换别的 Kilo 版本,需要一次评审过的构建。runner 在启动成员之前,会用这些受信任的产物,升级保留容器里的适配器文件和 Kilo 可执行文件,原生会话文件保持不动。更新时不需要绑定挂载主机目录,也不需要额外的 Linux capability。

走常规部署,MCP 就会在每个支持的运行时上启用,没有功能开关,也没有预览档位。回滚时,必须保留一个 schema 兼容的服务,并且不动已经迁移好的连接和绑定数据;如果降级 schema 会丢掉这些记录,就改为向前修复。

MCP 和 connector 都和网关的推理路径互相独立。轮换凭据用的是现有的 AGENT_SOURCE_CONTROL_ENCRYPTION_KEY;不同的域分隔符,保证代码托管的密文和 MCP 的密文不会被当成对方解读。

运行时边界

Claude Code 会在原生请求内部重试临时性的推理失败,包括 HTTP 429 和 5xx。headless 和常驻两种启动方式都设置了 CLAUDE_CODE_MAX_RETRIES=15 和 CLAUDE_CODE_RETRY_WATCHDOG=0:首次请求之后最多重试十五次;固定版本 CLI 的指数退避从 500 毫秒开始,最长 32 秒,再加上最多 25% 的随机抖动。CLI 会遵守整数形式的 Retry-After;在普通模式下,如果要求等待超过 60 秒,它就会停止。整个轮次的时间上限仍是 runner 的执行超时;在退避等待期间,所有者取消照样能中断查询。重试的提示会显示在对话里,带上第几次重试和等待时间。

重试时复用的是那个待处理的推理请求,已经完成的工具调用、原生会话,以及唯一那条平台提示词都会保留。重试次数用完时,这一轮原生会话算失败,但上下文仍然保留;runner 不会再发一遍提示词。普通的、永久性的客户端错误,比如 HTTP 400 和 403,不会重试;HTTP 401 仍走 CLI 自带的刷新凭据流程。部署新版 runner 后,这些启动参数才会生效;已有的常驻 CLI 进程,会在重启或恢复时用上它们。

只有在固定版本的二进制上确认了两件事之后,适配器才会声明支持 MCP:一是能把 CLI 指向平台的中继;二是能挡住其他所有 MCP 配置来源,不让它们添加服务器。光凭运行时属于哪个系列,说明不了这条边界成立。

运行时

固定版本

中继怎么接入

为什么仓库没法自己加服务器

claude-code

2.1.220

--mcp-config,传入平台的服务器

--strict-mcp-config,已在 CLI 上实测:不加它的话,仓库里提交的 .mcp.json 会和我们的配置一起被加载,而且不需要任何批准

opencode

1.18.9

评审过的 opencode-freeinference 包装脚本,把中继端点合并进它生成的 OPENCODE_CONFIG

包装脚本导出 OPENCODE_DISABLE_PROJECT_CONFIG,opencode.json 和 .opencode/ 都会被它挡住

kilo

7.4.17

评审过的包装脚本为 run 和 serve 两种模式配置带认证的合并中继;在线 bridge 先确认连接成功,才报告就绪

固定版本的构建给旧的 MCP 加载器打了补丁,让它遵守 KILO_DISABLE_PROJECT_CONFIG;除了常规的项目配置,.kilo/mcp.json 和 .kilocode/mcp.json 也会被挡住

codex

0.145.0

exec 和 app-server 共用合并中继的地址,以及令牌所在环境变量的引用;app-server 会确认 MCP 已经启动

受信任的 guest-root 初始化程序会在启动之前,把这个部署确切的中继地址,作为唯一允许的服务器,以原子方式写入 /etc/codex/requirements.toml;仓库配置和用户配置都没法扩大这份系统白名单。AGENT_SANDBOX_MCP_URL 变了之后,保留容器下一次启动 Codex 时,会把这条"只允许一个中继"的策略换成新地址,除非组里还有别的 Codex 成员正在运行;如果策略是别的形式,一律拒绝,绝不替换

pi

0.73.1

评审过的 pi-mcp-bridge.ts,随镜像放在 /usr/local/libexec/,用显式的 -e 加载

--no-extensions 会关掉所有其他扩展来源:项目里的 .pi/extensions/、全局目录,以及设置文件里指定的任何东西,所以显式指定的路径是唯一的入口。已经在固定版本的二进制上实测过,也在 pi 迁移后的新包 @earendil-works/pi-coding-agent 的 1.2.0 版本上实测过:仓库自己的扩展不会加载,中继的工具注册为 <alias>__<tool>,调用能到达上游。在 1.2.0 上,这个参数还会关闭 pi 内置的 MCP 客户端,所以不管项目是否被信任,.pi/mcp.json 和配置目录里的 mcp.json 都不会启动任何服务器

如果任务在一个还没声明支持 MCP 的运行时上指定了连接,会在创建时就被拒绝,而不是在工具悄悄缺失的情况下照样运行。

Codex 的系统策略直接根据受信任的部署地址写入,不需要为每个部署单独构建镜像。

按各个运行时自己的语法引用凭据。 Claude Code 会展开 ${AGENT_MCP_TOKEN};OpenCode 系列则在解析原始配置之前,替换其中的 {env:AGENT_MCP_TOKEN}。语法用错了,就会把占位符字面量当作 bearer 令牌发出去,结果是 401。两种写法都已在固定版本的二进制上实测过。中继地址由 apps/backend/cloud_agent_host/runtimes/ 里的适配器决定;凭据引用由 deploy/sandbox/ 里对应的 CLI 适配脚本写入。令牌通过环境变量传递,绝不出现在命令行参数或配置文件里。

--strict-mcp-config 挡住的是什么

传 --mcp-config 时,一定同时传 --strict-mcp-config,平台配置为空时也一样。严格模式能保证这份配置是 MCP 服务器的唯一来源。

为 hybridInference#1129 做的那次测量,用的是任务实际的 CLI 配置:headless 的 -p、bypassPermissions,并以仓库目录作为工作目录。在一个包含下面内容的目录里

{
  "mcpServers": {
    "evil_repo_server": { "command": "/bin/echo", "args": ["pwned"] }
  }
}

会得到下面这两行 system/init,唯一的区别就是有没有这个参数:

调用方式

CLI 报告的 MCP 服务器

--mcp-config <ours>

evil_repo_server, github

--mcp-config <ours> --strict-mcp-config

github

在这种权限模式下,仓库里的服务器会直接加载,既没有提示,也不需要批准。所以要无条件地加上这个参数,即使任务没有任何 MCP 绑定也一样。

任务真正跑起来之前

在开启任务认领之前,先完成主机开通、网络检查、注册和凭据配置。每次部署都对照这份清单检查一遍。

0. 沙箱网络需要主机防火墙规则、一个 DNS 解析器和两个中继

每个沙箱组都跑在它自己的一个可路由网桥上,setup、agent 和每个 Fork 出来的兄弟对话都一样;它能访问什么,由这个部署在主机上的 nftables 表决定:TCP 80/443 上的公网 IPv4、AGENT_EGRESS_DNS_SERVER 指定的那一个 DNS 解析器,以及这个组的两个中继(推理走网关隧道,工具走 MCP 隧道),除此之外什么都访问不了。没有可以切换的模式,也没有能进一步放开的设置;要改变沙箱能访问的范围,就是一次设计变更。完整的规则、如何在主机上查看,以及每种拒绝是什么意思,见下面的沙箱网络。

所以,主机在能跑任务之前,需要具备:

  • AGENT_EGRESS_CHAT_NETWORK_POOL 和 AGENT_EGRESS_DNS_SERVER。两者都没有默认值:地址池必须是这个部署独有的,而解析器是沙箱唯一能用的 DNS。

  • 两个中继都在中继网络 AGENT_RELAY_NETWORK 上运行,主机 compose 会把它定为 <COMPOSE_PROJECT>-agent-relays。这个网络声明了 internal: true,任何沙箱组都不会加入它:生命周期代码在这里按别名找到两条隧道,再以同样的别名把它们接到每个组的网桥上。如果某个别名在这里没有正在运行的容器,或者不止一个,那么在 Agent CLI 启动之前,组网络就会被拒绝。

  • 内核要支持 nftables,包括 bridge 族。每个 runner 在认领任何任务之前,预检都会装好这个部署的表,并读回来确认。

  • Docker 28 或更新版本,客户端和守护进程都要。每个中继以网关优先级 -1 加入组网桥。原因是:一个连着多个网络的容器,会先按优先级从高到低、再按网络名选默认路由,而 freeinference-chat-net-* 排在 <COMPOSE_PROJECT>_internal 前面;如果用默认优先级,中继加入的第一个沙箱组就会成为它的出口,它的 SSH 会话就会撞上沙箱的防火墙规则而断掉,所有共用这个中继的沙箱组都会受影响。预检会拒绝更旧的 Docker;如果发现某个中继接在网桥上却没有设优先级,会带上优先级重新接一次。

检查发生在四个时间点:

  1. sandbox_host.sh prepare 在排空任何 runner 之前,用候选 runner 镜像里的解析器校验网络设置。

  2. sandbox_host.sh check 从中继网络上的容器发出请求。只有推理的 /health 和 MCP 的 /healthz 都返回 HTTP 200,才会写入按确切 SHA 的就绪标记。runner 自己那个更底层的推理探测,只要收到任何 HTTP 响应就认为连通;这种较弱的探测,不能用来放行一次部署。

  3. 每个 runner 的预检,都会安装主机上的 nftables 表并读回确认。

  4. 每个沙箱组冷启动时,都会在 guest 里以沙箱用户的身份,检查默认路由、配置的解析器,以及两个中继的 TCP 端口。任何一项失败,都会停止沙箱、保留工作区,并让这一轮失败。

DNS 能解析,并不说明中继是好的:隧道的名字可能还在,但它的上游早就挂了。

如果 runner 主机不在网关那台机器上,见在另一台机器上运行 runner:那时两个中继分别是通往网关、以及通往 MCP 专用监听器的 SSH 隧道。

中继网络让 Compose 来创建。用同样的名字手动建的网络,会因为标签对不上而被拒绝。freeinference-chat-net-* 网络则不一样:它们由生命周期代码通过 Docker API 创建,名字和标签根据沙箱组和代次生成,主机上的接口名根据这个部署的前缀生成。每个网络从 AGENT_EGRESS_CHAT_NETWORK_POOL 里分一个 /29,同时申请时由 Docker 来仲裁。空闲停止或丢失停止时,组的网桥会被断开并删除,Docker 保留容器的可写层;唤醒时再重新创建。对于创建到一半被中断而遗留的、带标签的网桥,Sandbox Manager 的调和会在五分钟宽限期之后删掉。设这个宽限期,是为了防止状态调和在网桥刚建好、沙箱还没接入时就把它删了,同时又能回收网桥接口和 IPAM 地址。环境测试或预构建还在用的网桥归那次运行所有,运行结束时由它自己删除。调和删不掉的网桥,比如还接着一个未知容器的,会记一条 chat_network_prune_failed,留到下一轮再处理;它不会让主机的沙箱列表失败。

1. 只有已注册的沙箱主机才会运行任务

任务都在已注册的主机上运行。已经删除的 agent-job-runner Actions 工作流,用的是没有隔离的 process 后端和共享的 dispatcher 凭据;在本服务里,它既不是执行路径,也不是备用路径。

用 deploy/host/deploy.sh 至少部署一台主机,把它注册到控制平面,让它的 runner 服务去轮询 /v1/agent/worker/claim。runner 要等它所在的那个副本证明了以下几项,才能认领任务:保留协议、容器镜像和运行时的固定版本、sandbox manager 配置、execution profile,以及主机容量。

独立部署时,主机服务需要的不只是一个注册凭据。从 deploy/host/.env.example 改起:COMPOSE_PROJECT、AGENT_SANDBOX_IMAGE、AGENT_EGRESS_CHAT_NETWORK_POOL 和 AGENT_EGRESS_DNS_SERVER 都是必填(docker-compose.sandbox.yml 对每一个都用 :? 引用,没设就无法渲染);此外还需要 AGENT_SANDBOX_MANAGER_TOKEN、三条隧道的目标地址以及各自的密钥和 known-hosts 文件,以及控制平面和主机分成两套 Compose 时用到的那三项 sandbox manager 反向转发设置。

2. 凭据

主机凭据:为一台指定名字的主机,签发一个短期的注册令牌:

# Admin API; the response shows the single-use token once.
POST /admin/agent/runner-hosts/enroll-token

第一次启动时,把这个值填进 AGENT_ENROLL_TOKEN。注册成功后,它会被一个绑定到这台主机的长期凭据取代,凭据保存在 AGENT_HOST_STATE_ROOT 下;之后的每次心跳和认领都用它认证。请求里写的主机名,本身永远不会被当作身份接受。

Sandbox manager 凭据:每个环境生成一个 AGENT_SANDBOX_MANAGER_TOKEN,控制平面和每台主机上的 Sandbox Manager 都设成同一个值。它授权文件、终端、Git 和会话生命周期相关的调用。它不是认领任务用的凭据,也绝不会进入对话容器。

GitHub 凭据:安装一个 GitHub App,把它的私钥交给控制平面。不需要手动签发或轮换任何东西:平台会签一个九分钟有效的 App JWT(github_app.py;超过十分钟的 GitHub 会拒绝),再用它换取一个一小时有效的令牌,范围限定在覆盖目标仓库的那个安装上。

私钥通过只读的绑定挂载,放在容器里的一个固定路径上。这里涉及两个变量,而且它们不是一回事:

# The *host* file that holds the PEM. This is the mount source.
AGENT_GITHUB_APP_PRIVATE_KEY_FILE=/etc/cloud-agent/github-app.pem
# compose: $AGENT_GITHUB_APP_PRIVATE_KEY_FILE ──▶ /etc/cloud-agent/github-app.pem:ro

AGENT_GITHUB_APP_PRIVATE_KEY_PATH 指的是容器内部的路径,deploy/control-plane/docker-compose.yml 里它的默认值就是那个挂载路径,所以用 Compose 部署时只需要设置 AGENT_GITHUB_APP_PRIVATE_KEY_FILE。如果改了路径变量,却没在那个位置挂载任何东西,服务启动后 GitHub 功能就是不可用的,见下文。把文件放在主机上,属主设为服务用户,并执行 chmod 600。控制平面镜像里不带部署的 config/、distributions/ 或 var/data/ 目录。

GitHub 有没有配置,看的是已保存的 GitHub App ID,它在第一次初始化设置时从 AGENT_GITHUB_APP_ID 导入。在这套部署里,私钥路径总是有默认值,没有 App 时那里挂载的是 /dev/null,所以光看路径说明不了什么。设置了 App ID 之后,如果私钥加载不了,比如缺失、为空、服务用户读不了,或者不是有效的密钥,控制平面也不会因此停掉(issue #47):

  • Integrations 和任务输入框会提示 GitHub 暂时不可用。创建任务(临时工作区也一样)、runner 认领任务或查询分支,都会返回 503 source_control_unavailable,并带上 Retry-After: 30;排队的任务、待处理的发布和目标的唤醒都会等着,不会消耗任何重试次数。

  • 日志里会记一条 ERROR 事件 source_control_load_failed,带一个 reason(key_file_unreadable、key_missing、key_invalid、unexpected);如果是文件的问题,还会记下文件路径和操作系统给出的错误。只有原因变了才会再记一次。如果有任务在排队却一个都启动不了,现有的 queue_not_starting 告警也会触发。

  • 服务每 30 秒重新读一次私钥。直接在原地把文件修好,比如改属主、改权限或改内容,同一个进程在一分钟内就会恢复,并记录 source_control_load_recovered。App ID 和内联凭据请在 Admin → Settings 里改。如果要改私钥绑定挂载在主机上的路径,仍然需要重建容器。

也可以在设置页面里保存一份内联 PEM,它会加密存进数据库,并且优先于挂载的私钥。如果部署还在传旧的 AGENT_GITHUB_APP_PRIVATE_KEY 环境变量,它会在一次性导入时被接受。保存的内联私钥为空时,仍然使用挂载的文件。

检出和发布需要 App 有 contents: write 和 pull_requests: write 权限。要在工作区和新建任务的选择器里启用 Create repository,还要给 GitHub App 配置仓库权限 Repository creation: write。GitHub 也接受 Administration: Read and write,但 Repository creation 只授予这个功能需要的创建操作。见 GitHub 的仓库创建权限说明。已有安装的所有者,可能需要到 GitHub 上批准更新后的权限。没有这项权限时,创建仓库会提示 App 没有创建仓库的权限;已有的任务照常使用原来的权限。App 不需要 workflows 权限,所以即使绕过了补丁检查,改动 .github/ 的补丁也推不上去。要吊销,卸载这个 App 即可。

创建仓库时,会用已连接用户的 GitHub App 用户令牌(加密保存在控制平面、可以刷新)调用 GitHub 的 POST /user/repos。它只在这个用户的个人账号下创建仓库,默认私有,并初始化一个 README。没有任何长期凭据会进入沙箱。创建仓库不会在本地加一条仓库授权,也不会绕过部署白名单:如果 App 安装只覆盖了选定的仓库,所有者需要通过 Configure repository access 给新仓库授权,再刷新选择器。如果创建之后的权限检查临时失败,会把它当作"已经创建、等待授权"的仓库处理,而不是再创建一次。

连接流程(用户授权自己的仓库)还需要 App 的 OAuth 部分,回调地址指向这个部署的前端:

AGENT_GITHUB_APP_CLIENT_ID=Iv1.xxxxxxxx
AGENT_GITHUB_APP_CLIENT_SECRET=...
AGENT_GITHUB_APP_INSTALL_URL=https://github.com/apps/<app-slug>/installations/new
# App setting "Callback URL": <agent web origin>/connected?provider=github
#   for example https://agents.example.org/connected?provider=github

回调地址要设在 agent Web 自己的源上,回调页面和会话 cookie 都在那里。如果 Web 构建带了 base path,就用 <origin><basePath>/connected?provider=github。授权链接里不带 redirect_uri,所以 GitHub 会用 App 配置里的第一个 Callback URL。Web 的源一旦变了,就要更新这项设置。

如果回调地址还留在 Web 应用已经不用的源上,连接流程就会出问题,而 GitHub 那边不会报任何错,所以一定要和 Web 应用一起迁移。

输入框和 Integrations 页面,会根据 AGENT_GITHUB_APP_CLIENT_ID 和 GitHub 网页端的 OAuth 地址生成 Connect 链接。只要 App 没有装在用户自己的账号上,连接之后就会再给出安装地址。这两个地址必须分开,重新连接才能正常工作:对于已经装好的 App,GitHub 会把 /installations/new 链接直接转到它的设置页面,而不会调用 OAuth 回调。

Configure repositories 用的是安装地址,GitHub 会自己判断打开用户还是组织的设置页面。把 AGENT_GITHUB_APP_INSTALL_URL 设为 /apps/<slug>/installations/new。如果把它指向 OAuth 地址,还没安装 App 的用户就会在授权页面上无限循环。

runner 克隆仓库用的凭据也来自同一个 App,但两者不是同一个令牌。安装令牌默认会继承 App 拥有的全部权限,除非申请时主动要求更少,所以克隆令牌是按"只对当前这一个仓库有 contents: read"来申请的。检出因此不会继承发布器那么大的权限。

认领时还会另外签发一个只限单个仓库、带 contents: write 和 pull_requests: write 的安装令牌,作为 GH_TOKEN 注入到所属沙箱成员的 Agent CLI 环境里。GH_REPO 指定任务的仓库,GH_HOST 指定 github.com;gh 以 App 安装的身份认证,而不是用户的账号。这个令牌不会写进 Git 或 GitHub CLI 的配置、共享的工作区文件,也不会写进镜像。setup 脚本和单独打开的终端会话都拿不到它。Git 通过只在调用时生效的环境设置,使用 gh auth git-credential 辅助程序,所以 git push 和 gh pr create 不需要交互式登录就能认证。主机上的克隆凭据仍然是只读的;用户的账号令牌、刷新令牌和 App 的私钥都留在沙箱之外。

从沙箱里直接推送,会绕过受信任发布器的补丁检查,包括对工作流文件和 secret 的检查。仓库的分支保护和 GitHub 规则仍然有效。App 安装必须授予 Contents 和 Pull requests 的写权限;如果令牌签发失败,认领会被退回队列。

每次认领都会从 App 的缓存里取一个有效的令牌,缓存会考虑过期时间。如果令牌换了,成员会在一个新的进程里接上原生会话,这样之后的命令就会用上新的凭据。安装令牌大约一小时后过期,一轮之内不会续期,所以如果某一轮跑得很久,里面的 gh 调用可能要等到下一轮才能拿到新令牌。

没有配置 App 时,认领不会返回克隆令牌,runner 以匿名方式克隆;公开仓库这样就够了。私有仓库必须要有 App。

静态的 AGENT_GITHUB_TOKEN 仍然可以作为受信任发布器的备用凭据,但它是长期有效的,需要有人去创建和轮换,所以优先用 App。沙箱里的 GitHub 认证必须用 App,绝不会退而使用这个静态令牌。两样都没有的话,发布器的循环会空转,并给出提示:任务照样运行、照样产出补丁,只是永远不会变成 PR。

自行部署运行

管理员也可以在 Servers 里挂接一台能通过 SSH 访问的服务器,或者新建一个 CloudLab 实验,然后把就绪的 Host 设为新沙箱的全局位置。运维 profile、凭据、自动续期,以及带保护的资源释放,见计算资源手册。改了这个选择,已有的保留沙箱仍留在原来的 Host 上;Host 的 Remove 和 Forget 都不会销毁底层的机器。

把主机 agent 作为常驻服务运行,这样即使崩溃或重启,排队的任务也能接着被认领。在 .env 里配置注册信息和共享的 sandbox manager 令牌。下面的示例里包含了给受信任的内部仓库用的共享内核设置。不受信任的仓库,请使用默认的 kata 后端,并完成 Kata 配置。

AGENT_ENROLL_TOKEN="<single-use token for this host>"
AGENT_SANDBOX_MANAGER_TOKEN="<same environment value as the control plane>"
AGENT_SANDBOX_BACKEND=container  # explicit trusted-repository downgrade

AGENT_SANDBOX_BACKEND=container 这一行是一次有意为之的降级:沙箱组会共用主机的内核。要让新的沙箱组用上 Kata,先把主机配置好,并用下面的检查确认 guest 运行时和中继路径都正常。已有的保留 runc 容器,需要按那里说明的方式单独迁移。

Compose 用的是 restart: unless-stopped;把主机的 Docker 守护进程设为开机自启,服务在机器重启后就会自动恢复。主机的生命周期命令如下:

deploy/host/sandbox_host.sh up      # build images, tunnel + runners
deploy/host/sandbox_host.sh preflight # validate this host against an existing image
deploy/host/sandbox_host.sh check   # probe all relay/control paths
deploy/host/sandbox_host.sh status  # containers + recent log tail
deploy/host/sandbox_host.sh down    # stop them

# `up` in its two halves, for a deployer that has to put something between
# them. Nothing in `prepare` replaces a container that is serving.
deploy/host/sandbox_host.sh prepare  # pull or build, Kata smoke test, candidate check
deploy/host/sandbox_host.sh activate # drain, replace, prove

即使在网关所在的那台机器上,也要用独立的主机服务,只是把隧道的目标指向本机的监听器。以前那个和网关同机部署的 agent_runner.sh overlay 没有搬过来,这个决定记在 MIGRATION.md 里。preflight 会检查 Docker、保留会话的配置、主机隔离和配置的镜像;up 会针对它要部署的镜像,再把这些检查做一遍。

主机配置参考

脚本在启动之前会先校验必需的环境变量,并把 runner 自己的预检结论直接显示出来(网关能否访问、保留的镜像和运行时能否使用、Docker 数据目录是否正确、中继网络是否存在且不带路由、主机网络规则是否已安装并读回确认),而不是把它埋在一个后台日志里。手动执行的等价命令如下:

# Put a tested alternate version in the corresponding *_EXTRA_VERSIONS build
# argument, then select it in Admin. Default versions are shared protocol pins
# and change in source together with SHARED_SANDBOX_RUNTIME_PINS.
docker build -f deploy/sandbox/Dockerfile.agent-sandbox \
             --build-arg CLAUDE_CODE_EXTRA_VERSIONS="<exact-version>" \
             -t freeinference-cloud-agent-job:latest .

AGENT_SANDBOX_IMAGE=freeinference-cloud-agent-job:latest \
AGENT_HOST_SLOTS=4 \
  docker compose -f deploy/host/docker-compose.sandbox.yml \
               up -d

主机 compose 要求必须提供 AGENT_SANDBOX_IMAGE,不给默认值(runner 和 Sandbox Manager 都用 :? 引用它),所以要明确写出来。部署时会按环境给它打不同的标签(…-job:staging、…-job:prod),这样一边重新构建,不会覆盖另一边的镜像。

claim_job 用的是 FOR UPDATE SKIP LOCKED,所以所有主机共用一个队列,不需要选主,也不需要分片。

变量

默认值

说明

AGENT_SANDBOX_BACKEND

kata

process(没有隔离)除非设置了 AGENT_SANDBOX_ALLOW_UNISOLATED=1,否则拒绝启动。用默认的 kata 时,要等主机装好了 shim,并且有一个真实的容器证明自己拿到了独立的内核,部署才会放行;container 是一次明确选择的共享内核降级,系统会反复醒目地提示这一点

AGENT_SANDBOX_IMAGE

必填

由 Dockerfile.agent-sandbox 构建的镜像,或者部署方自己提供的等效镜像

*_EXTRA_VERSIONS

空

用逗号分隔、测试过的版本,和每个经过源码评审的默认版本一起打进镜像。在 Admin 里为新对话选其中一个;保留的对话继续用它开始时的版本。五个默认版本不是主机设置,因为 runner 和控制平面在做能力准入时,把它们作为一个协议单元整体证明

AGENT_RUNTIME_DOWNLOAD_NETWORK

bridge

在管理员设置页面升级 agent 时,下载这一步所用的 Docker 网络。那里只运行 npm,并且关闭了软件包脚本;脚本和检查都在没有网络的环境里运行(设计)

AGENT_RUNTIME_REGISTRY_URL

公共 npm

主机下载升级版 agent 用的 npm 仓库。控制平面也要设成同样的值,它从这里读取最新版本。pi 的升级来自 @earendil-works/pi-coding-agent;如果用私有镜像源,除了镜像里用的 @mariozechner 那个包,也要包含这个包

AGENT_EGRESS_CHAT_NETWORK_POOL

必填

本部署的 RFC1918 IPv4 地址池,每个活跃的沙箱组网桥占一个 /29,由守护进程来分配。没有默认值,并且必须和本机上其他每个部署的地址池都不重叠(两个重叠时,主机规则会拒绝后装的那个),也不能和主机上 Docker、VPN 及私有服务的路由重叠。沙箱组的网桥在停止时删除,唤醒时重建

AGENT_EGRESS_DNS_SERVER

必填

沙箱唯一能用的 IPv4 DNS 解析器,走 TCP 和 UDP 53 端口。它是受保护地址段里唯一的例外,所以站点的解析器即使在私有地址上,或者就在本机上,也能用。不能是回环地址,也不能在地址池里面;见下文 选择解析器

AGENT_EGRESS_PROTECTED_CIDRS

—

在内置的主机地址、私有地址、链路本地和元数据地址、CGNAT、组播以及保留地址段之外,还要额外拒绝的 IPv4 地址块,用逗号分隔,比如某个机构放在公网地址上的服务。它只能增加保护。控制平面也要设成同样的值,检查所有者的 SSH 服务器时也会拒绝这些地址块

AGENT_RELAY_NETWORK

自动得出

生命周期代码在哪里找那两个中继;主机服务会自动得出 <COMPOSE_PROJECT>-agent-relays。它必须存在,并且是 internal 的,否则预检会拒绝;任何沙箱组都不会加入它。只有要指定一个已有的网络时,才需要设置

AGENT_EGRESS_SETUP_TIER, _AGENT_TIER

已移除

和网络分级、Squid 代理一起退役了,一起退役的还有 AGENT_EGRESS_ALLOWLIST、AGENT_EGRESS_NETWORK_*、AGENT_EGRESS_PROXY_*、AGENT_EGRESS_STOP_GRACE、AGENT_SANDBOX_NETWORK 和 AGENT_SANDBOX_ALLOW_OPEN_NETWORK。已经没有代码读取它们,设了也不起作用;把它们从 .env 里删掉

AGENT_RUNNER_HOST

主机名

已注册的执行环境,它跑的每个轮次都共用这个名字,也是管理后台的放置开关进行选择的单位。deploy/host/sandbox_host.sh 会用主机自己的名字填上它。绝不会在容器里推导,因为容器里的主机名是容器 ID。注册之后以保存的凭据为准,所以改这个值并不会给任何东西改名。不设置的话,这个环境不会进入资源池,除此之外什么都不变。它和 AGENT_PHYSICAL_HOST 不同,后者只用于在清单里显示,把同一台服务器上的几个环境归到一组

AGENT_PHYSICAL_HOST

主机名

这个环境所在的物理服务器,注册时作为 physical_host 标签上报,用来给管理清单分组。sandbox_host.sh 默认用 hostname -s;直接调用 Compose 时要明确设置。绝不是容器的主机名,也不带运行时后缀

AGENT_HOST_SLOTS

3

在管理员到 Servers 页面设置数值之前,同时运行多少个轮次;最少 2,最多 128。不再读取 AGENT_RUNNER_REPLICAS

AGENT_RETAINED_DOCKER_DATA_ROOT

/var/lib/docker

守护进程应有的 DockerRootDir,保持这个默认值。要把数据放到持久磁盘上,就把 <disk>/docker 和 <disk>/containerd 绑定挂载到 /var/lib/docker 和 /var/lib/containerd。自定义的守护进程数据目录会被拒绝。主机和 sandbox manager 都会检查它,并且它绝不会挂载进对话容器

AGENT_HOST_STORAGE_PROBE

overlay_root

用哪种探针测量存放沙箱可写层的文件系统,结果显示在 Servers 页面的 Storage 列。off 会停止测量这台主机的存储,用于回滚;认不出的名字会报告 unsupported,而不是去测别的东西。只有主机部署证实过的存储拓扑,才会发布读数;见 沙箱存储用了多少?

AGENT_STORAGE_MIN_AVAILABLE_MIB

1024

Storage reserve for new sandboxes 的初始值;主机第一次通过心跳拿到设置之后,就由管理后台的运行时设置取代。如果最新核实的可用空间低于这个预留值,就不分配新沙箱,但这一轮不会被消耗掉。已有的沙箱可以恢复;容量未知时,不会判定为存储紧张。这只是准入时的一道检查,不是磁盘配额

AGENT_SANDBOX_UID / _GID

10001

只用于自定义的沙箱镜像,必须和镜像里的用户一致

AGENT_REPO_ALLOWLIST

—

用逗号分隔的 owner/name 或 owner/*,用于单租户的内部自用。每个被准入的用户,都可以让平台克隆、发布到列出的仓库。自己连接了 App 的用户用不着它;不设置,只是意味着唯一的授权来源是用户自己的连接

AGENT_GITHUB_APP_CLIENT_ID / _CLIENT_SECRET

—

App 的 OAuth 部分。只有面向用户的连接流程需要它;签发安装令牌只用私钥就够了

AGENT_GITHUB_APP_INSTALL_URL

—

GitHub App 的安装地址,https://github.com/apps/<slug>/installations/new。只要 App 没有装在用户自己的账号上,连接之后就会给出这个地址,Configure repositories 背后用的也是它。控制平面会附上一个短期的、绑定到用户的 state;App 的 Callback URL 要设在 agent Web 自己的源上,即 <origin>/connected?provider=github(见 2. 凭据)

AGENT_GITLAB_OAUTH_CLIENT_ID / _CLIENT_SECRET

—

GitLab.com 的 OAuth 应用凭据,保存在控制平面上。只配置 read_user 和 read_api 两个作用域

AGENT_GITLAB_OAUTH_REDIRECT_URI

—

登记的确切回调地址,通常是 <frontend>/agents/connected?provider=gitlab。除 localhost 开发环境外,必须用 HTTPS

AGENT_SANDBOX_MCP_URL

—

仅主机端使用:本主机的沙箱看到的 MCP 专用中继地址。独立的主机 compose 会根据 MCP 隧道的别名得出它,控制平面已经不再读取它;绝不要把它指向推理服务或完整的控制 API

AGENT_MCP_TUNNEL_*

—

远端 MCP 专用监听器的目标地址,以及单独的、受限的 SSH 密钥和 known-hosts;完整的变量列表见 ENVIRONMENT.md

AGENT_GITHUB_TOKEN

—

控制平面上发布器的备用凭据;不设置的话,发布器就空转

AGENT_PUBLISH_BASE_BRANCH

dev

如果任务没有指定自己的分支,草稿 PR 就以这里为目标分支。从某个分支开始的任务,目标就是那个分支;这里只是备用值

两种 OAuth 流程都只能从需要登录的 /agents/integrations 页面发起。OAuth 的 state 无法预测,绑定到当前登录的用户和服务商,十分钟后过期,只能用一次。GitLab 还额外使用 PKCE。GitLab 的访问令牌和刷新令牌在保存之前先加密,刷新时轮换,绝不返回给浏览器,断开连接时会尽力吊销。

GitHub 仓库支持完整的 Agent 生命周期:检出、查找分支、推送和发布草稿 PR,用的都是短期的 GitHub App 安装令牌。草稿 PR 的标题,会在一个单独的、只属于所有者的授权下,通过一次有限制的推理请求,概括整个共享会话里记录下来的用户和 assistant 消息;这个授权由每次发布尝试自己签发,标题写好后就吊销。Fork 里从之前轮次复制过来的内容只读一次,在它们原来所在的那个对话里读。会话长到一次请求装不下时,最多分成四段同时概括,再在同样一分钟的时间预算内,根据这几段的概括写出标题。再长的会话就根本不发给模型(退回原因是 conversation_too_long)。空洞的"已完成"之类的文字会被拒绝并重新生成。如果推理用不了,发布器会从每个包含的轮次的收尾总结里,取第一行具体的内容,跳过标题和"已完成"之类的套话;如果连可用的总结都没有,就改用改动的文件列表。日志 agent_pr_title_generation_failed 会以结构化字段记录快照 ID 和退回的原因,能拿到时还会记录异常类型和 HTTP 状态,但不会记录对话内容或凭据。在 Admin Settings 里开启 JSON 日志,就能看到这些字段。原始的任务内容保留在 PR 正文里。只要草稿 PR 还开着,一个对话在多轮之间就一直沿用同一个 PR;一旦有人把它关闭或合并,下一次发布的快照会从同一个 agent/… 分支开一个新的草稿 PR。这个版本对 GitLab 的支持有意做得更窄:它只核实登录的 GitLab 用户,并列出他能访问的项目供查看。GitLab 项目不会出现在 Agent 的任务输入框里,控制平面目前也不读取它们的源码,不发布 GitLab 合并请求。

在 runner 主机上配置 Kata

Kata 给每个沙箱组一台轻量级虚拟机,有自己独立的内核。不受信任的仓库要用它,因为这些仓库的构建和测试脚本会执行任意代码。普通容器和主机共用内核,只能用于受信任的内部仓库。

运行时装在主机上。runner 镜像里只有 Docker 客户端,主机的守护进程必须能识别 io.containerd.kata.v2。每台主机以 root 身份运行一次:

sudo deploy/host/setup_kata_runtime.sh

这个脚本可以重复运行。它会在下载任何东西之前,先检查主机是否真的能启动虚拟机(/dev/kvm、CPU 虚拟化扩展),用固定的 SHA-256 校验发布包,并把 shim 链接到 /usr/local/bin,这样 containerd 不用改 unit 文件就能找到它。版本号固定在脚本里;升级只需要改那里的两行,之后在每台还装着旧版本的主机上,--check 都会失败。

Guest 内的进程追踪与成员隔离

安装脚本还会给默认的 Kata 配置选用发布包里的 vmlinuz-debug.container,并设置 runtime.disable_guest_seccomp=false。否则,即使 Docker 接受了容器的安全选项,Kata 默认的打包配置也会忽略 OCI 的 seccomp 过滤器。这个内核是用 Kata 评审过的 eBPF/BTF 调试配置片段构建的;普通的 guest 内核支撑不了进程观察器。--check 会同时检查这个内核文件和当前生效的配置,而 runner 的预检做得更彻底:它会启动一个用完即弃的沙箱,形态和真实的保留 guest 完全一样(非 root、去掉 capability、no-new-privileges),然后执行和启动自带观察器时同一条固定的特权 exec。它的自测要求三个生命周期探针全部挂上,再运行一个很快就结束的子进程,并要求这个子进程的 fork、exec 和 exit 记录都经由环形缓冲区送达。所以,无论是缺 BPF 系统调用、缺 BTF 镜像、缺 tracepoint、事件送不出来,还是 exec 时权限不够,都会在 runner 开始认领任务之前就表现为部署失败,而不是等到某个轮次跑完后,才发现进程列表是空的。同一个探测还要求 Claude 精确划分工具边界时用到的那个固定的工具标记辅助程序可用。见 Kata 的调试内核构建说明。

固定的观察器和平台生命周期辅助程序以 guest root 身份运行。Agent CLI 和仓库代码仍然以各个成员自己的 UID 运行,去掉了所有 capability,并带有 no-new-privileges;它们没法读取、修改那份只有 root 能写的日志,也没法直接往里面塞记录。Claude 那个固定的、非 root 的标记辅助程序,通过一个会核对调用方身份的 Unix socket 和它通信。同一个 UID 下的工具仍然可以执行这个辅助程序,所以精确到每个工具的归属只能用来诊断,绝不能作为安全或计费的依据;eBPF 的生命周期统计本身并不依赖这些标记。Kata 容器的 capability 边界集里保留了 BPF、PERFMON、SETUID、SETGID、CHOWN、DAC_OVERRIDE、KILL 和 SYS_PTRACE,给固定的 guest-root 辅助程序使用,用途包括:追踪、UID 过滤器的自测、socket 的属主设置、成员清理,以及跨成员 UID 核实标记发送方的 /proc/<pid>/exe。Kata 的特权 exec 拿不到原始集合以外的 capability。普通的 exec 会显式地经过 setpriv --no-new-privs 进入,因为 Kata 3.32 下 Docker exec 不会继承 PID 1 的这项设置。预检要求:以非 root 执行、有效和允许的 capability 都为零、NoNewPrivs=1 且 Seccomp=2;然后运行观察器和标记的自测,并要求一对真实的、非 root 的 pre/post 记录送达受信任的观察器日志。特权辅助程序还会核实被检查的那个容器确实用的是 Kata 运行时,并按核实过的容器 ID 执行;改一下 runner 的设置,没法把一个旧的 runc 容器变成 guest-root 的目标。共享容器的周期性 Docker 统计是整个组的总量,所以当前成员的轮次会有意把 CPU、内存、I/O 和网络的汇总值报告为不可用(coverage=0、sampler_version=shared-member-v1),以免把兄弟对话的工作算到自己头上。进程是否存在,则是另一种由事件驱动的遥测:即使一个进程完全在两次统计采样之间开始又结束,fork、exec、exit 也照样会被记录下来,而且内核的记录里带着这个进程消耗的资源:退出时线程组的峰值 RSS、从存储读取的字节数和写脏的字节数,以及它最后一次离开 CPU 时的调度运行时间(基于时钟 tick 的 CPU 时间,对大多数不到一毫秒的进程读数都是零,所以不用它)。这些就是 Kata 下按工具调用统计的 CPU、内存和 I/O:一个进程算在唯一一个包含其启动时刻的工具调用窗口上;如果一个进程可能同时属于多个重叠的调用、它的线程组还没完全结束,或者所在的统计丢过事件,就不算在任何调用上,并把相关的调用标记为数据不完整。在 Agent CLI 自己的进程里完成的工作,不属于任何调用。预检里的自测还要求,一个不到一毫秒的子进程的记录里,CPU 和峰值 RSS 都不为零,并且带有组退出标志;所以如果 guest 内核的 BTF 没法重定位这些字段,部署就会失败,而不是报一堆零。存储和读写字节的统计取决于内核配置,没有的话就显示为不可用。所以,每个轮次的那张表,是一份按成员 UID 统计、在这一轮的游标之后观察到的进程启动的生命周期清单;它既不是这一轮开始前就已经在跑的常驻进程的某一时刻快照,也不是整个容器的总量。

Guest 的网络

Docker 内置的 DNS 解析器(127.0.0.11)在 guest 外面。所以每次 Kata 激活之后,一个受信任的 guest-root 辅助程序会写两个文件:在 /etc/hosts 里写一段中继条目,内容是中继在这个组网桥上的地址;在 /etc/resolv.conf 里写上 AGENT_EGRESS_DNS_SERVER,这是主机规则允许沙箱组访问的唯一解析器。在 runc 下这两个文件都不动:容器创建时就用 --dns 指定了同一个服务器,Docker 的解析器会转发给它。网络这块没有切换,也不需要为了网络重启 guest,setup 和 agent 共用这个组的网桥。运行中的 guest 也绝不会在底下被重新接线,因为单靠 Docker 的在线网络变更,并不会更新 Kata 3.32 里 guest 的网络接口:一个正在运行、却没有恰好只接在自己组网桥上的沙箱,会被拒绝,直到它被停掉为止。

已有的 runc 沙箱组与激活时的检查

把一台主机从 runc 切过来时,它保留的 runc 沙箱组要么继续放在兼容的 sandbox manager 上,要么单独迁移。改后端设置,并不会改变一个已停止 Docker 容器的运行时;特权辅助程序的栅栏会拒绝这些旧容器。不要在给新任务启用 Kata 的过程中,顺手删掉保留的工作区。

要求:一台 x86_64 的 Linux 主机,物理机,或者开启了嵌套虚拟化的虚拟机。没有 KVM,Kata 就跑不起来。

装好 shim 是必要的,但还不够。 guest 内核能用,并不说明沙箱能连到任何一个中继。在给新的沙箱组改后端之前,先在主机上的仓库目录里,用它的 .env 检查已配置的运行时和正在运行的服务:

AGENT_SANDBOX_BACKEND=kata deploy/host/sandbox_host.sh preflight
AGENT_SANDBOX_BACKEND=kata deploy/host/sandbox_host.sh check

preflight 检查配置的镜像和 guest 内核。check 会在中继网络(AGENT_RELAY_NETWORK)上用 Kata 启动中继探测,要求推理的 /health 和 MCP 的 /healthz 都返回 HTTP 200。它还会检查正在运行的 runner 完整的沙箱预检(这一步会安装主机的表并读回确认)、sandbox manager,以及到控制平面的各条路径。中继网络只是生命周期代码找那两个中继的地方;保留的沙箱组在激活时会拿到自己的网桥,从那里出发的路径,由每次冷启动时在 guest 里做的检查覆盖。compose 的 ..._internal 网络是主机上各个服务用的网络,替代不了这两项检查中的任何一项。

Kata 的中继探测只通过 Docker inspect 解析配置好的中继端点,再把地址传进 guest,和运行时固定写 hosts 的做法一致。单纯用 alpine getent 做探测,依赖的是 Docker 内置的 DNS,验证不了这条路径。检查失败时,先根据报告找出是 guest、中继还是服务那段路径出了问题,再去切换新的沙箱组。

这些检查都通过、已有的 runc 沙箱组也都处理好之后,从主机的 .env 里删掉 AGENT_SANDBOX_BACKEND=container,重新部署。deploy/host/sandbox_host.sh up 会针对它构建的镜像,把这些检查再做一遍:

  • 用默认的 kata 后端时,不仅要装好 shim,而且从沙箱镜像启动的一个真实容器,必须报告一个和主机不同的内核。如果守护进程接受了这个运行时,沙箱却仍然跑在主机内核上,这项检查就会失败;而这恰恰是所有更省事的检查方法都会漏掉的情况。

  • 不会自动退回到共享内核。如果你确实接受共享内核,仍然可以,但必须明确选择:设置 AGENT_SANDBOX_BACKEND=container,每次部署都会再提醒一遍它的代价。

调度与运行时证明

控制平面在调度时,也会独立地检查同一件事。控制平面的 compose(deploy/control-plane/docker-compose.yml)里,AGENT_DEFAULT_EXECUTION_PROFILE 已经是 untrusted-kata-v1,所以这是默认行为,不需要主动开启。这个默认值意味着:如果部署里的主机都没有配好 Kata,就根本不会认领任何需要安全隔离的任务,而不是退回到共享内核。即便如此,在至少一台已注册的 Kata Host 通过预检之后,还是建议显式地设置它,让这个值在部署的 .env 里一目了然。一个需要安全隔离的任务,只有在同一个认领事务里同时满足下面所有条件,才能被认领:

  • 调用方出示的是这台 Host 当前有效的、注册时签发的、本机专属的凭据;不存在用共享 dispatcher 令牌认领的途径;

  • 确切的那个 runner 副本报告的是 io.containerd.kata.v2;

  • Python 预检用这个运行时启动了配置的沙箱镜像,并观察到一个非空的、和 Host 内核不同的 guest 内核。

attempt 记录的运行时、镜像摘要、CLI 版本和内核标记,都来自认领本身,而不是 Host 那条会变的心跳记录。这一点在滚动部署时很重要,因为那时 runc 副本和 Kata 副本可能暂时共用同一条 Host 记录。缺了探测、runner 版本太旧、用了自定义的运行时、自己声称有 kata 能力,或者 Host 凭据已被吊销,这些情况都根本认领不了需要安全隔离的任务;也没有退回 runc 的路径。trusted-v1 选的是评审过的、给受信任内部仓库用的共享内核边界,并不是换了一种工作区或会话执行器。每条后续消息和每次 Fork,都沿用保存在对话上的 execution profile。保留的对话在不可变的容器标签里记着最初的镜像摘要;在 Docker 返回容器 ID 之前如果需要重试,就用这个按内容寻址的镜像。所以即使镜像标签被挪到了别的镜像上,只要数据库记录里写的还是旧镜像,对话实际用的镜像就不会变。

需要多少 runner

保留的共享会话拓扑是无条件启用的。一个普通对话,就是一个只有一个成员的沙箱组:一个容器和工作区,一条在线的 CLI 通道。Fork 会立即调用固定版本 CLI 原生的、专门用于 fork 的接口,在同一个容器里再加一个对话、会话和进程。父对话和子对话可以同时执行,组内没有轮次锁。

清理成员进程

每个成员都有一个固定且互不相同的 UID。交出一个轮次之前,主机会通过沙箱镜像里装的、root 所有、权限 0555 的 member-process-reaper,记下这个 UID 下所有进程的 (pid, /proc starttime) 清单。清理时,把这份基线通过 stdin 交给一个新启动的辅助进程;它在每次发信号之前都会立即重新核对进程身份,从 TERM 升级到 KILL,再扫描一遍,只要还有进程没退出,就判定失败。这份基线绝不是成员能写的文件,所以仓库代码没法改它来保住某个进程,或者让清理误杀一个被复用了 PID 的进程。

shared-sandbox-v1 是认领时的一项证明,不是功能开关。主机必须从实际认领任务的那个进程声明它。一台主机的并发上限至少为 2,因为 Fork 出来的对话要和原对话同时运行;在管理员到 Servers 页面设置数值之前,这个上限就是 AGENT_HOST_SLOTS(默认 3)。

Fork 的兼容性与轮次顺序

在历史某一轮做 Fork,还需要 native-fork-at-anchor-v1;对于保留的沙箱组,还要满足以下两者之一:主机当前的沙箱镜像就是这个组自己用的镜像;或者这个组的 claude-session-fork 辅助程序是评审过的那个版本(创建容器时记在 agent_sandboxes.fork_helper_version 里),同时主机的镜像里仍带着这个容器正在用的那个确切 CLI 版本。所以发布新版本并不会让已有的对话失去 Fork 能力:镜像会重新构建,但只要这次发布没改动,里面的辅助程序和固定版本的 CLI 都不会变。较旧的、版本未知或没评审过的 fork 辅助程序,仍然按"镜像必须完全一致"的规则处理,绝不会假定它能理解历史某一轮的锚点;主机的 fork driver 也绝不会指向它自己镜像里已经没有的 CLI 版本。给一个正在运行的轮次发消息,遵循同样的 CLI 版本规则,和 fork 辅助程序无关。

迁移 0026_fork_helper_version 会给在这个 schema 下创建的已有沙箱组记上版本 2:旧的协议 v2 认领检查和新的辅助程序版本检查,要求的都是同一个按锚点 fork 的实现。从旧数据库复制过来的沙箱组,不做记录。runner 新建沙箱组时,会在预留请求里上报辅助程序的版本;之后的预留不能用重新构建后的主机镜像版本去覆盖它。旧版 API 会忽略这个可选字段,旧版 runner 则根本不发它,于是新记录也保持未记录状态,直到能证明它的镜像具备这项能力为止,而不是根据主机当前的镜像去推测。

对话状态在主机上唯一的控制和数据接口,是 Docker socket(或者评审过的 mTLS Engine 端点)。仓库、工作区、Agent CLI 的主目录和会话文件,都不会从主机映射进去。没有共享保留沙箱的历史记录无法运行,会直接失败,包括上线期间遇到的、旧版本留下的一个还在进行中的父对话。切换之前由旧 API 插入的、排在后面等待的轮次,会在那个父对话结束时失败;绝不会把它升格成一个新凭空造出来的原生会话。

所有已提交但还没开始的轮次,都处于 queued 状态,包括在前一轮还在运行时发出的消息。只有同一个对话里所有更早的轮次都进入终态之后,runner 才能认领一个排队的轮次。取消中间某个排队的轮次,也不会让后面的消息插队到一个仍在进行的更早轮次前面。没有单独的"提升"步骤,也没有 waiting 这种任务状态。

终端冻结也是按成员各自处理的:每个受保护的任务阶段,都为共享工作区单独持有一个 Sandbox Manager 挂起令牌。一个兄弟对话完成或恢复时,只释放它自己的令牌;只有在没有任何兄弟对话还处于受保护阶段时,交互式 PTY 才会恢复。

执行槽位

并发在管理后台的 Servers 页面设置。runner 容器在每台主机上只跑一个服务,负责注册、心跳和采集容量这些一台机器只需做一次的事;只有在没达到上限时,它才认领轮次,每认领一个就为它启动一个进程。这个进程把这一轮跑完就退出。所以这个上限就是主机同时运行的轮次数,其余的都在队列里等着。每个轮次的沙箱上限是 4g / 2 cpus,按每个并发轮次大约占这么多来规划资源。

可以在 Servers 页面上设置(Execution slots,在数字下方的那个字段),也可以用 PUT /admin/agent/runner-hosts/<host>/slots,请求体是 {"slots": N};传 null 会让主机回到它的 AGENT_HOST_SLOTS 默认值。应用这个值不需要启动或停止任何东西:主机会从下一次心跳的应答里读到这个数字,从那以后就按它来。

保存不等于生效,页面会把两者区分开。 心跳会以 slots_total 回报主机实际使用的上限,认领时按它和已保存数值中较小的那个来判断。所以调低会立即生效,甚至在主机知道之前就生效了;已经在跑的轮次不受影响,它们会跑完,在降到新上限以下之前,主机不会再认领新的轮次,页面把这些显示为 finishing。调高要等主机确认之后才生效,也就是再过一次心跳;在那之前,这一行显示 applying N。离线的主机显示 N not applied;如果 runner 早于"远程设置上限"这个功能,就显示 redeploy to change。

可设范围是 2 到 128。2 是 shared-live 的下限;128 只是防止手误,主机那边还会再检查一次。两者都不代表这台机器实际能承受多少,那要看同一页面上的 CPU 和内存。保存的数值在心跳、重启和重新注册之后都会保留,因为它是针对这台具名机器做的决定;只有选择 Host default,才会让它回到环境变量里的值。

移除服务器

在管理后台的 Servers 页面打开一台服务器的详情,从操作菜单里选 Remove server。确认永久移除后,会删除它的分配、节点、已注册的主机、主机设置和容量历史、节点注册令牌、准入限制,以及计算资源的操作记录。共用的连接和部署配置仍然保留。用户的对话、运行记录,以及已删除沙箱的审计记录,都会保留下来。

删除记录之前,先把云资源释放掉。不支持释放的已挂接服务器,可以在禁用它的主机之后移除。保留的沙箱,要通过沙箱删除流程移除,并等清理完成;还要先清掉默认的放置固定、正在运行的 attempt 和部署排空。移除时会在清理的同一个事务里检查这些依赖。如果一次申请从没提交给服务商,即使资源观察不可用,也可以直接移除。

另外还有三种情况,移除会以 The external operation is unresolved 拒绝。服务器的 Activity 标签页会列出涉及的操作:

  • 操作正在运行,或者正在等待确认一次服务商调用(申请在等它的实验,续期或释放在等服务商的答复),说明它还没结束。等它完成或停止。

  • 结果为 unknown 的操作,比如创建请求超时的申请,要等管理员来处理,这期间请求的释放也排在它后面。打开它重试;如果是申请,也可以认定它创建的那个实验。

  • 服务器从没记录过它的服务商资源,而它的某个操作显示服务商那边可能有一个时,移除会被拒绝。这类迹象包括:服务商的答复里提到过这个资源、申请已经过了提交这一步、有节点已经注册,或者某次调用一直没有得到确认。这时要释放这台服务器。释放会删除操作里提到的资源;没有提到的话,就记为不存在资源,所以先去服务商那里查一下,这次请求有没有创建出东西。

服务商一旦确认机器已经没了,停下来的操作(被阻塞、失败或已取消)就不会再挡住这台服务器,即使它是在确认服务商调用时停下的:那次调用现在已经改变不了什么了。

计算资源的 API 是 DELETE /admin/agent/servers/<allocation_id>,请求体是 {"expected_revision": N}。修订号冲突时,需要先刷新这台服务器。没有分配的主机,这个操作走现有的、带保护的 DELETE /admin/agent/runner-hosts/<host> 接口。非托管的主机 agent 要先停掉再移除;清单清理不会关掉那台机器,也不会删除文件。

沙箱设置

沙箱和工作区的限制,都在管理设置页面里配置。轮次时间上限、仓库大小上限、容器的 CPU、内存和磁盘上限、沙箱空闲超时、每个沙箱的会话数、工作区命令和文件的限制,以及终端资源限制,有两个地方可以设:Settings 页面的 Sandbox 卡片里,对所有服务器生效;Servers 页面里每台服务器详情的 Sandbox 表格里,只对那一台生效。对应的 API 是 PUT /admin/agent/host-settings 和 PUT /admin/agent/runner-hosts/<host>/settings,请求体是 {"settings": {"<key>": N}},传 null 就清除这一项。服务器自己的值优先于全局值,两处都没设的就用内置默认值。迁移 0036_host_settings_import 会把已有的、有效的、非默认的 .env 观察值,导入为对应服务器的设置,但不会覆盖管理员已经做出的选择。还没上报完整数据的主机,靠它最初的几次心跳完成导入。管理员编辑或重置某一项时,只会把改动的那几项从待导入列表里去掉,其他旧的自定义值都保留。全局层面去掉的项,对新注册的主机同样有效。之后再改 .env 不会起任何作用,清除覆盖值也绝不会把它们恢复回来。优先级和初始变量见 ENVIRONMENT.md 里的 在管理页面上设置的沙箱参数 一节。这些值和槽位上限一样随心跳的应答下发,不需要重新部署,同样的规则也适用:保存不等于生效。每台主机都会上报自己实际应用的值:runner 那部分在交出每个轮次时上报,sandbox manager 那部分在 sandbox manager 确认时上报;上报的值对上之前,页面一直显示 applying。正在运行的东西不会被中途截断:一个轮次仍按它开始时的时间上限执行;已有的沙箱保持原来的 CPU、内存、磁盘配额和对话容量;终端寿命改短之后,已经超时的终端会在 sandbox manager 下一轮巡检时关闭。不声明 host-settings-v1 的 runner,在重新部署之前会一直用自己 .env 里的值。

容器大小的设置作用于每个新建的沙箱组,组里的对话和 Fork 共用这些资源。默认是 2 vCPU、4 GiB 内存,磁盘为 0(不设单容器配额)。CPU 可以填小数的 vCPU;内存和磁盘可以用 MiB 或 GiB。磁盘上限设为正数时,会用 Docker 的 --storage-opt size= 限制可写层,包括工作区和安装的文件。这要求 overlay2 跑在用项目配额(pquota 或 prjquota)挂载的 XFS 上;主机不支持的话,Docker 会拒绝创建容器。已有的沙箱组在后续消息、重启、快照和 Fork 时,都保持原来的限制。初始化变量和存储要求见 ENVIRONMENT.md。

建议在运行不受信任任务的服务器上设置。 一个 untrusted-kata-v1 沙箱的可写层里存着它的工作区和所有安装的文件,docker stop 之后也还在,并且和这台服务器上其他所有沙箱共用一块磁盘;所以不设磁盘上限的话,一个租户就可能把这块磁盘写满,影响所有人。只要某台 Kata 服务器的 Docker 数据目录在带项目配额的 XFS 上,就把它的 Container disk 设成大于 0。这是建议,不是硬性要求:调度和准入都不会因此改变,保持 0 的服务器照样接不受信任的任务。每次认领都会上报新建沙箱组会拿到的上限(认领证明里的 sandbox_disk_limit_mib)。控制平面把一个新的不受信任沙箱组交给这样的服务器时,会记录 agent_untrusted_sandbox_without_disk_quota;runner 启动这个组时也会记录同样的事件;Servers 页面会在这台服务器的 Storage 下面显示 No container disk quota。已有的沙箱组保持创建时的上限,不会被标记。

沙箱归档

这个策略负责自动生成的初始归档和最终生命周期归档。显式操作 Archive to S3 and delete 有它自己的控制平面存储配置,见沙箱归档存储。

在 Admin → Settings → Sandbox 下的 Sandbox archives 卡片里,把部署级别的归档策略保存到 Agent 数据库。存放位置固定为 s3://freeinference/cloud-agent/sandbox,只读显示,绝不接受浏览器传来的值。这个位置是写在源码里的常量(cloud_agent_shared/sandbox_archive_settings.py 里的 SANDBOX_ARCHIVE_S3_URI),所以如果发行版要归档到别的桶,就在那里改;下面的 IAM 策略写的也是同一个桶和前缀。管理员一次性保存这几项:是否开启、AWS 区域、压缩后归档的最大大小、分段上传的阈值和分段大小。认证有两种方式:一组 AWS 访问密钥;或者 workload identity 需要的一对字段,即客户自管的 KMS 密钥 ARN 和预期的 12 位桶所有者账号。用访问密钥时,这对字段可以不填;这时上传使用 SSE-S3,除非另外也设了 KMS 密钥。secret 只能写入,加密保存,也不会出现在审计记录里。更新时要带上次读取返回的修订号,所以两个管理员不会悄悄覆盖彼此的修改。每次改动都会记在 Audit log 里。

仪表盘用的是只有管理员能调用的 GET 和 PUT /admin/agent/sandbox-archive-settings。如果想直接在 Postgres 里查看同一份策略,以及各主机的生效情况,可以用这样的只读查询:

SELECT sandbox_archive_settings,
       sandbox_archive_revision,
       sandbox_archive_updated_at
FROM agent_runner_policy
WHERE id IS TRUE;

SELECT host,
       sandbox_archive_settings_applied_revision
FROM agent_runner_hosts
ORDER BY host;

不要把归档策略、存放位置或 AWS 访问密钥写进 .env。如果 sandbox manager 没有 workload identity,就在 Sandbox archives 卡片上填写访问密钥。控制平面会先加密这个 secret 再保存,并通过带认证的心跳通道发给 sandbox manager。sandbox manager 只把它放在内存里,供访问 S3 用。沙箱标记里不包含它,所以它不会进入容器。不支持访问密钥的旧版 sandbox manager,会拒绝这份心跳数据,也不会确认这个修订;要用访问密钥,先把这些 sandbox manager 升级。没有访问密钥时,sandbox manager 使用主机的 AWS workload identity 和普通的 SDK 凭据链。这个身份需要对固定前缀拥有范围很窄的 S3 分段上传和写入权限、对具体对象的元数据权限,以及对配置的 KMS 密钥的加密权限。桶必须开启版本控制。生命周期保留规则和访问日志,在桶本身上配置。

具体来说,要给 sandbox manager 的角色授予:对 arn:aws:s3:::freeinference 的 s3:GetBucketVersioning;对这个桶、并限定在 cloud-agent/sandbox/* 前缀下的 s3:ListBucket(这样按确切键执行的 HeadObject,才能区分"不可变的初始对象还不存在"和"没有权限");以及对 arn:aws:s3:::freeinference/cloud-agent/sandbox/* 的 s3:GetObject、s3:PutObject 和 s3:AbortMultipartUpload。配置的 KMS 密钥策略,必须允许 SSE-KMS 分段上传所需的 kms:GenerateDataKey 和 kms:Decrypt。用访问密钥时,也需要同样这些 S3 操作;SSE-S3 上传不会调用 KMS。再加一条 AbortIncompleteMultipartUpload 的桶生命周期规则作为兜底,以防某台主机在它有时限的中止请求完成之前就消失了。

开启归档需要迁移 0042_sandbox_archive_settings,以及声明了 sandbox-archive-settings-v1 的 runner 和 sandbox manager。设置卡片会把数据库里保存的修订号,和每个 sandbox manager 确认已应用的修订号分开显示。归档开启期间,如果某台主机或某个 runner 副本还没应用这个修订,新的认领在它上面会直接失败;关闭这个策略,就能继续兼容旧主机。

这是生命周期归档,不是每一轮的对话记录:

  • 初始对象在源码和 setup 完成之后、第一个 Agent 进程收到提示词之前,对 /workspace 拍一次;

  • 每次由 sandbox manager 正常执行的停止,都会先把原生 Agent 的状态写盘,再保存一个最终对象,包含 /workspace、/agent-state,以及可选的 /home/agent/.freeinference-platform 状态;

  • 沙箱一直在线期间的多次轮次,不会生成新的对象。之后如果唤醒、又一次正常停止,会覆盖那个固定的最终对象键;S3 桶的版本控制会按桶的生命周期策略,保留它以前的对象版本。

策略的改动只对之后生效。如果一个沙箱在策略关闭期间就已经运行过,开启归档也没法补出它真正的、提示词之前的基线,所以这样的沙箱可能只有最终对象,没有初始对象。关闭策略会阻止新的归档准备,可能留下一个只有初始对象、没有之后最终对象的情况。但它不会放弃开启期间已经准备好的持久化工作:上传结果还不确定的初始基线,或者已经为最终捕获设好栅栏的正常停止,都会固定在原来的修订上,并按那个修订完成。如果需要成对的"前后"归档,就开启策略,并等设置页面显示每台兼容的服务器都已应用这个修订之后,再开始接新的任务;关闭策略之前,先把那些沙箱停掉或排空。

初始对象是不可变的,重试也安全。最终捕获和停止钩子同步执行,受配置的大小上限和操作截止时间约束;如果上传重试用尽,会报告为一次失败的正常停止,而不是谎称成功。初始归档和最终归档只用于导出和事后取证:没有任何功能会列出、下载或重放它们。要在另一台主机上恢复沙箱,用的是下面单独的可恢复检查点;平时停止再启动时,仍以保留的 Docker 可写层为准。

不是由 Sandbox Manager 正常执行的停止,就没有最终归档。这包括:主机崩溃或重启;Sandbox Manager 退出了,沙箱却还在跑;有人绕过它执行了 docker stop;以及原生传输通道没能正常关闭。这些情况下,无法证明原生历史已经写盘,所以 Sandbox Manager 不会上传,而是记一个 skipped 的最终标记(事件 sandbox_final_archive_skipped),释放网桥,并让对话继续:下一轮照常经过原生恢复检查后接着跑,和关闭归档时一模一样。下一次正常停止会照常归档,所以 S3 里不会有被跳过的那次停止的最终对象。旧版 Sandbox Manager 标为 blocked 的沙箱组,升级后也按同样的方式处理。只有在跳过记录没法持久写入,或者在任何策略下发之前就被发现的沙箱组,才会连同它的网桥一起被隔离,直到调和成功为止。

可恢复检查点

归档开启期间,每次带归档的正常停止,还会多存一个对象:容器相对于沙箱镜像的改动检查点。这项功能默认开启;在同一张设置卡片上取消勾选 Save restorable checkpoints 就能关闭,关闭归档也会一并关闭检查点。Sandbox Manager 会对已停止的容器执行 docker container commit,然后上传一个 OCI 镜像归档,里面只有这次 commit 的 manifest、config 和新增的层,路径是 cloud-agent/sandbox/v2/<sha256(sandbox id)>/g<generation>/<checkpoint id>.oci.tar。这个路径标识的是沙箱而不是主机,所以别的服务器也能恢复它;见恢复被移除主机上的沙箱。

  • 检查点依赖归档,用的是同一个桶、同样的凭据、同样的大小上限,以及对 cloud-agent/sandbox/* 的同一套 IAM 权限。每个对象只写一次(If-None-Match);响应丢失后重试时,必须找到完全相同的字节。

  • 检查点绝不会让一次停止失败。它在最终归档持久化之后才运行;失败时记一条 sandbox_checkpoint_failed,停止照常完成。它和最终归档共用同一个捕获槽位和截止时间,会让每次正常停止多做一次 commit、一次导出和一次上传。

  • Sandbox Manager 直接从 containerd 的内容存储里读取新增的层,这个存储以只读方式挂载在 AGENT_CONTAINERD_CONTENT_DIR。主机 Compose 会把 AGENT_HOST_CONTAINERD_CONTENT_DIR(默认 /var/lib/containerd/io.containerd.content.v1.content,也就是 Docker 的 containerd 镜像存储背后那个系统级 containerd)绑定到这里。blob 都以摘要命名,复制的每个字节都会对照摘要校验;blob 损坏的话,检查点就失败。这个挂载授予的权限,没有超出 Docker socket 本来就有的范围。如果挂载不存在、是空的,或者属于另一个守护进程,Sandbox Manager 会记一条 sandbox_checkpoint_content_unavailable,退回到用 docker image save 流式导出整个镜像(扫描基础镜像时每个镜像导出一次,之后每个检查点各导出一次)。它只保留 commit 后的镜像实际用到的 blob:经典的 overlay2 镜像存储在 save 时,还会输出一些没有任何 manifest 引用的元数据 blob,恢复时会拒绝它们。在一台 CloudLab Kata 主机上实测,对一个 12 MiB 的改动,save 这条路每次导出大约要 90 秒;而内容存储这条路只写改动本身。

  • Sandbox Manager 在 AGENT_SANDBOX_ARCHIVE_STATE_DIR/checkpoints 下给每个沙箱保留最新的一张回执,并在每次心跳里重复上报。只有回执上写的是这个组当前的主机、代次和容器,控制平面才会把它记进 agent_sandbox_checkpoints。管理后台的沙箱页面会显示最新的一张。回执刚记录时是 reported,等到某次恢复成功加载并启动了它,才变成 verified。

  • 每张回执都写明了这次停止所结束的那次运行对应的启动栅栏(start_seq)。凡是可能启动容器的路径,比如一个轮次、一次工作区文件、终端、Git 或环境请求、一次 Fork、一次快照采集或基线导入,都会先把栅栏往前推,再把推出来的值随请求本身一起发出去:写在请求体里,工作区请求则放在 X-Agent-Start-Seq 请求头里。Sandbox Manager 只用这次运行真正接纳了的请求的栅栏给运行打标。运行停下以后,栅栏不高于这个标记、又要重新启动容器的请求会被拒绝(409),控制平面把栅栏再往前推一次后重试。运行中的容器同样会清掉恢复点。只有回执上的栅栏仍是当前值时,它才算覆盖了这个沙箱组;还不支持恢复的旧版 sandbox manager 给出的回执不带栅栏,永远不算覆盖任何沙箱组。

  • 策略里不写默认值,只带一个关闭选项(checkpoints_enabled: false),并且只发给声明了 sandbox-checkpoints-v1 的主机。旧版 sandbox manager 照原样解析策略,继续只做归档,不做检查点。

  • 归档里是容器的可写层:工作区、原生会话状态,以及装在 /workspace 之外的所有东西。凭据所在的 tmpfs 挂载不会被 commit 进去。Sandbox Manager 自己为停止的那一代写下的归档标记也在里面;恢复出来的容器会把本组更早一代的标记当作不存在,再写上自己的。桶的生命周期和保留规则,对 v2/ 前缀和其他归档一样适用。

恢复被移除主机上的沙箱

当一台主机离开资源池,不管是在 Servers 页面点了 Remove,还是自动扩缩容释放了它的节点,它的凭据都会在一个事务里被吊销,这个事务同时会检查它上面已停止的沙箱组。如果一个沙箱组最新的检查点仍然覆盖着它,它就会变成 cold。"覆盖"的意思是:容器、代次和启动栅栏都没变;每个成员最后一个已结束的轮次都有记录;并且有一个别的主机能按摘要拉取的基础镜像。其余的沙箱组和以前一样,仍然绑在被移除的主机上,只有那台主机重新注册后才能继续;管理后台的沙箱页面会标明哪些是哪种。吊销凭据时如果还有一次带检查点的心跳在路上,这个检查点会在 reaper 的下一轮交出去。

已移除的主机上,不会再启动任何沙箱组,因为那台主机不会再上报能重新覆盖它的那次停止。工作区请求(Files、Terminal、Git 或环境重试)会返回 workspace_unavailable(409),Fork 会被拒绝,发布时的捕获和基础导入会一直等到这个组迁走为止。所以,还在路上的检查点一到,就会覆盖它的组;请求或 Fork 发现组已经被覆盖,会立刻把它交出去,这时请求返回 workspace_restoring。

还有两种情况,会把有检查点覆盖的已停止沙箱组变成 cold,但不吊销主机:一是服务商确认节点已被回收,这时会把它们全部交出去;二是主机在租期结束前迁出,这时每个沙箱组的最终检查点一到,就把它交出去。主机记录、它的凭据和任何部署排空栅栏,都留给移除流程去处理。

cold 沙箱组不会妨碍它原来的主机被遗忘,不管是点 Forget,还是永久移除服务器:它已经不需要那台机器了。在某次认领把它迁到认领它的主机之前,它记录的一直是它最后运行的那台主机。其他没删除的沙箱组仍然会挡住遗忘。

cold 沙箱组里任何一个对话的下一轮,可以被任何一台声明了 sandbox-restore-v1、并开启了归档的主机认领,只有仍然持有这个组容器的那台主机除外:容器名里不带代次,所以那台主机(或者以同样名字重新注册的主机)会和旧容器撞名。恢复失败不会留下容器,所以试过的那台主机可以再试。认领会把这个组搬到新主机名下,并推进它的代次(restoring),这样旧主机手里剩下的任何东西都会被隔离在外。新主机的 Sandbox Manager 会:

  1. 如果本机没有这个组的基础镜像,就按摘要拉取;

  2. 下载回执里写明的那个检查点版本,检查大小和 SHA-256;

  3. 检查归档的内容:只能有一个镜像,每个 blob 都校验过,每一层要么是基础镜像里的,要么包含在归档里,此外不能有别的东西。然后自己重写 index.json 和 manifest.json,保证加载时只会创建检查点自己的那个名字;

  4. 加载镜像,用这个组自己的资源限制和网络,从它创建容器,打上检查点 ID 的标签,然后像任何一次重启之后那样,接上每个原生会话。

下载下来的文件和校验过的归档,都是 AGENT_SANDBOX_ARCHIVE_STATE_DIR/checkpoint-spool 里没有名字的文件,Docker 从标准输入读取归档,所以恢复或快照加载因为重启、强制停止或 OOM killer 而中断时,不会在这个持久目录里留下任何东西。更早版本的 Sandbox Manager 会把这些文件命名为 restore-*;这样的文件都是被中断的恢复留下的,只要当前没有恢复在跑,就可以删掉。

加载的镜像 freeinference-checkpoint/<sandbox hash>:<checkpoint id> 只属于那个容器,并随容器一起删除:沙箱组被删除或丢弃时,以及之后迁回同一台主机、替换掉旧容器时。如果迁回时恢复的是旧容器自己的检查点,就直接用它的镜像,不会再加载一次。如果恢复在沙箱组启动之前就失败了,会删除它创建的容器(连同匿名卷)和它加载的镜像:下一次尝试可能由任何主机认领,所以即使在同一台主机上重试,也会重新下载检查点。删除镜像失败会记为 sandbox_checkpoint_image_remove_failed,删除容器失败会记为 sandbox_create_cleanup_failed;留下的东西可以用 docker image rm 和 docker rm --volumes 清掉。

如果恢复在轮次被准入之前就失败了,沙箱组会回到 cold,轮次回到队列,不消耗 attempt。第一次失败后 30 秒、第二次失败后 5 分钟、第三次失败后 30 分钟之内,这个组都不会再被认领,这样几次尝试加起来,能撑过镜像仓库或 S3 的短暂故障;第四次恢复失败时,这个组会被标记为丢失,排队中的轮次也随之失败:用完最后一次机会的那个对话立即失败,它的 Fork 兄弟对话在 reaper 的一轮之内失败。如果一次认领在主机预留这个组之前就结束了,不管是怎么结束的,它都根本没碰到检查点:可能是控制平面签发不了这一轮的凭据,或者主机启动不了这一轮,于是交回;可能是交接时拒绝了这一轮(所有者失去了 Agent 权限,或者它的模型或仓库不再被允许),或者主机没能启动它,于是失败;也可能是主机认领后马上挂了,于是被回收。这些都不算恢复失败,也不会触发等待。检查点本身永远不会被改动。在某个轮次把它恢复好之前,对 cold 或 restoring 沙箱组的工作区请求都会返回 workspace_restoring(409)。恢复时读取 S3 用的,是主机本来就会拿到的归档凭据:对归档前缀的 s3:GetObject 和 kms:Decrypt。

主机消失时正在运行的那一轮不在覆盖范围内,仍然会以 LOST 结束。用本地构建的沙箱镜像创建的沙箱组没法迁移,因为别的主机拿不到那个镜像;自动扩缩容的主机应该用 AGENT_SANDBOX_IMAGE_PULL=true 拉取已发布的镜像(见 ENVIRONMENT.md)。如果一台主机在它的沙箱组被迁走之后又重新注册了,它没法再启动或上报这些组,因为它们的代次已经往前走了。用管理后台的 Discard 操作,把那些旧容器删掉。

环境安装快照

如果新任务所用的环境带有安装脚本,可以直接从保存好的安装结果启动,不必再跑一遍脚本。快照就是环境安装完成后的容器,不含仓库自己的文件,和检查点一样存储。设计见安装快照。

  • 跟着检查点一起开启。 快照要求归档和检查点(Save restorable checkpoints)都已开启。任何一个关着,每个任务都会完整安装,环境列表上也不显示快照状态,什么都不会排队。

  • 存在哪里。 cloud-agent/sandbox/env/v1/<sha256(owner)>/<snapshot id>.oci.tar,在主机本来就会写入的那个前缀下,和检查点一样受同样的大小上限、KMS 加密和 If-None-Match 写入约束。负责构建的主机会把 commit 后的镜像在本地保存为 freeinference-environment/<owner hash>:<snapshot id>;别的主机第一次用到时,会经过和恢复时一样的检查再加载它。

  • 由谁构建。 快照只由 prebuild 运行来构建,在用完即弃的环境运行容器里进行,绝不会从对话的工作区里取。控制平面会在这几种情况下排队一次构建:新任务没有可用的快照、锁文件变了、所有者主动要求,或者某次环境测试通过了。测试自己的容器永远不会被保存,因为它的 start 和 verify 脚本是带着运行时的变量值跑的,而这些值不在 setup 指纹的范围内。预构建占用的是环境运行的槽位,只交给声明了 environment-snapshot-v1 的主机,并且永远不算作所有者那唯一一个正在运行的测试。一次构建失败后,6 小时内任务不会再为这个安装排队新的构建;但测试通过或所有者手动重建,仍然会触发。删除快照会取消它们还在等待的预构建;只有在运行的租约仍然有效时,才会接受它的回执。如果一个预构建排队一天之后还没有任何主机开始跑,就放弃它:维护任务先让它的快照失败,再让它的运行失败(反正也不会再有认领接手),这样它既不会永远等下去,也不会被自动扩缩容当成排队中的工作。

  • 谁会用到。 只有在给一台主机上的新沙箱组做认领、这台主机的沙箱镜像正好是快照的基础镜像,并且绑定上的 setup 指纹一致时(指纹是以 AGENT_SOURCE_CONTROL_ENCRYPTION_KEY 为密钥,对安装脚本和 setup 阶段的变量算出的 HMAC),认领才会指定一个快照。runner 还会比对仓库里锁文件的 Git 对象 ID。只要有任何不一致或出错,就改为从沙箱镜像创建这个组,并完整安装;轮次永远不会因为快照而失败。没有那把加密密钥时,绑定上不带指纹,也就不会生成任何快照。runner 会上报新沙箱组怎么处理了它的快照:用上的快照会因此保留,没通过检查的会被删掉,还会触发上面说的构建。这份上报只接受来自持有该任务有效租约的 attempt,而且它所在的主机仍处于注册状态;其他 worker 令牌会得到 409 lease_lost,什么都不会改变。

  • 保留多久。 同一个安装,最新的快照会取代旧的快照。改动一个安装,比如改了安装脚本,或者改了某个 setup 阶段的变量或 Secret(包括全局的),会让之前那个安装的快照全部作废;如果某次构建在改动之后才完成,它的回执一到就会被作废;为任务自己的安装脚本构建的快照,会对照那个脚本检查。维护任务还会把每个所有者就绪的快照和当前的配置做对比,每 30 秒一轮、每轮 20 个所有者,所以没有经过发布、因而没被发现已经过时的快照(比如在这些检查上线之前就已经过时的),也会被取代。被取代的快照不再分发,24 小时后删除。快照满 25 天后不再分发;满足以下任一条件时会被删除:连续 7 天没人用;所属环境被所有者归档或删除;某个所有者就绪的快照总量超过 20 GiB(从最久没用的开始删)。

  • 怎么删除。 主机每 10 分钟轮询一次 /v1/agent/worker/environment-snapshots/deletions,删除它列出的对象版本,最久没尝试过的优先,这样桶拒绝删除的对象,永远不会拖住其他对象。每台主机还会列出自己本地的 freeinference-environment/* 镜像,问一下 /v1/agent/worker/environment-snapshots/retired 哪些属于已作废的快照,然后删掉它们。删除对象需要对 cloud-agent/sandbox/env/* 有 s3:DeleteObject 和 s3:DeleteObjectVersion 权限,其他功能都用不到这两项。没有这些权限时,删除会记为 environment_snapshot_delete_failed 并重试;作为兜底,请在 cloud-agent/sandbox/env/ 前缀上加一条桶生命周期规则:当前版本在创建 30 天后过期,非当前版本 1 天后过期。30 天比 25 天的使用期限更晚。

  • 安装记录。 安装时会往容器里写一个只有 root 能访问的标记文件 /var/lib/freeinference-broker/environment-setup-v1.json。对于本机没有记录的容器(从快照创建的,或者从检查点恢复的),只要标记里的指纹对得上,就认为安装已经完成,所以恢复时不会再重跑安装脚本。从快照创建的容器,接下来会运行这个环境可选的更新脚本。

  • 团队环境。 一个团队环境的快照,供每一位活跃成员使用,不管当初是谁的任务或测试排队构建了它;任何成员用到它,都会让它继续保留。它的大小计入当初排队构建它的那位成员的 20 GiB。只有团队的所有者和管理员能删除或重建它。他们改了安装脚本或安装阶段的值,就会让所有成员手里这个环境的快照全部作废;成员自己的全局变量则不会,因为团队环境安装时根本不用它们。

启动失败与队列顺序

如果一个轮次的进程启动不了,或者在接手之前就退出了,主机会通过 POST /v1/agent/worker/jobs/<job>/release 把它退回。这样不消耗 attempt,也不用干等一个没人持有的租约过期。一旦 CLI 已经接下了这个轮次,就不能再退回了,因为重放可能会把同一条提示词执行两次。这之后如果租约过期,就按保留会话的规则处理,可能会让对话结束。连续启动失败时,主机会暂停认领、逐步拉长重试间隔,并在心跳里上报,这样 Servers 页面就能解释为什么有容量没用上。

每台主机只跑一个 runner 容器。在管理员另行设置之前,AGENT_HOST_SLOTS 提供初始的并发上限;部署已经不再读取副本数。把这个服务扩成多个副本,会让同一台主机注册两次。部署检查会用空的 stdin 启动一个轮次进程,要求它先完成预检,再干脆地拒绝运行,这样在任何提示词被接下之前,就把启动这条路实际走了一遍。

队列顺序是全局的先进先出。按用户的公平调度和并发配额目前还没做,所以一个用户的一批任务,可能占满所有槽位。

沙箱组唯一的空闲计时(以及几个独立的安全看门狗)

AGENT_CHAT_IDLE_TIMEOUT_S 默认 1800 秒(30 分钟),是沙箱组唯一的空闲超时。当所有成员最近的活动都已持久地结束,并且没有任何成员或 sandbox manager 的操作还在进行时,Shared Session Supervisor 会停掉容器,并删除它的网桥。它绝不会执行 docker rm。之后再次获取时,会重新安装主机防火墙规则、重建网桥,然后启动并检查同一个带标签的容器。supervisor 自己关闭时,不会去动对话容器。

sandbox manager 启动时,调和会先停掉正在运行的孤儿容器,再重新开放准入。它绝不会只凭内存里一个没法证明的租约,就接管常驻进程,也不会因为租约表是空的,就断定有一个活跃的轮次。Docker 的观察结果通过已注册的心跳上报。如果列出主机上的沙箱时,某个沙箱组没法调和,比如它的容器丢了、标签被改了,或者在读取它的归档标记时有别的调用改动了它,就会记一条 shared_sandbox_reconcile_group_failed,并按它当前的样子上报;这绝不会连累主机上其他沙箱组的观察结果。控制平面在下发一个针对特定代次的停止命令之前,会先检查持久化的 attempt 栅栏和空闲截止时间;下一次心跳会记下 Docker 报告的 STOPPED 状态。

对于一次已证实的激活,心跳还会上报正在进行的轮次、终端、文件或 Git 操作,以及按主机单调时钟算出的剩余空闲时间。关闭终端时,会带上它最后一次活动的时间:手动关闭算作一次新的活动;回收一个本来就已经空闲的 PTY,则沿用原来的组计时窗口,不会再加一段超时。如果重建出一个代次为零、处于 RUNNING、却没有活跃 attempt 也没有截止时间的容器,会给它一个新的 30 分钟恢复窗口。数据库只接受这一次,防止之后的心跳不断延长它,或者停掉一个正在进行的轮次。

attempt 租约丢失

在提示词送进 Agent CLI 的 stdin 之前,worker 会先把它的 job_id 持久化,作为沙箱的准入回执。如果这次保留激活的 attempt 租约过期了,系统会停掉它,把上下文标为 LOST,并让任务失败;即使崩溃可能恰好发生在写入 stdin 之前,也不会自动重试。已经排在这一轮后面等待的消息,会在同一个数据库事务里一起失败;之后再发的后续消息会收到 sandbox_context_lost,而不是变成一个永远没人能认领的排队任务。这个保守的做法,是为了防止提示词或工具被重复执行。

在主机上,reaper 关闭这个成员的 residency 时,也会结束在上面运行的那次激活,效果和放弃一样,所以 Fork 兄弟对话的沙箱组仍然可以进入空闲,它的发布快照也不会因为有活跃的修改而被拒绝。runner 挂掉时正在跑的轮次,在 runner 到 sandbox manager 的轮次流关闭时就已经中止了。如果 runner 是卡住了而不是挂了,它仍然占着那条流:关闭会打断它的轮次,等流把这一轮关掉,激活才结束。runner 已经不再读取的流可能永远走不到这一步,所以 sandbox manager 的巡检会在丢失 60 秒后,关掉仍然开着的轮次。reaper 关闭 residency 时,每个成员最多等主机的 sandbox manager 5 秒,同一轮回收的成员并行处理;无论结果如何,都会吊销每个成员的授权:主机的下一次心跳会被告知这一代已经丢失,由它自己关掉进程。这样,一台不再响应的主机(这通常正是租约过期的原因)就不会拖住这一轮里其他所有主机的处理。

安全看门狗

下面这些保护各有各的用途;它们不会制造额外的空闲状态,也不会停掉一个正常的沙箱组:

  • AGENT_TIMEOUT_S 默认 3600 秒。它是针对单个卡死的 agent 轮次的硬性看门狗。到达这个时限的轮次会被取消并隔离;绝不会被当成一次普通的"已结束、转为空闲";

  • AGENT_TERMINAL_IDLE_TTL_SECONDS(30 分钟)会关掉一个客户端和进程都已经没有动静的过时 PTY。对于保留对话,PTY 实际的空闲上限,取这项保护和对话计时里先到期的那个;释放时会保留最后一次活动的时间,不会再多等一轮;

  • AGENT_TERMINAL_HARD_TTL_SECONDS(8 小时)限制单个 PTY 的最长寿命,哪怕它一直有输出也一样。它是资源和安全上的保护,不是让沙箱停用;

  • AGENT_RESIDENCY_TTL_S 默认 1800 秒,给每个成员对常驻进程的归属,以及可续期的推理授权加上栅栏。这个租约丢了,成员会被降级或重启;已经接手这个进程、但还没结束的激活,会在它正在跑的轮次关闭之后,以 LOST 上下文结束。它本身不会停掉一个正常的对话沙箱组,也不会构成另一套空闲计时;

  • 没有任何 TTL 会删除对话数据。保留沙箱只会在两种情况下被删除:管理员手动删除;或者所有者永久删除了用到它的所有对话(见沙箱清单)。Docker 存储的用量,需要运维人员另外监控。

有时间和输出上限的工作区命令,如果超时或输出超限,只会杀掉它自己那个经过核实的进程组,沙箱里其他的常驻会话和终端都不受影响。主机读取输出时,使用固定大小的块、按字节限制的诊断日志尾部,以及有上限的队列;原生 CLI 的单条记录过大时,会停掉对应的成员。即使 CLI 一直没有输出,资源检查点也会照常送到控制平面。具体的限制和边界,见主机执行限制。

Resume、Fork 与发布

没有 retained/shared 的功能开关,也没有运行时白名单的开关。Claude Code、Codex、Pi、OpenCode 和 Kilo,都要通过镜像、运行时和辅助程序的确切证明之后,才能被认领。兼容的后续消息,只会把新的提示词发给同一个常驻进程;恢复时,则在一个新进程里接上固定版本的原生会话。只要某个原生轮次已经成功结束、有持久化的原生序号、镜像的固定版本一致,并且所属主机支持,从它发起 Fork 就会在同一个容器和工作区里立即完成。界面只在这一轮最后一个 assistant 或工具边界上提供 Fork。Edit & rewind 会为所选的用户提示词,找到它之前最近的那个这样的边界,并把替换后的文字放进子对话的输入框里,但不会发送。前端把 NEW、LIVE、RESTORED 和 LOST 当作上下文的真实状态;hot/warm/cold/replay 只是诊断用的遥测,永远不会决定换用另一个执行器。当前的保留 runner 绝不会产生 replay;API 之所以还接受它,只是为了让上一代版本已经接下的 attempt,在两边并行上线期间能够完成;历史记录在界面上仍然可以查看。

一个轮次结束时,什么都不会发布。只有所有者主动要求时(POST /v1/agent/threads/{thread_id}/publish,也就是对话里的 Create PR / Update PR 按钮),平台才会发布工作区:这个请求会推进沙箱组的 epoch,并打开一个快照,包含上一次发布以来结束的所有轮次;之后的捕获、secret 扫描、推送、草稿 PR 和 base 导入,都和以前一样。工作区里还有轮次在跑时发起请求,会被拒绝(turn_running);如果已经有一次发布在进行,就直接返回那一次发布。在所有者要求之前,一个已结束的工作区不会卡住任何东西:空闲时它会停掉,它的对话也可以删除。还没发布的轮次留在保留沙箱里;沙箱正常停止后,也会保存在它的检查点里。见设计。

发出发布请求之后,快照和草稿 PR 相关的字段会异步更新。详情页和最近任务列表只在发布状态为 pending/capturing/captured/publishing 时轮询,到 published/failed 就停止;其他已经结束的任务,不会因此在后台一直轮询。

如果快照的补丁被发布器拒绝、捕获被主机拒绝,或者五次发布重试都用完了,它会停在 failed 并记下原因,它的沙箱组被标为 dirty。它不会再被重试。所有者下一次发起请求时,会打开这个组的下一个快照,取代失败的那个:每个快照都是相对上一次已发布 commit 的完整 diff,所以失败的那些改动也包含在内;如果它还带着之前被拒的内容,也会同样被拒。快照仍然按 epoch 顺序发布;只是失败的快照不会再挡住后面的快照。如果失败的那次已经把 commit 推到了 agent/<thread> 分支上(推送成功后草稿 PR 那步失败了,或者推送超时后才实际落地),这个 commit 就算是这个组自己的:发布器在推送之前,会把它记为快照的 pushed_commit_sha,记不下来的 commit 一律不推。后面的快照会接着它往下做:分支快进到后一个快照的树,那时再开草稿 PR。分支上如果有别的 commit,仍会被当作"分支被人动过"而拒绝,直到那个分支被删掉;平台从不 force-push。GitHub 以 403 或 429 返回的速率限制,会按 GitHub 给出的等待时间(Retry-After,或者额度耗尽时的重置时间)重试,最多等一小时;已发布 base 的导入如果因为凭据撞上速率限制,也一样处理。如果草稿 PR 被拒的原因只是 head 分支(head invalid),这是 GitHub 还没看到刚推上去的分支时可能给出的回答,会在同样五次尝试的额度内重试;base 分支无效则直接永久拒绝。

base 导入会把一个已发布 commit 的对象加进工作区,但不写任何 ref、HEAD 或 index,所以等这些 pack 足够旧之后(默认的 git gc 是两周),Git 自己的清理会把它们删掉。之后某次捕获如果发现已发布的 base 不见了,并不会失败:sandbox manager 会用自己的 409 拒绝,沙箱组针对同一个已发布 base 回到 importing,发布器再从 GitHub 把那个 commit 拉一遍并导入,导入完成后再做捕获。这个 bundle 是从工作区最初起步的那个 commit 切出来的,因为工作区自己的历史里一直保留着它,而 Git 会把已发布的那些 commit 一起清掉。如果缺的那个 base 从来没有被任何快照发布过,就无处可拉,它的捕获会像以前一样失败。如果一次发布一直在等一个反复失败的 base 导入(GitHub 不再提供那个 commit、访问权限被收回,或者工作区收不下这个 bundle),一小时后会失败,沙箱组保持 dirty;所有者下一次发起请求时,会重新捕获、重新导入。

导入只对它发往的那一代和那个容器生效。如果导入还没完成,这个组的主机就被移除了,另一台主机又恢复了这个组,那么旧主机迟到的答复不会改变任何东西,导入会重新发给恢复后的容器。

准入时只通过存储预留检查剩余空间;磁盘紧张告警,以及一个不修改状态的、提示词发出前的上下文指纹,都还没做。本地的功能检查,不能证明生产环境的 Kata、真实服务商或运维层面的验收都没问题。

滚动部署顺序

主机脚本和控制平面脚本,由下游的部署自动化来协调。本仓库发布的是不绑定任何部署的产物,不包含任何具体环境的部署 Actions 任务。在这里说的按确切 SHA 的就绪栅栏之下,两条部署线可以同时进行。产物部署见发布契约。

主机和控制平面何时就绪

主机会先准备好替换用的沙箱和 runner 产物,证明 Kata 可用,然后才让自己的就绪标记失效。它找出旧 runner 确切的 Compose 容器 ID,然后请 Docker Engine 做一次不限时的优雅停止。runner 停止认领,等所有已接下的轮次跑完。优雅关闭不会给轮次子进程发信号:每个子进程都会跑完自己那一轮后自然退出,交接是否被接受还不确定的子进程也一样。在这些轮次结束之前,sandbox manager 和隧道都保持可用。只有到这时,激活才会替换 sandbox manager 和 runner。隔离、隧道、运行时和保留 Docker 目录的检查都通过后,它写入 .deployed-sha,然后才发布确切的 shared-sandbox-deployment:<sha> 能力。与此同时,控制平面那边准备好产物,执行迁移。在 compose up 替换正在运行的 API 之前,部署会等一次比就绪检查开始时间更新的心跳,并对每个可以放置新对话的 sandbox manager 做反向探测。认领接口要求的也是同样确切的部署能力。处于待定状态的 runner 会注册,并以基础协议发送心跳,好让主机检查能看到它们,但它不会去轮询任务;这也防止了旧 API 在还不认识新认领字段时就分配工作。所以,标记为待定或失败的 runner,即使在 API 冷启动的初始化阶段,也拿不到任何工作。如果放置固定在主机之间换过,检查还会探测每一台仍然持有保留沙箱组的在线主机;改了放置固定,不能让旧对话从上线安全检查里消失。SHARED_READY_TIMEOUT 控制这次等待,默认 900 秒。

由控制平面托管(而不只是观察)的计算资源分配里的 Host,部署时不会等它。因为只有新版控制平面起来之后,才会把它升级上去(见计算资源);如果要求它先达到目标版本,只要有这样的 Host 在线,每次上线都会超时。所以检查会把它当作保留沙箱组的持有者来对待:如果它持有保留的沙箱组,就必须发来一次新鲜、兼容的心跳,并让 sandbox manager 能正常响应。在它报告目标 commit 之前,认领接口仍然不让它开始新的对话。因此,如果把放置固定到一台托管的 Host 上,就需要由部署中被排空的那台主机来证明目标版本。

部署用户在部署目录里写入 .shared-live-ready,它和容器所属的注册目录是分开的。runner 只把这一个文件以只读方式挂载到 /run/cloud-agent/shared-live-ready。主机脚本把内容从 pending:<sha> 改成 <sha> 时,会保留文件的 inode,所以已有的文件挂载能看到这次变化;写入为空或写了一半,都无法声明一个确切的目标 SHA。一次上线不需要改动任何凭据的属主或权限。

回滚的兼容性

部署编排器和目标代码是分开运行的。在 GitHub Actions 部署里,编排器来自工作流所在的那个 commit。在线回滚没法执行栅栏机制出现之前那些旧版本的脚本;没有当前 deploy/DEPLOY_PROTOCOL 的 commit,或者协议值不兼容的 commit,会在改动 Docker 状态之前就被拒绝。当前协议 shared-live-exact-sha-v4 对应的是单一沙箱网络:v3 的部署脚本装不了它,任何部署也不会跨过它回滚(跑过旧网络的主机,需要做一次一次性清理)。它同样排除了早于重写后 schema baseline(0001_schema_baseline,2026-09-10)的那些 commit:它们的 Alembic 迁移链已经不是数据库里记录的那一条了,所以不是 schema 兼容的回滚目标。数据库改动一律先扩展。绝不要在一个还在运行的新 API 底下把列降级回去;先把代码回滚到一个 schema 兼容的 commit,之后任何收缩性的迁移,都作为一次单独评审的操作来做。

如果下游的 Actions 是由 workflow_run 触发的,工作流的定义来自那个仓库的默认分支。要依赖新的回滚栅栏,先把编排器的改动同步到默认分支;在这次上线期间,不要对某个环境触发一个更旧的默认分支工作流。

迁移 0049_host_drain_fence 引入了持久化的排空归属,以及它的 V1 recipe 标记。增量迁移 0051_host_drain_fence_epoch 确立了当前永久的兼容底线。激活 V2 控制平面之前,部署编排器必须拒绝每一个控制平面 recipe 里没有 AGENT_DEPLOY_DRAIN_FENCE_V2 的候选版本;激活之后,应用和 schema 都不要再降到那个版本以下。recipe 里保留 AGENT_DEPLOY_DRAIN_FENCE_V1,只是为了让旧版编排器能完成过渡升级。一旦 V2 跑过,只支持 V1 的版本就不再是有效的回滚目标。

现在每台主机都有一个非空的 drain_fence_epoch。API 返回它规范的 UUID 写法;获取或转移栅栏时,要求带上同样的值,以及预期的所有者令牌。状态改动成功时会轮换 epoch。完全相同的请求令牌重试会先被检查,所以即使那次成功已经改了 epoch,成功响应丢失后的重试仍然是幂等的;而一个不同的令牌带着旧的 epoch,就算过时。

在 API 之外,数据库自己也会强制执行代次。它的 BEFORE UPDATE 触发器在 status、credential_hash、drain_fence_token、drain_fence_status_before、drain_fence_active 或 compute_node_id 变化时轮换 epoch,并且忽略任何直接给 epoch 赋值的尝试。它的 BEFORE DELETE 检查会在排空栅栏生效期间拒绝删除。这样,无论是运维人员、reaper、旧版写入方,还是放置的变化,都会让之前所有的快照失效,而不会重新造出一个只看令牌的 ABA 状态。旧版 V1 控制平面不比较 epoch,所以在运维上并不兼容,尽管它的写入照样会触发轮换。

回滚或恢复之前,先检查栅栏的状态:

SELECT host, status, slots_used, drain_fence_token,
       drain_fence_status_before, drain_fence_active, drain_fence_epoch
FROM agent_runner_hosts
WHERE drain_fence_token IS NOT NULL;

即使这个查询结果为空,兼容底线依然存在:当前的控制平面可能在下一次自动部署时创建回执。如果某个生效中的栅栏没人管了,就重新部署支持 V2 栅栏的版本,用它按令牌条件执行的释放(或者经过审计的强制覆盖)。如果做不到,就停掉控制平面和每一个部署工作流,开一个事务,锁定恰好一行主机记录,核实预期的令牌、epoch 和 slots_used = 0。只有这样,运维人员才能清除这一个生效中的栅栏:

BEGIN;
SELECT host, status, slots_used, drain_fence_token, drain_fence_active,
       drain_fence_epoch
FROM agent_runner_hosts
WHERE host = '<exact-host>'
FOR UPDATE;

UPDATE agent_runner_hosts
SET status = 'online',
    drain_fence_token = NULL,
    drain_fence_status_before = NULL,
    drain_fence_active = FALSE
WHERE host = '<exact-host>'
  AND status = 'draining'
  AND slots_used = 0
  AND drain_fence_token = '<expected-token>'::uuid
  AND drain_fence_epoch = '<expected-epoch>'::uuid
  AND drain_fence_active
RETURNING host, drain_fence_epoch;
COMMIT;

要求恰好返回一行。返回的 epoch 是新轮换出来的值,不要自己去给它赋值。如果只想清掉一张未生效的回执,就用同样精确匹配主机、令牌和 epoch 的条件,再加上 NOT drain_fence_active;只有在单独核实过的恢复确实需要时,才改状态。绝不要批量清除栅栏字段。如果所有者、令牌或 epoch 有任何不确定,就让主机保持排空,并恢复支持 V2 栅栏的控制平面。

冷启动与部署失败

这个栅栏是无条件启用的,因为 retained/shared-live 是唯一的执行器。它没法打开任何旧路径,过时的 AGENT_RESTARTABLE_* 或 AGENT_SHARED_LIVE_* 值也绕不过它。失败时,现有的控制平面继续运行,部署停下来等运维人员修复。只要 API 和 MCP 中继缺了任何一个,主机就没法完成全部就绪探测(真正的冷启动两个都没有)。迁移之后,控制脚本只会单独启动缺失的那个或那几个服务。检查没通过时,只会停掉这次调用自己启动的服务,原本就在的对端会继续运行,方便诊断。在同一道按确切 SHA 的栅栏通过之前,Web 不会启动。一旦通过,这些初始化时启动的服务就归入期望的目标版本:之后如果最终的 API 或 Web 探测失败,这套服务会继续运行以便诊断,但不会写入 .deployed-sha。

保留旧迁移链的数据库

旧的 Alembic 修订,没法用重写后的迁移图升级。如果必须保留历史,就在一个单独恢复出来的副本上运行 scripts/convert_legacy_database_copy.py,而不是用下面那种只保留部分状态的重建。转换脚本目前只支持 0025_retained_followup_gate 这一个版本;其他旧修订需要各自经过审计的转换。它会把所有原始的行和列保留在 legacy_archive 里,在约束开启的情况下建好真正的 baseline,复制受支持的在线状态,并在一个事务里执行仓库里的增量迁移。如果有活跃的任务、有其他客户端连着,或者副本已经转换过,它都会拒绝。

  1. 在拍最终快照之前先排空,并停掉所有写 Agent 数据库的进程。保留一份完整的、自定义格式的 PostgreSQL 转储、它的校验和、当前的代码和镜像,以及 Agent 的配置。在一个没有连接任何应用的隔离 PostgreSQL 实例上,演练一遍恢复这份转储;不要把线上数据库当作转换的目标。

  2. 把恢复出来的数据库命名为 agent_legacy_copy_<unique_name>。私下把 LEGACY_COPY_DATABASE_URL 指向这个副本;脚本有意绝不会退而使用 AGENT_DATABASE_URL。使用目标版本的代码目录和它的 Python 依赖。先审计,再转换,并使用新的报告路径:

    python scripts/convert_legacy_database_copy.py \
      --database agent_legacy_copy_rehearsal --report audit.json
    python scripts/convert_legacy_database_copy.py \
      --database agent_legacy_copy_rehearsal --report conversion.json --apply
    

    报告里只有计数和哈希,没有用户数据,也没有凭据。把它们和备份一起私密保存。事务失败时,原来的 schema 保持不动。如果写报告时出了 I/O 错误,要先检查副本再重试。

  3. 核对所有归档的行、保留下来的公开数据,以及序列的高水位。在副本上跑一遍目标版本的存储查询,再做一次全新的写入。已退役的 replay 遥测会保留在归档里;它在公开数据中的连续性字段会变成 NULL,因为它不是一种原生恢复模式。已退役的产物表留在归档里,供运维人员恢复用,当前的 API 里不再提供。

  4. 先独立确认原网关的签发方,然后按 minimal-identity.md 里的流程,在转换后的副本上明确认定这个签发方。转换本身不会绑定命名空间,也不会让副本就能承接流量。现有的主机凭据、所有者身份,以及保留文件系统的路径,都要保留。

  5. 正式切换时,从一份全新的、静止状态下的快照,把验证过的流程再做一遍。核实无误后,只让 Agent 指向转换后的数据库。在两个角色都验证完之前,入口保持关闭。在有新数据写入之前,恢复时可以还原保留下来的源配置和数据库。一旦有了新的写入,就不要再让旧代码指向新的 schema,也不要丢掉新数据库:恢复时必须保留并调和这些新数据。优先采用测试过的、兼容的产物恢复。

完整的转换约定,见旧数据库副本计划。

重建 Agent 数据库

这个流程从使用已退役 Alembic 迁移链的部署里,保留选定的状态,并有意丢弃任务历史。它在 2026-09-10 演练过。如果必须保留历史,请用副本转换。这里选择保留哪些表,是在后来新增凭据和配置之前定的;不要把它当作针对当前数据库的通用重置流程。

schema baseline 在 2026-09-10 重写过(0001_schema_baseline)。凡是标记着旧迁移链里任何一个修订的数据库,执行 alembic upgrade head 都会报 "can't locate revision" 失败。这样的数据库,要么像上面那样在副本上转换,要么只在有意丢弃任务历史时才重建。下面这种只保留部分状态的重建,并不是"删库再部署"那么简单:runner 把主机凭据存在一个文件里,只要这个文件还在,就绝不会重新注册;所以 agent_runner_hosts 一旦清空,每次心跳都会变成 401,runner 记一条 agent_host_credential_rejected 并停止认领,而控制平面的部署会一直等一次永远不会来的新心跳。在 compose 里实际生效的位置是 ${AGENT_HOST_STATE_ROOT:-/var/lib/hybridinference/agent-host}/credential(在 deploy/host/docker-compose.sandbox.yml 里设置);代码默认的 AGENT_HOST_CREDENTIAL_FILE=/var/lib/hybridinference/agent-host-credential(enrollment.py)只适用于在那个 compose 文件之外启动的 runner;并且一旦设了 SIBLING_COMPOSE_PROJECTS,AGENT_HOST_STATE_ROOT 就变成必填。主机的注册信息必须在重建后保留下来。同样必须保留的,还有用户的代码托管连接和 MCP 连接,因为这些凭据在数据库这边没人能重新签发;以及身份命名空间的绑定,它说明了这些记录用的 user_id 是哪个网关签发的:deploy.sh 会在两个监听器启动之前运行 python -m cloud_agent.identity_namespace --check,如果所有者数据找不到可以归属的签发方,这项检查就会拒绝启动 API。

这个流程已经在旧 schema 的副本上演练过(2026-09-10),也包括想当然的做法会怎么失败:按位置插入,会把值插进错误的列,因为重写后的 baseline 里有些列的顺序变了;主机记录如果带着旧的 slots_used 恢复,就再也不会认领,因为占着这些槽位的 attempt 已经不存在了;恢复时如果漏了身份绑定,API 在启动时就会停下;恢复后处于 draining 的主机再也不会认领,因为没有任何东西会撤销这次排空。下面每一条 docker compose 命令,都和 deploy/control-plane/deploy.sh 的调用方式一样(项目名、环境文件、compose 文件),并在已经检出目标 commit 的部署根目录里运行。示例使用控制平面部署脚本的默认项目名 cloudagent,请换成你自己的。

  1. 记下哪些主机正在接任务,然后让它们把手上的跑完。下面的排空是这次重建自己加的,必须在第 5 步撤销,但只针对被它排空的那些主机:重建之前就已经是 draining、offline 或 disabled 的主机,必须保持原样,而心跳也不会把它们重新设为在线。所以先把撤销语句写好,再做改动;然后从管理页面(或者 POST /admin/agent/runner-hosts/<host>/drain)排空,等主机上没有正在运行的 attempt,再停掉所有持有数据库连接的服务,Postgres 保持运行:

    docker exec cloudagent-postgres-1 psql -U cloudagent -d cloudagent -Atc \
      "SELECT format('UPDATE agent_runner_hosts SET status = ''online'' WHERE host = %L;', host) \
       FROM agent_runner_hosts WHERE status = 'online'" \
      > cloudagent-undrain-$(date +%F).sql
    docker exec cloudagent-postgres-1 psql -U cloudagent -d cloudagent -Atc \
      "SELECT host, status, slots_used FROM agent_runner_hosts; \
       SELECT count(*) FROM agent_attempts WHERE status = 'running'"
    docker stop cloudagent-control-plane-1 cloudagent-mcp-relay-1 cloudagent-web-1
    
  2. 先把所有东西做一份取证用的转储,再把需要保留的状态做一份只含数据的转储,包括:主机注册信息、三张用户连接表,以及这些 user_id 所属的身份命名空间绑定;然后把放置策略导出成一条语句。--column-inserts 至关重要(原因见上文);format('%L') 则能让没有固定的 active_host 保持为 SQL 的 NULL,而不是变成一个会被新外键拒绝的空字符串:

    docker exec cloudagent-postgres-1 pg_dump -U cloudagent -Fc cloudagent > cloudagent-$(date +%F).dump
    docker exec cloudagent-postgres-1 pg_dump -U cloudagent --data-only --column-inserts \
      -t agent_runner_hosts -t agent_repo_grants -t agent_gitlab_connections \
      -t agent_mcp_connections -t agent_identity_namespace \
      cloudagent > cloudagent-state-$(date +%F).sql
    docker exec cloudagent-postgres-1 psql -U cloudagent -d cloudagent -Atc \
      "SELECT format('UPDATE agent_runner_policy SET active_host = %L, runtime_versions = %L::jsonb;', \
                     active_host, runtime_versions) FROM agent_runner_policy" \
      > cloudagent-policy-$(date +%F).sql
    
  3. 动数据库之前,先构建目标版本的 migrate 镜像。迁移文件是在构建时复制进镜像的,而不是从代码目录挂载进去,所以如果用的是过时的镜像,就会在空数据库里重建出旧的迁移链:

    export AGENT_DEPLOYMENT_SHA="$(git rev-parse HEAD)"
    docker compose -p cloudagent --env-file .env -f deploy/control-plane/docker-compose.yml config --services
    docker compose -p cloudagent --env-file .env -f deploy/control-plane/docker-compose.yml build migrate
    
  4. 重建数据库,执行 baseline,然后检查它被标记成了哪个版本:

    docker exec cloudagent-postgres-1 psql -U cloudagent -d postgres \
      -c 'DROP DATABASE cloudagent' -c 'CREATE DATABASE cloudagent OWNER cloudagent'
    docker compose -p cloudagent --env-file .env -f deploy/control-plane/docker-compose.yml \
      run --rm --no-deps migrate
    docker exec cloudagent-postgres-1 psql -U cloudagent -d cloudagent -Atc \
      'SELECT version_num FROM alembic_version'
    

    版本必须是重写后迁移链的 head(0001_schema_baseline 或者它之后的某个修订);如果显示的是旧迁移链里的某个 00xx_…,说明第 3 步用错了镜像。

  5. 恢复要保留的状态,把那个描述着已经不存在的 attempt 的计数器清零,撤销这次重建加的排空,恢复放置策略,并读回身份绑定确认:

    docker exec -i cloudagent-postgres-1 psql -U cloudagent -d cloudagent -v ON_ERROR_STOP=1 < cloudagent-state-$(date +%F).sql
    docker exec cloudagent-postgres-1 psql -U cloudagent -d cloudagent -c 'UPDATE agent_runner_hosts SET slots_used = 0'
    docker exec -i cloudagent-postgres-1 psql -U cloudagent -d cloudagent -v ON_ERROR_STOP=1 < cloudagent-undrain-$(date +%F).sql
    docker exec -i cloudagent-postgres-1 psql -U cloudagent -d cloudagent -v ON_ERROR_STOP=1 < cloudagent-policy-$(date +%F).sql
    docker exec cloudagent-postgres-1 psql -U cloudagent -d cloudagent -Atc \
      'SELECT gateway_issuer FROM agent_identity_namespace'
    

    slots_used 统计的是主机上正在运行的 attempt,而这次重建一个都没保留;如果恢复成一个非零值,新任务一到 slots_total 就会全部停在 "queued",而 runner 的认证和心跳看起来却一切正常。

    排空也要做同样的处理,原因一样。draining 是写在记录上的,而心跳有意从不清除它,所以恢复后的主机能认证、能发心跳,却仍然什么都不认领;接着第 6 步的就绪检查会报 pinned host ... is not shared-live ready: ... status is draining 失败。重放第 1 步生成的那些语句,能准确地恢复原本在线的主机,原本就已排空或禁用的主机则保持不动。

    最后一条语句必须打印出旧数据库绑定的签发方。如果什么都没打印,说明旧数据库没有绑定:先在重建前核实原网关的签发方,然后用 python -m cloud_agent.identity_namespace --adopt-existing --issuer <original-issuer> 认定它。没做这项核实,就不要认定当前配置的签发方;也不要去掉这项检查,正是它防止重建悄悄地把一个网关的 user_id 交给另一个网关。

  6. 像平常一样部署目标 commit。migrate 发现数据库已经在 head,控制平面启动,runner 的下一次心跳会用恢复回来的 credential_hash 通过认证;接着就绪检查看到一次新鲜的心跳,部署完成。如果主机服务在此期间重启过,并因 agent_host_credential_rejected 退出了,就在第 5 步之后重启它,它手里的凭据又有效了。

  7. 宣布完成之前,端到端地验证一遍:创建一个任务,确认它被认领(SELECT state FROM agent_jobs 变成 running),然后依次做一次后续消息、一次取消、一次 fork、一次发布,再重启一次主机服务,之后往保留对话里再发一条后续消息。

如果主机注册信息恢复不了,退一步的办法是做一次受控的重新注册:删掉主机上的凭据文件(就是上面说的那个路径),从管理界面签发一个注册令牌,在一次启动时设置 AGENT_ENROLL_TOKEN,在启用保留对话之前,先确认第一次心跳已经到达。

对话历史是有意不带过去的;需要时,用一个临时的 postgres:16-alpine 读取取证转储,绝不把它导回去。

执行不向后兼容的迁移

deploy.sh 会先执行迁移,再替换 API。所以在替换完成之前,每个迁移都必须和正在运行的版本兼容。下面列出的这些转换,从较旧的 API 版本升级时,需要一个停机维护窗口。这份列表说的只是这几个 schema 转换,并不意味着之后每个增量迁移都要停机。选择滚动部署之前,请针对实际的源版本和目标版本核对兼容性。

  • 0002_request_keys_and_actors 去掉了 agent_admin_audit_log.actor_id 的默认值。在它之前的 API 插入审计记录时不带这一列,而 log_admin_action 按设计会吞掉这种失败,所以在这个窗口里,管理员的审计记录会悄悄丢失,而不是报错。

  • 0003_thread_level_config 删掉了 agent_jobs.repo、setup_script、execution_profile 和 mcp_servers,而旧 API 每次读取任务都会查这几列。

  • 0004_residency_on_membership 删掉了 agent_thread_residencies,而旧 API 每次 residency 心跳都会写它。

  • 0005_queued_follow_ups 把 waiting 状态的任务改成 queued,并从任务状态的约束里去掉了 waiting。新的调度器会让排队的轮次一直不被认领,直到同一个对话里所有更早的轮次都进入终态。旧 API 仍可能插入 waiting,并且不检查前面的轮次就认领排队的轮次,所以执行这次迁移之前必须先停掉它。降级同样需要停掉所有写入方;降级会把有依赖关系的排队轮次恢复成 waiting。如果某个已经进入终态的中间轮次,挡住了一个还没完成的更早轮次,降级会被拒绝,直到这些排队的后续消息被跑完或取消,因为旧的调度器保证不了它们的执行顺序。

  • 0008_generic_mcp_connections 清空旧的 agent_threads.mcp_servers 列并限制写入,同时把 agent_mcp_connections 改造成一种自描述的连接:传输方式、执行位置、端点或启动参数,以及认证配置,服务商名字则作为可选的 template 保留。旧的名字列表,在所有者对这个服务商恰好只有一个连接时,映射到 agent_mcp_thread_bindings;否则记到 agent_mcp_legacy_bindings 里,因为有歧义的映射,可能让之后新建的连接继承一个原本针对另一个连接授权的会话。它排在 0007_attempt_turn_resources 之后,保持迁移链是一条直线;紧跟着的 0009_mcp_refresh_lease 加了一个租约,防止多个 worker 同时刷新同一个凭据。0010_mcp_policy_revisions 加了权限修订号的分配器,这样保存或重新挂接一个连接时,就不会复用某次旧批准的策略版本。0011_mcp_rolling_compat 会在经过最初那个 0008(它删除或改名了这些列)升级的数据库上,把那几个不起作用的兼容列补回来。启动新版控制平面和 MCP 中继之前,先把迁移执行到 head。

  • 0058_mcp_connector_invariant 修复了 MCP 连接的 connector 路由必须唯一这一点,并给每个连接的授权映射加了唯一约束。请把这个修订当作一道"必须先停掉所有写入"的边界。旧版二进制可能会重新创建缺失的映射,在断开连接后留下过时的映射,或者在把连接从 user-connector 改成别的类型之后,仍然保留 connector 的权限。不要对这个修订做新旧版本混跑的滚动部署,而应该:

    1. 停掉所有控制平面、MCP 中继和 Web 层里写数据库的进程;

    2. 运行 alembic upgrade 0058_mcp_connector_invariant(或者经过 0058 一直升级到预期的更新修订);

    3. 部署能正确维护这一约束的控制平面、MCP 中继和 Web 层新版本;然后

    4. 等所有旧的二进制都替换完之后,再恢复写入。

runner 不持有任何数据库连接,它的心跳都是发给控制平面的 HTTP 请求,所以停掉控制平面、MCP 中继和 Web 层之后,数据库就没有任何写入方了。

  1. 排空主机,保留生成的 cloudagent-undrain-*.sql,然后等两个计数都降到零。这两样一旦停掉写入方就再也结束不了,所以和上面重建流程的第 1 步一样,要在这里先等它们结束:

    docker exec cloudagent-postgres-1 psql -U cloudagent -d cloudagent -Atc \
      "SELECT count(*) FROM agent_attempts WHERE status = 'running'; \
       SELECT count(*) FROM agent_session_forks WHERE state IN ('preparing', 'materialized')"
    

    还处于 preparing 或 materialized 的 fork,是协调器还没处理完的 fork。0004 不会在它上面执行,而不是冒险给一个 reaper 可能还会完成的 fork 打上删除标记;而 reaper 跑在 API 里面,所以必须在停掉任何东西之前,先让它处理完。停滞太久的 preparing 操作,会在停滞窗口(默认 15 分钟)之后判为失败。

  2. 停掉写入方:

    docker stop cloudagent-control-plane-1 cloudagent-mcp-relay-1 cloudagent-web-1
    
  3. 在没有任何写入的情况下,构建并运行目标版本的 migrate 镜像。和上面重建流程的第 3 步一样,在已检出目标 commit 的部署根目录里运行,并且先构建镜像,再动数据库:

    export AGENT_DEPLOYMENT_SHA="$(git rev-parse HEAD)"
    docker compose -p cloudagent --env-file .env -f deploy/control-plane/docker-compose.yml build migrate
    docker compose -p cloudagent --env-file .env -f deploy/control-plane/docker-compose.yml \
      run --rm --no-deps migrate
    
  4. 撤销第 1 步记下的那些主机的排空,然后像平常一样部署目标 commit。它的迁移什么都不用做,就绪检查这时也能看到一台允许认领的主机了:

    docker exec -i cloudagent-postgres-1 psql -U cloudagent -d cloudagent -v ON_ERROR_STOP=1 < cloudagent-undrain-$(date +%F).sql
    deploy/control-plane/deploy.sh "${AGENT_DEPLOYMENT_SHA}"
    
  5. 按上面重建流程的第 7 步做一遍验收检查。

主机上的保留 Docker 存储

保留的对话状态,就是 Docker 容器的可写层。如果守护进程用的是 containerd 镜像存储,镜像就放在旁边的 containerd 里。要在同一台主机上持久保存,就把这两个存储都放到一块挂载好的数据盘上,守护进程的路径保持默认。docker info 必须一直显示 DockerRootDir=/var/lib/docker,AGENT_RETAINED_DOCKER_DATA_ROOT 也保持 /var/lib/docker。自定义的 data-root、graph 或 containerd 的 root,在主机启动之前就会被拒绝。

自动 SSH 初始化会根据服务器的沙箱存储路径完成这些设置,见沙箱存储路径。如果一个非空的存储还不是这种绑定方式,它会拒绝去搬动它。

手动安装的主机也用同样的布局。<disk> 是一个已有的可写挂载点,例如 /scratch/hybridInference-cloud-agent/sandbox:

<disk>/docker      bind -> /var/lib/docker
<disk>/containerd  bind -> /var/lib/containerd

把这些绑定写进 /etc/fstab,开机后自动生效:

<disk>/docker /var/lib/docker none bind,nofail,x-systemd.requires-mounts-for=<disk> 0 0
<disk>/containerd /var/lib/containerd none bind,nofail,x-systemd.requires-mounts-for=<disk> 0 0

给每个守护进程加一个 drop-in,分别是 /etc/systemd/system/docker.service.d/cloud-agent-storage.conf 和 /etc/systemd/system/containerd.service.d/cloud-agent-storage.conf:

[Unit]
RequiresMountsFor=<disk> /var/lib/docker

containerd 那个文件里写的是 /var/lib/containerd,而不是 /var/lib/docker。然后运行 systemctl daemon-reload。这样一来,如果磁盘没挂上,对应的守护进程就会保持停止,而不是在原来的空路径上打开一个空存储。

要搬一个已经有数据的存储:先在守护进程运行时复制一遍;然后停掉 Docker 和 containerd,再带 --delete 复制一遍,直到这次复制以 0 退出。把旧目录挪到一边,建好空的挂载点,做绑定挂载,再启动守护进程。不要把 daemon.json 指向新路径。设置了 restart=no 的容器,这次重启后会一直停着,需要手动启动。挪到一边的旧副本,要等机器重启一次、这些绑定都正常恢复、主机也已经在正常服务之后,再删掉。

sandbox manager 会拿 AGENT_RETAINED_DOCKER_DATA_ROOT 和守护进程报告的 DockerRootDir 对比,不一致就拒绝。它有意没法自己用 findmnt 去确认挂载:如果仅仅为了检查存储,就给 sandbox manager 挂一个主机根目录,就破坏了"只通过 Docker 操作"的边界。挂载检查应该在主机开通时完成,也就是上面那些 RequiresMountsFor= drop-in。

不要把 <disk>、/var/lib/docker、/var/lib/containerd、/workspace 或 /agent-state 绑定进 sandbox manager 或沙箱。不要对带 org.freeinference.chat-sandbox=true 标签的对象使用通用的 docker system prune、按容器 TTL 的清理、docker rm,或者镜像垃圾回收。查看和操作它们,要通过 Docker(docker inspect、docker exec、docker cp,以及 sandbox manager 的 RPC),而不是直接翻 overlay 目录。只能在 Docker 和 containerd 都停掉的情况下,备份或搬动这两个存储,并且要保留属主、硬链接、扩展属性和稀疏文件。

只要同样的绑定能恢复,这样 docker stop/docker start 就能扛过 runner、Sandbox Manager、Docker 守护进程和主机的重启。别的主机要接手一个沙箱组,只能靠它的可恢复检查点,并且只能在这个组被交出去、而最新的检查点仍然覆盖它的时候:它的主机被移出资源池、它的节点被确认已回收,或者主机在租期结束前迁出。没有检查点的话,这两个存储一旦丢失,上下文就是 LOST;不过控制平面的数据库仍然记着找到它所需的、权威的"对话→主机→容器"对应关系。

错过一次心跳、主机记录显示离线或禁用、sandbox manager 暂时连不上,这些都不能证明这份 Docker 状态已经没了。让对话继续固定在原主机上并显示为不可用,恢复或重新注册同一台具名主机,然后调和它那个确切的、带标签的容器。只有在所属的那个 Docker 守护进程上检查,证实预期的容器或数据目录确实没了,才可以把上下文改为 LOST;绝不要在另一台主机上建一个空白的替代品。

沙箱存储用了多少?

Servers 表格里,CPU 和 Memory 旁边有一列 Storage:存放保留沙箱可写层的那个文件系统,按 df 的口径已经用了多少。这是沙箱数量回答不了的问题,25 stopped 只说明保留着 25 个容器,没说它们占了多少磁盘。而且它观察的是整个文件系统,不是单个对话:仓库、依赖、没提交的改动和原生 CLI 会话,都在容器的可写层里,停掉容器也不会释放这些空间。

Storage reserve for new sandboxes(默认 1024 MiB)用的也是这个读数:低于这个值时,runner 不接需要新建沙箱的轮次,并把它原样退回队列。腾出 Docker 存储,或者调低预留值,就能恢复新的分配。这道保护和它的局限,见主机运行时设置。

这个读数测的是什么

runner 也是同一个 Docker 守护进程、同一个存储驱动下的普通容器,和沙箱一样,所以按 OverlayFS 的 statfs 语义,它内部的 os.statvfs("/") 返回的就是上层所在文件系统的信息,也就是可写层所在的那块磁盘。不需要挂载主机目录,不需要查询 Docker,也不需要起子进程。数字都按 df 的口径:used 是 total - free,available 是非特权写入方还能用的空间,百分比按 used + available 计算。文件系统为 root 保留、目前仍空闲的块,两个数字里都不算,所以两者加起来不等于总量,total - available 也不等于已用空间。

证实存储拓扑

Docker 的 overlay2.size(以及在带 pquota 的 XFS 上,容器的 --storage-opt size=)是用项目配额实现的,而对项目内的目录,statfs 返回的就是配额。所以一个读自己配额的容器,和一台执行 df <upperdir> 的主机,报告的都是配额,比如 10 GiB,设备号也相同,拿这两者比较什么都证明不了。因此 sandbox_host.sh 会拿容器的数字,去和包含上层目录的那个文件系统的挂载根的 df 结果比较,因为挂载根不在任何容器的项目配额之内,然后把结论写进 .shared-live-ready 旁边的 .storage-probe/verdict.json:

  • 挂载根的大小,必须和容器自己读到的 total_bytes 完全相等。size 不会随写入变化,所以这一条就是拓扑测试;

  • df <upperdir> 和 df <mount_root> 的大小必须一致,并且 daemon.json 和 dockerd 的命令行里都不能出现 overlay2.size;任何一条对不上,都会记为 unsupported(项目配额);

  • used 和 available 只在 max(1 GiB, 1% of size) 的误差范围内比较,而且只用于诊断,因为构建和清理在交错进行,不会有谁为了监控让一切停下来。

非 root 的部署用户,可能没法进入 Docker 和 containerd 那些 root 所有的快照目录。直接查挂载失败时,部署会用正在运行的副本那个不可变的镜像 ID,起一个临时的元数据辅助容器。Docker 会解析两个非递归、只读的目录绑定:候选的挂载根,以及这个副本的上层目录。辅助程序在读取数据之前,会对照主机挂载表的预期,检查实际绑定的设备、文件系统根和类型。靠猜出来的路径,或者对不上的挂载,永远不会被判定为已核实。

这个辅助容器没有网络、没有 capability、不共享主机的 PID 命名空间,也没有 Docker socket 和凭据。它的根文件系统是只读的,只读取自己的挂载元数据,并对两个绑定进来的目录调用 fstatvfs。它共用证实流程剩下的时间预算,自己还有一个最多五秒的超时,退出时就被删除。启动失败的话,会在剩余的时间预算内清理掉。这是只在部署时才有的挂载例外;采集器和任务容器都拿不到这些主机视图。不需要任何 sudo 规则,也不需要改主机目录的权限。

每个 runner 每轮采样都会读这个文件,并且只为自己的、大小对得上的那个上层目录发布数字。其他任何情况,比如没有文件、没有对应的条目、条目写着 unsupported、文件系统的大小变了,都会报告为 unverified 或 unsupported,页面上会如实显示,而不是画出一个数字。重建的容器会有新的上层目录,所以旧的证实结果自然就不适用了;没有按时间过期的机制。

刷新或关闭这个指标

Compose 只把专用的 .storage-probe/ 目录以只读方式绑定到 /run/cloud-agent/storage-probe。每次采样都按文件名打开 verdict.json,所以删掉它,下一次采样就会报告 unverified;sandbox_host.sh check 会重新生成它,采样随即恢复,不需要重启副本。被绑定的目录本身要保留。目录里的文件是先写一个新文件、再重命名覆盖,从不会先清空,所以刷新期间正在采样的副本,读到的要么是旧内容,要么是新内容,不会读到空文件。如果是从旧的 .storage-probe-verdict 单文件绑定升级上来,执行一次普通的 up 即可,它会用新的挂载方式重建 runner;光执行 check 改变不了已有容器的挂载配置。旧文件会被忽略,也不会作为备用。

用到辅助容器时,主机上需要 Docker 25 或更新版本。它以非递归方式绑定那两个目录,在 --mount 里写作 bind-recursive=disabled,这个写法由 CLI 自己解析,是在 25 版加入的。旧的写法不能拿来兜底:当前的 CLI 会直接拒绝它,29.2.1 遇到 bind-nonrecursive=true 时会提示新写法,然后以 125 退出;所以为了兼容老主机去用旧写法,结果是所有不老的主机都用不了。Docker 低于 25 时,辅助容器的调用会失败,主机报告 unverified,警告里会引用 CLI 自己的报错。部署用户本来就能进入存储根目录的主机,根本不会走这条路。

存储验证绝不会阻止部署。 一个指标测不出来,不能成为把一台正常工作的主机挡在资源池外的理由:上面每一种失败,都只是一条警告和一条判定记录,up 会继续执行。由此在运维上有两点:

# Re-run the checks without redeploying — this also refreshes the verdict.
deploy/host/sandbox_host.sh check

# Turn the metric off entirely (the rollback). The `.env` is the deploy
# checkout root's, beside `.deployed-sha`, which is where every reader looks.
echo 'AGENT_HOST_STORAGE_PROBE=off' >> .env
deploy/host/sandbox_host.sh up

这里的 up 不能省。容器的环境变量在创建时就固定了,所以在改设置之前启动的副本,在被替换之前会一直测量;up 会替换它们。在两者之间执行一次 check 仍然有用:它会把判定结果重写为空,这样那些副本虽然还在测量,下一次采样时也不会再发布数字了。这个变量只接受 overlay_root 或 off,没有别的:其他任何写法在配置阶段就会被拒绝,以免脚本和 runner 对它的理解不一致。

测量的局限

这个读数测的是块空间,所以 available 并不保证某个容器还能写进去:inode 用完、配额满了、被重新挂载成只读,照样会写失败。它包括这个文件系统上的所有其他写入方,所以既不是 Docker 对象的总和,也没法预测还能再放下多少个沙箱。在默认的主机部署里,如果挂载根本身就在一个限制了大小的项目配额里,上线后也会停止报告,而这是 df 分辨不出来的;配置检查只能尽力而为。rootless、远程 Docker、btrfs/ZFS 和 devicemapper 的主机都没有验证过:探针会报告 unsupported,而不是去猜。在 Docker Desktop 上,主机的 shell 看不到虚拟机里的挂载,所以本地开发时会显示 unverified。

任务在哪台机器上跑

Admin → Servers。 列表里是每一个发送着带认证心跳的已注册执行环境,按它们上报的物理服务器分组;New chats 选择器决定,还没有沙箱的对话可以在哪里创建。默认选项是 Automatic,意思是谁先轮询到,新对话就归谁。一旦有了保留的沙箱组,它的轮次就只能由这个组所属的环境认领。

runner 是主动来拉任务的。放置规则是在 runner 认领任务时执行的,而不是由控制平面派发过去。规则如下:

  • 切换不会抢占,也不是排空。 新主机会开始认领还没有沙箱的对话;已经在旧主机上运行的任务会一直跑到结束,并持有各自的租约。已有的保留对话会被严格固定在持有它 Docker 可写层的那台主机上,所以之后的后续消息仍然可以在那里被认领。沙箱组只会通过覆盖它的检查点迁移,而且只在它的主机被移除、被回收,或者在租期结束前迁出之后(见恢复被移除主机上的沙箱)。所以切换之后,两台主机可能会同时干活;排空的意思是先等旧主机清空,而这里不会这么做。

  • 没注册的 runner 认领不了任务。 控制平面根据保存的主机凭据确定是哪台主机;请求体里写个名字,冒充不了被固定的那台机器。

  • 机器通过一次性注册加入。 AGENT_ENROLL_TOKEN 绑定到选定的主机名,只能兑换一次。之后各个副本共用保存下来的主机凭据,心跳也和认领请求互不相干。

  • 存储不会故障转移。 如果选中的主机停了,新对话会一直等,而不是换个地方创建。已有的保留对话也一样:它们自己的主机虽然已注册、但不可用时,它们会等着;改选择也搬不走它们的 Docker 层。选中的主机最近没有轮询时,管理页面会给出警告;但真正权威的运维信号,是它已注册的心跳和 sandbox manager 探测,而不是最近有没有哪个 runner 碰巧认领过任务。

加第二台机器,不只是拨一下这个开关。配置的模型端点和 MCP 端点,走的是沙箱组网桥上的两个中继,其他私有地址都被主机规则挡住了。不在网关那台主机上的 runner 机器,需要下面要讲的两条隧道,以及上面 §0 里的全部内容。网关公开的推理地址也可能访问得到;泄露的授权在那里被重放,是一项已接受的风险。预检会在启动时就拒绝,而不是等到任务第一次调用模型或 MCP 时才失败;但它是在那台机器上拒绝,不是在这里。

在另一台机器上运行 runner

加第二台机器,可能是为了扩容,也可能因为硬件就在那台机器上。它之所以不是"在那边跑一下脚本"这么简单,是因为沙箱要能访问到平台。沙箱的组网桥可以访问公网,但只限 TCP 80/443;主机规则会拒绝私有地址,只放行这个组固定的那几个中继。配置的网关端点和 MCP 端点用的就是这些中继:它们是 SSH 隧道,生命周期代码在中继网络上找到它们,再接到每个组的网桥上。另外还有一个单独的控制平面中继,只有 runner 能看到:

runner host                                      service host(s)
┌──────────────────────────────────────┐
│ [sandbox, its own group bridge]      │
│   ├→ gateway-tunnel ─────────────────┼──ssh──→ inference :8080
│   ├→ mcp-tunnel ─────────────────────┼──ssh──→ MCP-only :8001
│   └→ host rules and NAT ─────────────┼────────→ public IPv4, TCP 80/443 only
│                                      │
│ [runner, internal network]           │
│   └→ control-plane-tunnel ───────────┼──ssh──→ control API :8000
└──────────────────────────────────────┘

每条隧道都是中继,而不是代理:没有 CONNECT,也不能由调用方选择目标,所以沙箱发出的任何东西,都没法把任何一条路由扩大。setup 和 agent 共用这个组的网桥,所以 setup 访问软件包仓库时,走的也是和其他所有流量一样的公网 TCP 80/443。控制平面隧道从不接入任何组网桥,它在 runner 网络上的地址是一个私有地址,主机规则对所有沙箱组都会拒绝。

为什么用 ssh,而不是直接把网关的端口暴露出来。 沙箱里有限定范围的推理授权,runner 里有已注册主机的凭据。把任何一个服务绑到实验室网络上,都会让这些凭据以明文出现在一个共享网段上。用隧道的话,每个服务都只需要监听回环地址。

deploy/host/sandbox_host.sh up       # relays, sandbox manager and the runner
deploy/host/sandbox_host.sh check    # probe relays and runner/Sandbox Manager paths
deploy/host/sandbox_host.sh status
deploy/host/sandbox_host.sh down

deploy/host/docker-compose.sandbox.yml 里包括 runner、sandbox manager、三条按用途限定的隧道,以及两个网络:这套服务自己的网络,和不带路由的中继网络。runner 和 sandbox manager 通过 Docker 创建组网桥。这是官方支持的独立部署拓扑。它不会启动网关的 backend;源仓库以前那个 overlay,可以通过 depends_on: backend 声明做到这一点。

变量

说明

AGENT_TUNNEL_*

推理中继的目标地址、密钥、known-hosts 文件和端口;远端默认 127.0.0.1:8080

AGENT_MCP_TUNNEL_*

MCP 专用中继单独的目标地址和 SSH 凭据材料;远端默认 127.0.0.1:8001。不要把它指向 8000 端口上完整的控制 API

AGENT_CONTROL_PLANE_TUNNEL_*

runner 专用的控制 API 中继;远端默认 127.0.0.1:8000。沙箱网络里有意不放它

AGENT_*_TUNNEL_SSH_KEY

私钥,以只读方式挂载。每个中继都有自己的密钥,并在远端机器上有自己的账号,按下面的说明加以限制;任何中继都不需要 shell,也不需要打开另一个中继端口的权限

AGENT_*_TUNNEL_SSH_KNOWN_HOSTS

每个中继都必须有的主机密钥文件。主机校验没法关闭:一个没核实过的跳板,会把限定范围的令牌和隧道流量,交给在那里应答的任何东西

AGENT_GATEWAY_HOSTNAME / AGENT_MCP_HOSTNAME

沙箱在组网桥上解析的两个名字,默认是 agent-gateway 和 agent-mcp。不要换成服务的地址:用 IP 指定的中继会在启动时被拒绝

AGENT_CONTROL_PLANE_HOSTNAME

runner 专用的控制中继名称,默认 agent-control-plane

生成 known-hosts 条目,并把它的密钥指纹,和这台服务主机的一份可信记录对比一下:

ssh-keyscan -p 22 "<gateway-host>" > /etc/agent-tunnel/known_hosts

在远端机器上授权隧道

优先使用 Admin → Servers → Server setup → Automatic setup(见计算资源手册)。它会创建下面这些账号,写入并读回它们的 sshd 策略;如果已有的策略会把权限放宽,它会拒绝。只有在用不了它的时候,才手动授权隧道,并且要和它生成的配置一模一样。

给每个中继一个专用的、没有 shell 的账号:推理账号在网关机器上,MCP 账号和控制账号在控制平面机器上。每个账号的 authorized_keys 里只有一行:

# gateway machine, inference account
command="",restrict,port-forwarding,permitopen="127.0.0.1:8080" ssh-ed25519 AAAA... inference
# control-plane machine, MCP account
command="",restrict,port-forwarding,permitopen="127.0.0.1:8001" ssh-ed25519 AAAA... mcp
# control-plane machine, control account
command="",restrict,port-forwarding,permitopen="127.0.0.1:8000",permitlisten="172.24.0.1:18092" ssh-ed25519 AAAA... control

在 restrict 之后,port-forwarding 会把远程转发(-R)和本地转发都重新打开,而 permitopen 只限制本地转发的目标。单看这几项,任何一把密钥都能在远端机器上开监听端口。所以控制密钥用 permitlisten 写明它唯一的那个反向监听地址,也就是控制平面机器的 Docker 网桥网关,加上本主机的 AGENT_SANDBOX_MANAGER_REVERSE_PORT;并且每个账号都要配上 deploy/host/sshd-tunnels.conf.example 里对应的 Match User 块。推理和 MCP 的块设置 AllowTcpForwarding local 和 PermitListen none;控制账号的块用 GatewayPorts clientspecified 只允许那一个 PermitListen。这些块要放在其他任何可能匹配到这些账号的 Match 块之前;然后运行 sshd -t,并在重新加载 sshd 之前,用 sshd -T -C user=<account>,host=localhost,addr=127.0.0.1 逐个确认每个账号的最终配置。

在主机的代码目录里运行 sandbox_host.sh check。中继网络上的沙箱运行时容器,必须从推理的 /health 和 MCP 的 /healthz 收到 HTTP 200。正在运行的 runner 必须通过完整的沙箱预检,包括读回主机的防火墙表,并且能访问 sandbox manager 和控制平面。从主机 shell 直接发请求,测不到这些容器里的路径。

跑起来之后,这台机器就会出现在 Admin → Servers 里,可以在那里选它。

回程:控制平面如何访问这台主机的 sandbox manager

控制平面还需要一条能回到这台主机 sandbox manager 的路。

文件列表、终端流量和 git status,方向都是控制平面 → 工作区所在的 sandbox manager。sandbox manager 跑在持有这个工作区的 Docker 守护进程旁边;主机记录里存着它注册时上报的地址。一体化的本地环境共用一个网络,所以在那里 http://sandbox-manager:8092 能用。但官方支持的独立主机服务,不和控制平面那套服务共用网络,哪怕两个项目跑在同一台物理机上也一样,所以那个服务名在哪里都解析不出来。sandbox manager 的地址访问不到,就会返回 503,正常执行期间的 terminals/suspend 也一样。历史上第一次部署,就在这一步连续失败了三次。开始接任务之前,先把这条路验证一遍。

这条路没法从另一头打通:控制平面所在的机器通常根本访问不到这台机器。所以它借用的是这台机器已经建立的那条连接,也就是在控制平面隧道上做一个反向转发:

  runner host                                control plane host
┌──────────────────────────┐              ┌──────────────────────────┐
│ sandbox-manager         │◀─── -R ──────│ 172.24.0.1:18092         │
│        :8092             │              │  (docker bridge gateway) │
│                          │              │        ▲                 │
│ control-plane-tunnel ────┼──── ssh ────▶│        │ control-plane   │
└──────────────────────────┘              └────────┴─────────────────┘

变量

AGENT_SANDBOX_MANAGER_REVERSE_BIND

控制平面主机的 docker 网桥网关。不能用 127.0.0.1,它的容器看不到这个地址;也不能用 0.0.0.0,那会把 sandbox manager 暴露给那台机器所在的整个网络,而保护它的只有一个共用的令牌。可以用 docker network inspect <net> --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}' 查到它

AGENT_SANDBOX_MANAGER_REVERSE_PORT

那台主机上一个空闲的端口

AGENT_SANDBOX_MANAGER_ADVERTISE_URL

http://<bind>:<port>,也就是 runner 上报、控制平面会去连接的地址

控制平面主机上的 sshd,必须对控制账号启用 GatewayPorts clientspecified(deploy/host/sshd-tunnels.conf.example 里这个账号的块就是这么写的),同时这个账号的 PermitListen 和这把密钥的 permitlisten,都要写明这个确切的地址和端口。绝不要设成 yes:那样不管客户端要求什么,所有远程转发都会绑定到通配地址上,而且还报告成功,结果每台主机的 sandbox manager 都暴露在那台机器的所有网卡上,只靠一个共用令牌保护。如果用默认的 no,sshd 会把转发绑定到回环地址上,控制平面的容器就访问不到。sshd 直接拒绝的监听(端口被占用,或者 PermitListen 不允许的端口),会让 ssh 在 ExitOnForwardFailure 下退出,容器随之重启;但上面那两个错误的 GatewayPorts 值都不会触发这种情况。在那台机器上用 ss -ltn 确认一下:这个端口只在网桥地址上监听,别的地方都没有。

还要在那台机器的防火墙上,把这些反向端口限制在它的 Docker 网桥接口上。光靠绑定地址是不够的:Linux 会在任何网卡上接受发往任何本机地址的数据包,所以同网段的邻居,如果把网桥地址的路由指向这台机器的局域网网卡,照样能连到这个监听端口。用 ip -o -4 addr show to <bind> 找到对应的网卡,然后针对每个反向端口(或者你分配的端口范围)加一条规则,例如:

iptables -I INPUT -p tcp --dport 18092 ! -i br-0123456789ab -j DROP

按那台机器保存防火墙配置的方式,把这条规则持久化。

这三项都要设置。官方支持的独立主机服务,从不对外发布它的 sandbox manager,也不和控制平面共用 Compose 网络,即使两套服务碰巧跑在同一台物理机上。如果声明了地址,背后却没有隧道,那就是一个没有任何东西应答的地址,结果还是 503,只是排查起来更费劲。

在控制平面容器里面检查:

docker exec "<control-plane-container>" \
  python -c "import urllib.request; print(urllib.request.urlopen('http://<bind>:<port>/health', timeout=5).status)"

HTTP 401 恰恰说明这条路是通的。 urllib 会把它报成 HTTPError,因为 sandbox manager 拒绝了这个没带认证的请求。如果是 DNS 错误或者超时,才说明路不通。

这个拓扑里,三件看起来是细节、其实很要紧的事

runner 不是网关镜像。 它用 Dockerfile.agent-runner 构建,里面加了 Docker 客户端,用来创建和检查保留的对话容器;还加了 git,用来准备一份清除过凭据的源码归档。网关镜像这两样都没有,用它构建的 runner 在任何主机上都过不了预检。

没有主机目录到工作区的映射。 第一轮时,受信任的 runner 只临时用一个目录,把请求的 commit 拉下来,去掉所有仓库凭据,在 tar 流里统一文件属主,然后把这个流导入保留容器里的 /workspace。之后临时的克隆目录就删掉了。之后所有的编辑、服务商状态、终端、setup 的结果,以及 Fork 出来的兄弟对话,都留在同一个 Docker 可写层里。

Docker socket 就代表了整台主机的权限。 runner 和 sandbox manager 能在对话容器里创建、启动、停止、检查和执行命令,所以把这两个服务和用户隔离开,比改它们的 Unix uid 重要得多。安全边界是保留沙箱本身(不受信任的多租户任务用 Kata;只有受信任的仓库才用普通 Docker),而不是主机目录的绑定挂载,也不是每一轮再起一个容器。

沙箱网络

对话可以带一个 setup_script。它在第一个 Agent CLI 轮次之前、在保留容器里运行,用的网络就是之后 agent 用的那个。它生成的文件留在这个容器的 Docker 可写层里,后续消息、空闲后的停止和启动、主机服务重启,都不会丢;没有主机侧的 setup 缓存,也没有可以挪到别处的 setup 归档。包管理器直接通过 HTTPS 访问公开的软件仓库,不需要配置任何代理;需要其他端口的访问,比如通过 SSH 用 Git,都会被拒绝。唯一的例外,是所有者自己的、检查过的 SSH 服务器,每一台都只有启用了它的对话才能访问(SSH 服务器)。长期有效的平台凭据和上游凭据都留在沙箱之外;限定到成员的推理令牌和 MCP 令牌,会进入各自的 CLI 进程。沙箱以非特权身份运行,所以 apt install 用不了,请用 pip --user、virtualenv 或 npm ci。

新对话还可以通过 + → Plugins,或者创建请求里的 plugins 字段,选最多八个运行时插件。这是对话级别的配置:后续消息沿用这些选择,Fork 时会复制给新成员。Claude Code 和 Codex 用的条目形如 {"plugin": "review@example", "marketplace": "example/plugins"};marketplace 的来源可以是一个 GitHub 仓库,或者一个不带凭据的公开 HTTPS 来源(对 Codex 来说是一个 Git 仓库)。pi、OpenCode 和 Kilo 用的是 npm 包条目,形如 {"plugin": "@example/plugin@1.2.3"},没有 marketplace。npm 的确切版本号可以不写;版本范围、标签、本地路径、任意安装命令,以及来源地址里带凭据,都会被拒绝。

插件在成员的运行时初始化阶段安装,也就是常驻 CLI 开始接收提示词之前,用的是固定的运行时版本,以及这个成员私有的主目录和配置。安装成功后,进程和容器重启都会保留;安装失败可以重试,并且绝不会把这个选择标记为已完成。出错或超时会让启动失败,并给出一段长度有限的诊断信息。插件使用这个组现有的网络和非特权的成员身份。在运行时支持这些保护的地方,项目配置和插件的自动发现仍然保持关闭;明确选择的插件,通过运行时原生的机制加载,和平台的 MCP 配置并存。

只有一个网络,每个任务、每个阶段都一样,也没有任何开关去选它(设计)。每个沙箱组(setup、agent 和每个 Fork 出来的兄弟对话都在内)都有一个可路由的 Docker 网桥,从 AGENT_EGRESS_CHAT_NETWORK_POOL 里分一个 /29。它在主机上的接口名,用的是这个部署的前缀:ca 加上由 AGENT_SANDBOX_NAMESPACE 算出的五位十六进制数,再加上由这个组算出的八位。网桥的路由和地址伪装由 Docker 负责;它能访问什么,由这个部署在主机上的 nftables 表决定。

放行什么,拒绝什么

放行:

  • TCP 80 和 443 上的公网 IPv4,比如 curl、Python 的 HTTP 客户端、pip、npm、通过 HTTPS 的 Git;

  • AGENT_EGRESS_DNS_SERVER 指定的那一个解析器,TCP 和 UDP 53 端口。它在受保护地址段之前检查,所以即使解析器在私有地址上,或者就在本机上,也能用,但只限 53 端口;

  • 这个组自己的两个中继,按确切的网桥、地址和端口匹配:推理走网关隧道,工具走 MCP 隧道;

  • 这个组的所有者登记、检查过、并在其某个对话上启用的每一台 SSH 服务器,按确切的公网 IPv4 地址和端口、确切的网桥匹配,放在 ssh_targets 集合里。它在受保护地址段之后才检查,所以不管集合里写了什么,受保护的地址照样拒绝(SSH 服务器)。

拒绝:

  • 本主机的每一个地址,包括网桥自己的网关;唯一的例外是上面那个解析器正好跑在本机上的情况;

  • 所有不是普通公网单播的 IPv4 地址,即 IANA 规定的特殊用途地址块 0.0.0.0/8、10.0.0.0/8、100.64.0.0/10、127.0.0.0/8、169.254.0.0/16(云元数据服务就在其中)、172.16.0.0/12、192.0.0.0/24、192.0.2.0/24、192.88.99.0/24、192.168.0.0/16、198.18.0.0/15、198.51.100.0/24、203.0.113.0/24、224.0.0.0/4 和 240.0.0.0/4;还有 Azure 的 WireServer 168.63.129.16,这是一个放在公网地址上的平台服务;以及 AGENT_EGRESS_PROTECTED_CIDRS 额外加上的地址。这些地址的任何端口都拒绝;

  • 其他沙箱组,不管是本部署的还是别的部署的;

  • 其他所有端口和协议:UDP 443(QUIC)、ping,以及所有 IPv6;

  • 从外部主动发起的连接。只有这个组自己发起的连接,回包才会被接受;而且即使是这样,来自受保护地址的回包也会被丢弃。没有那种笼统的"已建立连接一律放行"。

规则看的是数据包实际发往的地址,所以一个域名如果解析到(或者被重新绑定到)私有地址,会和那个地址本身一样被拒绝。大多数拒绝会立即回应一个 TCP reset,或者 ICMP 的 "administratively prohibited",所以被挡住的请求会马上失败,而不是一直等到超时;IPv6 则直接静默丢弃。

它不保证什么(设计 §2)。TCP 80/443 只是一条端口规则:没有任何东西检查里面传的是什么,所以拦不住 DNS over HTTPS,也拦不住在这些端口上搭的隧道。也没有任何东西能阻止沙箱把仓库内容,或者它自己的短期令牌,发到某个公网主机上。平台自己的公网地址也可能访问得到。MCP 工具调用仍然不在公开的主 API 上;推理授权被人在公网上重放,按下文所说,作为一项风险被接受。

MCP 的前提条件,以及已接受的推理授权风险

设计 §3 记录了 2026-09-25 的决定:对面向真实用户的部署,接受推理授权泄露并被重放的风险,不再把单独的内部推理入口作为发布的前提条件。

MCP 隔离仍然是必须的。 已经在 #327 里完成:主 API 不再挂载工具调用的路由,所以 MCP 隧道必须指向 8001(见 MCP 服务器)。

推理沿用现有的路径。 CLI 持有它所在成员的授权,推理隧道继续指向现在的网关目标(默认 8080)。这个决定不需要单独的网关监听器,也不需要在主机上做凭据替换的中继。任务碰到的代码或指令,可能会让它的授权被带出沙箱,再通过一个能访问到的网关 API 被重放。这些调用会消耗所有者的配额和平台的推理资源,但仍受所有者的模型权限、授权过期和撤销的约束;它们拿不到其他用户的身份、MCP 工具或平台管理权限。

常驻进程的授权,只要进程还活着就会续期,两轮之间也一样。所以较短的 TTL 和默认 30 分钟的空闲停止,并不是从泄露那一刻算起的绝对上限。现有的停止、替换、接管和释放路径仍然会撤销授权;撤销失败时,靠授权过期兜底。

v4 部署脚本的更新和旧网络的清理仍然要做。这个决定并不免除 Kata 和网络的验收、成员隔离、长期凭据的限制、账号配额检查,以及其他任何发布关卡。

主机上的防火墙表

这些规则是本部署的两张 nftables 表:一张 inet 表,管被路由的流量,比如去往互联网、本主机或另一个网桥的流量;一张 bridge 表,管同一个网桥上沙箱和它的中继之间的帧。两张表都命名为 cloud_agent_ca,后面接 AGENT_SANDBOX_NAMESPACE 的 SHA-256 的前五位十六进制数;在主机服务上,这个命名空间就是 Compose 项目名,所以设好 COMPOSE_PROJECT 之后:

digits="$(printf '%s' "$COMPOSE_PROJECT" | sha256sum | cut -c1-5)"
table="cloud_agent_ca$digits"

runner 和 sandbox manager 都没有 NET_ADMIN,也不在主机的网络命名空间里。它们通过一个临时的辅助容器修改主机规则:用的是它们自己的镜像,运行在主机的网络命名空间里,只有 NET_ADMIN 这一项 capability,根文件系统只读;下面的 host_nft 函数也是这个形态。每一步防护操作就是一次辅助容器调用:里面一小段 Python,要么把安装和读回确认的 nft 命令一起执行,要么读取、比较并写入某一个网桥的中继放行规则,因为启动辅助容器的开销,远比它执行的那几条 nft 命令大。不管守护进程的默认运行时是什么,它都在 runc 下运行,因为如果在 Kata 虚拟机里跑,改的是虚拟机自己内核的规则,而不是主机的。

  • 在任何东西接入网桥之前就装好。 runner 和 sandbox manager 启动时的预检,会安装这些表并读回来确认:每条链、它的钩子和规则数量、受保护的地址集合、登记的地址池。任何一处不一致,runner 就停止认领,sandbox manager 就无法启动。组网络相关的操作会再检查一次,检查通过的结果在 60 秒内都算数。不管有没有沙箱组在运行,sandbox manager 的巡检都会在 45 秒后续一次这个结果,所以启动或唤醒时,从不需要自己去等安装和读回。

  • 只作用于本部署。 这些表通过网桥接口名的前缀来识别流量。它们绝不会清空或修改别的部署的规则,所以多个部署可以共用一台主机。

  • 中继按确切的端点放行。 一个组的中继接上之后,它们的网桥、地址和端口会写进两张表里的 relays 集合,网桥删除时一起删掉。每次激活和每条工作区命令,都会重新同步这个网桥的放行规则;如果针对同一个 Docker 网络,防护已经做过这次更新,并且在最近 10 秒内(两次巡检)的读回中确认仍然有效,就不会再做一遍。重新安装、检查失败,或者读回时看到了别的内容,都会让它忘掉这条记录,下一次更新就会去读内核。中继自己的默认路由,仍然走它所在服务的网络,因为它是以网关优先级 -1 接入每个组网桥的。

  • 沙箱组运行期间一直有人盯着。 只要本部署有任何沙箱组在运行,sandbox manager 每五秒一次的巡检就会读回这些表;和自己装的内容不一致时,记一条 network_guard_rules_changed,并把缺的部分重新装上。重新安装的表里不含任何沙箱组的例外规则,所以同一轮巡检会把例外还给每个网桥丢了例外的运行中沙箱组:中继例外按 Docker 的报告恢复,SSH 目标按 sandbox manager 最后一次为同一个 Docker 网络写入的内容恢复;没能恢复的组会记一条 chat_group_exceptions_not_restored,等它下一次激活时再同步。如果连续两次检查都失败,它会记一条 shared_chat_network_unguarded,并停掉所有正在运行的沙箱组(工作区和原生会话都保留),而不是让某个沙箱组在失去启动时依赖的那道边界之后继续跑。唤醒沙箱组时,会先重新安装并核实规则;做不到的话,唤醒就失败。

  • 规则保存在内核里。 runner 退出时,这些表还在;机器重启会把它们清掉,在重新装好之前,任何沙箱组都不会启动。

要以只读方式查看它们,用同样形态的辅助容器:

image="$(docker inspect --format '{{.Image}}' "${COMPOSE_PROJECT}-runner-1")"
host_nft() {
  docker run --rm --network host --runtime runc --cap-drop ALL \
    --cap-add NET_ADMIN --read-only --entrypoint nft "$image" "$@"
}
host_nft list table inet "$table"      # chains; each refusal has a counter
host_nft list table bridge "$table"
host_nft list set inet "$table" relays # relay endpoints, per live bridge
host_nft list tables                   # every deployment's tables here

--runtime runc 不能省:如果主机的 Docker 默认运行时是 Kata,不加的话列出来的就是某个虚拟机里的规则。计数器是本部署所有沙箱组的总和。不会保留按连接记录的日志(设计 §10)。

地址池重叠

每个部署都在自己的 inet 表里登记它的地址池;装好之后,再拿自己的地址池,去和内核里比它早创建的那些 cloud_agent_* 表的地址池比较。两个地址池重叠时,后装的那个会拒绝,提示 this deployment's pool … overlaps …, registered earlier by …,并要求给每个部署各自分配一个 AGENT_EGRESS_CHAT_NETWORK_POOL。先装的那个继续运行,所以一个照抄了地址池的邻居启动时,没法把一个正在工作的部署搞垮;而且因为每个部署都是装好之后才检查,两个同时启动的部署也不会互相漏掉对方。

地址池还必须避开主机已经在路由的所有网络。Docker 会原样接受显式指定的子网,它只检查其他 Docker 网络,从不检查主机自己的路由;所以如果组网桥落在局域网、VPN 路由或者某个私有服务的网段里,就会多出一条更具体的路由,把主机发往这些地址的流量吸进沙箱网桥。装好之后,防护会读取每张路由表里的每条 IPv4 路由(ip -4 route show table all,用的是同一个、不带任何 capability 的辅助容器),有重叠就以 this deployment's pool … overlaps the host route … on … 拒绝。默认路由、比 /8 还宽的兜底路由(比如全隧道 VPN 的两个 /1),以及这个部署自己的网桥,都不算冲突。只通过默认网关才能到达的网络,没有自己的路由可查,所以选地址池时,也要考虑站点本身的地址规划。

已经退役的部署,它的表会一直留到主机重启,地址池也一直处于登记状态。确定那个部署再也不用之后,用上面的辅助容器把它的两张表删掉,只删它的、不删别人的:先 host_nft delete table inet cloud_agent_ca<its digits>,再对 bridge 做同样的操作。绝不要删除或清空一个正在运行的部署用的表:Docker 会对每个组网桥做路由和地址伪装,限制它的正是这张表。任何清空主机整套规则的操作,也会把这些表删掉;下一次检查时会重新装上,连同每个运行中沙箱组的中继和 SSH 例外,但不要在有活跃沙箱组的主机上做这种重新加载。

选择解析器

AGENT_EGRESS_DNS_SERVER 是一个 IPv4 地址,也是沙箱唯一能用的 DNS。公共解析器在哪里都能用。站点自己的解析器,即使在私有地址上,或者就在本机上,也能用,它会成为沙箱唯一能访问的私有地址,而且只限 53 端口。它不能是回环地址(沙箱访问不到主机的 127.0.0.53),不能是未指定地址,也不能在地址池里面。

在 runc 下,沙箱创建时会用 --dns 指定它:Docker 内置在 127.0.0.11 的解析器负责回答中继别名,其他所有名字都转发给它。这项设置在容器创建时就固定了。Kata guest 看不到 Docker 的解析器,所以每次激活之后,受信任的 guest-root 辅助程序会把这个地址写进 guest 的 /etc/resolv.conf,和 /etc/hosts 里的中继条目放在一起。每次冷启动时在 guest 里做的检查,会同时确认两件事:guest 配置的确实是这个解析器,并且它能正常应答;具体做法是查询一次根域名服务器,任何正常工作的递归解析器都会在三秒内回答。

保护更多的地址

AGENT_EGRESS_PROTECTED_CIDRS 接受用逗号分隔的 IPv4 地址块,在内置的那些之外额外拒绝,比如某个机构放在公网地址上的服务。它只能增加保护,不能减少。即使解析器落在新加的地址块里,53 端口也仍然能访问,因为 DNS 是最先检查的。改了这个值,要等 runner 和 sandbox manager 带着新值重新部署,才会在主机上生效:每次安装都会替换受保护的地址集合,读回时会拿它和配置对比。

被拒绝时怎么排查

先看设置。sandbox_host.sh prepare 会在排空任何 runner 之前,用候选镜像自己的解析器读取这些设置;如果被拒绝,会在下面某条错误之后打印 the sandbox network settings were refused (see the error above):

  • AGENT_EGRESS_CHAT_NETWORK_POOL must name this deployment's group address pool 或 … must be RFC1918 space holding at least one /29:没设置;不在 10.0.0.0/8、172.16.0.0/12 和 192.168.0.0/16 范围内(链路本地、回环和其他特殊用途的地址段也会被拒绝);或者比一个 /29 还小;

  • AGENT_EGRESS_DNS_SERVER='…' must be one IPv4 resolver address 或 … must be reachable from a group bridge:没设置、不是 IPv4、是回环地址、是未指定地址,或者落在地址池里;

  • AGENT_GATEWAY_URL='…' names a relay by IP; use its network alias,AGENT_SANDBOX_MCP_URL 也一样:每个组网桥都会给中继分配一个新地址,只有名字是不变的。

然后是预检。预检通过之前,runner 不会认领任何任务,sandbox manager 也不会启动:

  • the relay network '…' does not exist:服务还没起来,或者 AGENT_RELAY_NETWORK 指向了别的网络;

  • the relay network '…' is not `internal`:中继网络绝不能路由到任何地方,让 Compose 按声明重新创建它;

  • nft failed: … 或 network guard helper could not run: …:辅助容器没能装上规则,要么是主机内核不支持 nftables,要么是 Docker 拒绝了这个辅助容器;

  • the network guard runs its helper from this container's own image:runner 是通过检查自己的容器(按 HOSTNAME 找)来确定镜像的,自定义了 hostname: 就会找不到;

  • … is missing chain …、… holds N rules, expected M、… is not hooked on …、… protects …, not the configured set 或 … registers pool …, not …,每条都会写明是哪张表:这些表在装好之后被人改过,或者设置不一样的 runner 和 sandbox manager 在互相覆盖。用同一份 .env 把两者重新部署一遍;

  • this deployment's pool … overlaps …:和另一个部署的地址池重叠;或者 … overlaps the host route … on …:和主机已有的某个网络重叠;见地址池重叠;

  • the sandbox network needs Docker 28 or newer, client and daemon:中继的网关优先级要求客户端和守护进程都满足;见 §0。

最后是沙箱组的生命周期。下面这些错误,会让需要这个组网络的轮次或操作失败,工作区会保留:

  • relay alias '…' has no running container on relay network '…'(或者 multiple running containers):某条隧道挂了或正在重启,或者两套服务共用了同一个中继网络;

  • AGENT_EGRESS_CHAT_NETWORK_POOL … has no free /29 subnet:地址池里的每个 /29 都被占满了,要么是本部署的沙箱组占的,要么是同一地址段里的其他 Docker 网络占的;

  • group network check failed inside the sandbox: …:冷启动之后在 guest 里做的检查,发现了下面某个问题:

    • no default route、resolver is […], expected […];

    • resolver … did not answer: … 或 … did not answer the query usefully:解析器挂了、拒绝递归查询,或者(在 runc 下)容器创建时用的解析器,已经不被现在的规则允许了;

    • relay host:port unreachable: …;

    • the host at …:9/udp was refused by ECONNREFUSED, not by the network rules、the metadata address 169.254.169.254:80 answered; the network rules are not in this guest's path(the platform address 168.63.129.16:80 也会有同样的提示),或者 … was not refused within 2s:主机地址和两个平台地址,都应该立即被规则自己回复的 ICMP 拒绝,结果没有,说明 guest 的流量没有经过本部署的表。

    沙箱会被停掉,但不会删除。能不能访问公网不在检查范围内,因为外部服务出故障,并不代表边界坏了;

  • fixed relay joined the chat network without its gateway priority:Docker 守护进程忽略了 --gw-priority,需要 Docker 28 或更新版本;

  • Kata guest network sync failed: …:guest-root 辅助程序没能写入 /etc/hosts 或 /etc/resolv.conf;

  • retained chat is attached to network '…', which is not its group bridge,结尾是 needs the one-time cleanup:这是旧网络时代留下的对话;sandbox manager 启动时遇到它,会直接无法启动。见下一节;

  • a running retained chat is not on exactly its group bridge:运行中的 guest 绝不会在底下被重新接线,先把它停掉。

在 sandbox manager 的日志里,chat_legacy_network_found 指的是本部署里一个来自以前"按阶段分网络"拓扑的网桥;它只会被报告出来,绝不会被接管或修复。chat_group_relay_exceptions_left 表示某个已删除网桥的中继条目,没能从表里撤掉。网桥没了之后,这些条目什么都匹配不上;如果辅助容器一直运行不了,下一次激活会失败。

升级到单一网络时的一次性清理

只针对运行过旧网络(按阶段划分的网桥和 Squid 代理)的主机。从没跑过旧网络的主机,这些都不用做。新版本不会接管旧版本留下的任何东西。它会以上面那条消息,拒绝还接在旧网络上的保留对话;而且因为 sandbox manager 启动时会停掉本部署所有的保留对话,只要有一个这样的容器,新的 sandbox manager 就完全起不来;旧的网桥则只会被记日志。设计上把这次切换定位为开发和测试主机上的一步操作,那些主机上没有需要迁移的用户(§8),所以下面的步骤会丢弃旧的保留对话。在第 4 步之前,把你需要的工作区都导出来。

只删除本部署的资源。 下面每一次列出,都按本部署自己的标签筛选:这套服务自己的容器、网络和卷,按 Compose 项目名筛;对话容器和组网桥,按 org.freeinference.sandbox-namespace 筛(在主机服务上,它就是同一个 Compose 项目名)。删任何东西之前,先看一遍列出来的内容。带着别的项目名或命名空间的资源,属于这台机器上的另一个部署,不要动它;分不清归属的东西,也不要动。

  1. 排空主机(Admin → Servers),等它上面什么都不跑了,再停掉这套服务:

    export COMPOSE_PROJECT="<this stack's Compose project>"
    deploy/host/sandbox_host.sh down
    

    如果代码目录里已经是新版本了,先把第 7 步里的两个设置加进 .env,否则新的 compose 文件渲染不出来。

  2. 列出本部署的对话容器:

    docker ps -a \
      --filter label=org.freeinference.chat-sandbox=true \
      --filter "label=org.freeinference.sandbox-namespace=$COMPOSE_PROJECT" \
      --format '{{.ID}}  {{.Names}}  {{.Status}}  {{.Networks}}'
    

    停掉服务并不会改变对话容器的状态,所以有些可能还在运行。它们全都是升级之前创建的。新版本会拒绝任何还接在旧网络上的容器,而且它们没有一个是用 AGENT_EGRESS_DNS_SERVER 创建的,在 runc 下,容器的解析器在创建时就固定了。所以它们都得删掉。

  3. 先把想保留的东西导出来。对话的工作区只存在于容器的可写层里,docker cp 可以读取已停止的容器:

    docker cp "<container>:/workspace" "./<container>-workspace"
    
  4. 按 ID 删除第 2 步列出的那些容器:

    docker rm -f "<id>" …
    

    这是唯一一处被允许对对话容器执行 docker rm 的地方;其他任何场合,主机上的保留 Docker 存储都禁止这么做。这些对话无法再继续(上下文显示为 LOST);新任务会用新的容器。

  5. 列出本部署的对话网络,删掉旧的:

    docker network ls -q \
      --filter label=org.freeinference.chat-sandbox-network=true \
      --filter "label=org.freeinference.sandbox-namespace=$COMPOSE_PROJECT" |
      xargs -r docker network inspect --format \
        '{{.Name}} {{.Internal}} {{index .Options "com.docker.network.bridge.name"}}'
    docker network rm "<name>" …
    

    每一行依次是:网络名、是否为 internal,以及网桥接口名。旧的 freeinference-chat-net-* 网络是 internal 的(true),并且没有接口名;新版本的组网桥不是 internal 的,接口名以 ca… 开头。新版本运行之前,列出来的全都是旧的。

  6. 删掉代理,以及旧的 Compose 网络和卷。先列出 Compose 为这个项目创建的东西:

    docker ps -a --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \
      --format '{{.ID}}  {{.Names}}  {{.Label "com.docker.compose.service"}}'
    docker network ls --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \
      --format '{{.Name}}  {{.Label "com.docker.compose.network"}}'
    docker volume ls --filter "label=com.docker.compose.project=$COMPOSE_PROJECT" \
      --format '{{.Name}}  {{.Label "com.docker.compose.volume"}}'
    

    用 docker rm -f、docker network rm 和 docker volume rm 删除以下这些:服务 agent-egress-proxy 和 agent-egress-proxy-config 的容器;Compose 键为 agent-egress、agent-egress-trusted 或 agent-egress-uplink 的网络(名字是 agent-egress 或旧的 AGENT_EGRESS_NETWORK_PLATFORM_ONLY,以及 <COMPOSE_PROJECT>-agent-egress-trusted 和 -uplink,除非 .env 里另外给它们起了名字);以及 agent-egress-proxy-config 卷,它里面只有生成的 Squid 配置。列表里的其他东西都不属于这次清理。如果之前用上一个版本执行过 down,其中一部分可能已经删掉了。

  7. 对照新的 deploy/host/.env.example 更新 .env。设置 AGENT_EGRESS_CHAT_NETWORK_POOL(它现在没有默认值了)和 AGENT_EGRESS_DNS_SERVER。旧的默认地址池是 10.240.0.0/16:如果这台机器上的两个部署都沿用了它,给其中一个换一个,否则后装的那个会拒绝启动。删掉已经退役的设置:AGENT_EGRESS_SETUP_TIER、AGENT_EGRESS_AGENT_TIER、AGENT_EGRESS_ALLOWLIST、AGENT_EGRESS_NETWORK_*、AGENT_EGRESS_PROXY_*、AGENT_EGRESS_STOP_GRACE、AGENT_SANDBOX_NETWORK 和 AGENT_SANDBOX_ALLOW_OPEN_NETWORK。已经没有代码读取它们了。

  8. 撤销主机的排空(Admin → Servers,或者 POST /admin/agent/runner-hosts/<host>/undrain):它的 runner 已经停了,所以暂时还不会认领任何任务,而排空也从不会被心跳自动解除。然后像平常一样部署新版本,用 shared-live-exact-sha-v4 版本的部署脚本。每个 runner 的预检,都会在认领之前装好主机上的表并读回确认。用 host_nft list tables(见主机上的防火墙表)确认本部署的 inet 和 bridge 两张表都在,然后跑一个 setup 里会安装软件包的新任务。

运行时验证

改了运行时集成代码之后,要跑适配器的契约测试。它们会重放录下来的事件和合成的事件,在不需要真实模型、也不需要部署凭据的情况下,锁定事件规范化后的映射:

uv run pytest tests/unit/test_agent_runtimes.py

MCP 的测试套件也在这里,和目录、每用户的注册表、中继、过滤器、API 以及运行时适配器放在一起。改了这条路径之后,至少要跑:

uv run pytest tests/unit/test_mcp_connection_config.py \
  tests/unit/test_mcp_protocol_and_authorization.py tests/unit/test_mcp_proxy.py \
  tests/servers/test_mcp_relay.py tests/servers/test_agent_jobs_api.py \
  tests/unit/test_agent_runtimes.py

这些测试不会调用实际安装的 agent 二进制。已安装 CLI 的冒烟测试,以及完整的真实模型测试矩阵,归运营这些二进制、模型和凭据的部署方负责。那套一致性测试的配置放在部署 overlay 里,不要放进中立的上游仓库。

最初的 staging 和 production 运行实际验证了哪些东西、又留下了哪些没验证,保存为一份带日期的记录。

SSH 服务器

所有者在 Settings → SSH connections(/settings/ssh-connections)里登记自己的 Linux 服务器,逐台检查,然后通过 + → SSH servers 按对话开启。之后 agent 会直接从沙箱里对这些服务器执行 ssh、scp 和 rsync;平台自己从不在服务器上执行任何命令。设计和已接受的风险,见 SSH 服务器设计,以及 AGENTS.md §7 里关于所有者 SSH 服务器的那一条。

  • 迁移与密钥。 0080_user_ssh_servers 新增了 agent_ssh_servers、agent_ssh_thread_bindings、agent_ssh_server_events,以及序列 agent_ssh_access_revision_seq。每台服务器的 Ed25519 私钥,都用 AGENT_SOURCE_CONTROL_ENCRYPTION_KEY 在它专用的 hybridinference/ssh-server-key/v1 域下加密;没有这把密钥时,这一部分会如实提示,并且无法添加任何服务器。

  • 上线。 runner 和 sandbox manager 会声明 ssh-access-v1,沙箱镜像里现在也带了 openssh-client。如果一个沙箱组里曾经有对话选过服务器,那么当认领它的 runner 不能证明自己有这项能力时,认领会被释放,不消耗 attempt;如果 sandbox manager 没有 PUT /workspaces/{workspace_id}/ssh-access 这个接口,这一轮会失败,而不是在缺了服务器的情况下照样启动 agent。先把主机升级好,再让所有者在上面开启服务器;已经固定在旧主机上的沙箱组会等着。

  • 一个轮次里发生了什么。 在 setup 和 agent 启动之前,runner 会把认领里这个组完整的 SSH 状态发给 sandbox manager,并标明是这个成员正在开始的那一轮。manager 会拿它和自己记录的最新状态比较,那份状态存在一个 root 所有的文件 /run/agent-members/.ssh-access-state 里(只有修订号和每个对话条目的摘要,没有密钥)。如果收到的状态至少一样新,就记录下来,并设置网桥的 ssh_targets;如果更旧,就不动网桥,并把它裁剪到最新状态允许的范围,而不是直接跳过。接着,manager 会删掉所有"最新状态不允许访问任何服务器"的成员槽位的 SSH 目录;再以这个正在开始的成员的 UID,把它可以用的服务器写进它私有的 tmpfs(每台服务器一个目录,里面有密钥、known_hosts、Host 配置块和摘要),并在它的 ~/.ssh/config 最上面加一行 Include。其他成员只会失去服务器,不会多出服务器。如果一个沙箱组里从来没有对话选过服务器,这些都不会发生,也不会发送任何状态。

  • 收回访问立即生效;授予访问要等下一轮。 为某个对话开启或关闭一台服务器,以及编辑、检查、更换密钥或删除一台服务器,都会把新状态推送给受影响的每个正在运行的沙箱组,推送到这个组所在的主机上。推送用沙箱组的源对话来指明是哪个组,sandbox manager 重启后会从容器里重新得知这一点;如果 manager 不认识这个对话,比如源对话已经删除、让出了槽位,就会换用这个组里另一个占着槽位的对话重试。推送只做删除:删掉一个元素,正在进行的 SSH 会话会在下一个数据包时被切断;成员针对一台已经不能再用、或者密钥已经变了的服务器的目录,也会被删掉。新开启的服务器或新的密钥,要随这个对话的下一轮才会送到,由那一轮的 runner 负责对密钥做遮蔽处理。如果最新的状态在记录之后应用失败,manager 会清空这个网桥上所有 SSH 相关的元素,并让这次调用失败;所以在某个状态被完整应用之前,这个沙箱组会一直保持关闭。推送只找正在运行的沙箱组,而认领读取状态是在预留沙箱组之前,所以如果改动发生在一个新的、已停止的或 cold 的沙箱组启动期间,推送和认领都拿不到它。runner 把沙箱组标记为运行中时,控制平面会再推送一次这个组的状态;它比认领时读到的更新,不管两者谁先到,manager 都会保留这一份。控制平面连不上的主机,会随下一轮的认领拿到状态。

  • 检查由控制平面向外发起连接。 一次检查会先解析地址,只要有任何一个解析结果是私有、保留或受保护的地址,就整体拒绝;然后用 ssh-keyscan 读取主机密钥;在所有者确认指纹之后,再用平台的密钥登录一次,执行 true。在控制平面上,把 AGENT_EGRESS_PROTECTED_CIDRS 设成和主机一样的值,这样检查拒绝的地址块就和沙箱一致。控制平面需要能向外连接所有者服务器的 SSH 端口(TCP)。每个所有者每小时最多做 20 次检查。每次检查对每个输出流最多读 64 KB,超过就杀掉客户端,所以一台用大量输出刷屏的服务器,只会让检查失败,而不会把控制平面的内存撑满。

  • 所有者的防火墙。 如果服务器限制了谁能连接,就必须同时放行控制平面(用于检查),以及它的对话可能运行的每一台主机。

可复用的云环境

迁移 0065_environments 新增了所有者的配置、不可变的加密版本、固定的工作区绑定、隔离的测试租约,以及操作结果。控制平面里的值使用 AGENT_SOURCE_CONTROL_ENCRYPTION_KEY,在单独的 hybridinference/environment-variable/v1 域下加密。这把密钥要和现有的凭据存储一起备份;不经重新加密就换掉它,旧的绑定就读不出来了。Secrets 无法通过所有者的读取或历史 API 拿到。

仓库环境是一项标准的产品功能,不需要管理员开关就能用。设置页面、创建任务时的选择,以及 Environment 标签页始终显示;/config 也始终返回环境相关的限制。以前保存的 repository_environments_enabled 设置,或者环境变量 AGENT_REPOSITORY_ENVIRONMENTS_ENABLED,都会被忽略,也不再出现在设置编辑器里。没有配置环境的任务,仍按基础环境运行,不做准备;配置了环境的工作区,仍然要求 runner 和 sandbox manager 实际支持 environment-v1。

在创建任何新的、绑定了环境的工作区之前,先把每一个控制平面副本都升级好;如果新旧版本混着处理准入、Fork 和终端请求,绑定信息可能会悄悄丢失。一旦出现了第一个绑定,就不要再回滚到读不懂它的二进制版本。如果非要回滚:先关掉这些执行入口,把环境测试和环境准备都排空,并让已绑定的工作区留在兼容的主机上,或者隔离起来。

支持环境的 runner 和 sandbox manager 要一起上线。发起认领的副本必须证明自己支持 environment-v1,而且实际通过认证的那个 sandbox manager,必须在接收配置之前声明支持它。较旧的主机遇到需要配置的任务,会把它释放掉,不消耗 attempt;它绝不能在缺少变量的情况下运行这些任务。环境测试同样遵守部署固定、计算资源准入、副本证明和主机槽位的限制,并且只拿到只读的源码凭据,拿不到推理、MCP 或仓库写入的授权。

准备回执存在主机凭据目录下的 environment-preparation 里,不在容器挂载和源码归档之内。里面记录的是绑定、容器和启动的身份,以及遮蔽过的步骤,从不包含变量的值。sandbox manager 重启时,这份主机状态要保留。安装在每个固定的工作区上只会成功一次;startup 在每次实际启动时运行一次。失败的步骤会一直显示在 Environment 标签页里,可以针对原来的绑定重试。

手动操作的回执和准备状态放在一起,在 environment-actions 下。它们会在启动命令之前先占住这次运行,并在 PTY 被回收之后,保留遮蔽过的结果。sandbox manager 重启时,这个目录要保留;删掉它,主机就失去了一道防止重放的栅栏,针对的是那些控制平面确认已丢失的请求。如果一次启动被中断、又没有可恢复的结果,会报告为 unknown,绝不会自动重放。换一个幂等键开始新的运行之前,先检查一下它在外部已经造成了哪些影响。这些回执并不能保证外部服务里的副作用恰好只发生一次。

测试、准备步骤或操作的输出,会经过遮蔽、限制长度后存储,其中每个 NUL 和落单的代理项都会替换为 U+FFFD:这两种字符 Postgres 都存不了,而拒绝这段输出,会让测试或命令一直完不成。

空的绑定仍然会固定原任务的选择,但既不需要 environment-v1,也不需要做准备。每次环境测试,会在一个后台 worker 上占用一个本地槽位,这样其他空闲槽位还能继续认领编码任务。主机上的孤儿查询是只读的,并且限流;租约过期和历史记录的维护,由控制平面的 reaper 负责。

取消会立即把结果隔离掉,但租用的槽位会一直保留,直到确认清理完成,或者租约过期。过期的孤儿不再占用容量;它所在的主机重新连上之后,在确认清理完成之前,不能认领新的工作。reaper 会让租约和超过 30 分钟的执行过期。重新连上时,已注册的主机只会收到它自己那些过期测试的 ID,并在确认清理之前,删掉那些确切的、带标签的容器。不要为了腾容量去删无关的沙箱组,也不要绕过租约和容器身份的检查。已经归档、并且没人再引用的版本,30 天后回收;已有的工作区、测试和发布绑定,会保留它们当时的加密值,用于遮蔽和扫描。

所有者提供的凭据,可以被用户代码读取,并通过现有的网络发送出去。按已知值做的遮蔽,以及受信任的发布扫描,都覆盖不到编码过的 secret、所有者原始的终端和文件,或者直接的 Git 推送。平台持有的长期凭据、保留的变量名,以及固定的出口规则,仍然受保护。

环境的 setup、startup 和操作,共用 /workspace/.cloud-agent-tools 里的可执行包数据,这个目录通过 Git 的本地 exclude 文件排除在版本控制之外。成员存放凭据的 HOME 目录仍然是私有的。显式刷新运行时会保留安装时的输入,并从下一轮开始生效;已经在运行的服务继续用旧值。startup 恢复只是明确放行接下来的一次人工轮次,绝不是一张永久的成功回执。

团队环境

团队的所有者和管理员可以为团队创建环境:在 Settings → Environments 里,New environment 或 Copy 会列出他们拥有或管理的团队。每位活跃成员都能在那里,以及新建任务的选择器里看到这些环境(标着团队名),并可以查看、测试、复制和选用它们。只有所有者和管理员能编辑、恢复、移除、重建环境或删除快照,其他人会收到 403。创建、编辑和移除团队环境,都会记在团队的审计日志里。设计见团队 §6。

  • 每位成员用自己的容器。 团队环境共享的是配置,而不是工作区。成员的任务在这个成员自己的容器里运行,用的是团队的变量和脚本、这个成员自己的推理授权和连接;这个成员个人的全局变量只在运行时使用(只用于安装的那些会被排除;如果名字相同,以团队的定义为准)。所以安装结果对每位成员都一样,团队才能共享快照。

  • Secrets 是共享的。 只要能用团队环境运行任务,成员就能在任务里读到它的 Secrets,对话框里也会说明这一点。请把它们当作整个团队都知道的内容;如果某位成员离开后不应该再持有某个 Secret,就把它轮换掉。

  • 离开团队。 绑定会记下团队,以及这位成员的访问代次。当成员离开、被移除,或者团队被删除时,他在团队环境上的每个对话,下一轮认领时都会以 team_environment_revoked 失败(403,日志记为 agent_job_team_environment_revoked);已经在跑的那一轮会跑完。在这样的对话上,终端、操作、Fork、环境准备和运行时刷新都会返回 409;遮蔽和发布扫描仍然会读取它的值。重新加入团队也不会让旧对话恢复,因为代次已经往前走了。如果成员的仓库默认设置指向一个他已经不能再用的团队环境,下次他开始任务时,这个默认设置会被清掉。

  • 移除环境。 移除一个团队环境,会清掉所有指向它的成员默认设置。已经绑定的对话,和个人环境一样,保持它们固定下来的配置。

  • 回滚。 只要还存在任何团队环境(包括已归档的),降级到 0082_team_environments 以下就会被拒绝。