数据截至 (上游 commit e55b2a12c9a5)
沙箱内部:守护进程、OpenCode 与内容寻址的镜像
30 秒导读: 每个 session 都有一台一次性的 Linux 盒子。盒子里 Kortix 自己的进程只有一个——
kortix-agent守护进程。它开机、克隆仓库、拉起 OpenCode(真正干活的 agent 运行时)、把 8000 端口反代出去,并顺手当静态站和文件服务。这台盒子的镜像不是"某次构建的产物",而是一串内容哈希:输入不变就复用,输入变了才重建。
本章讲数据面:盒子里到底跑着什么、怎么开机、外面怎么进来、镜像怎么来。 不讲"谁在什么时候要一台盒子"(见 02-session-lifecycle),也不讲盒子里 agent 调的外部工具与模型(见 05-executor-connectors、06-llm-gateway-and-metering)。
1. 这是什么(零基础也能懂)
一句话定义: 沙箱 = 一台按 session 分配的一次性 Linux 盒子,里面由一个叫 kortix-agent 的守护进程当"物业",OpenCode 当"干活的租客"。
它解决什么问题。 你让 AI 改一个真实项目的代码,它需要一个能随便折腾的地方:能 rm -rf、能装依赖、能起 dev server、能开浏览器。云端 API 进程里干不了这些。于是每个 session 拿一台真机(microVM),用完丢弃。
盒子里跑三样东西:
| 进程 | 端口 | 干什么 | 谁启动它 |
|---|---|---|---|
kortix-agent(守护进程) | 8000 | 反代 + 控制面 + 文件/搜索 API | 容器 ENTRYPOINT |
opencode serve | 4096(仅 loopback) | 真正的 agent 运行时(会话、工具、模型调用) | 守护进程 spawn 并监管 |
| 静态站(同进程内) | 3211 | 把 agent 写到磁盘的 HTML 直接发出去 | 守护进程,最先起 |
用起来什么样。 探活就是一个 HTTP GET,永远返回 200(README apps/kortix-sandbox-agent-server/README.md:74-87 描述了这个形态):
{
"daemon": "ok",
"status": "ok",
"runtimeReady": true,
"opencode": "ok",
"opencode_pid": 4567,
"static_web_port": 3211,
"repo": "https://github.com/owner/name.git",
"branch": "session-abc",
"commit_sha": "abc123…",
"boot_timeline": [{ "label": "static-web", "atMs": 3 }, { "label": "repo-materialized", "atMs": 812 }]
}
注意
daemon和runtimeReady是两件事。守护进程活着不代表这台盒子能用——第 7 节讲这条区分为什么是整章最重要的一句话。
一句话直觉。 把守护进程当成楼盘物业:它自己不写代码,但它开门、通水电、查身份证、在租客(OpenCode)睡死时把它叫醒。镜像则是图纸——同一张图纸盖出来的楼,内部一模一样。
2. 顶层全景(它大概怎么转)
怎么读这张图:从左到右是一次请求的方向;虚线框是那台一次性盒子的边界;盒子内部只有 8000 是对外的。
┌──────────────── 沙箱(一次性 microVM) ─────────────────┐
┆ ┆
浏览器 / CLI / Slack ┆ ┌─────────────────────────────────────────────┐ ┆
│ ┆ │ kortix-agent 守护进程 :8000 │ ┆
▼ ┆ │ │ ┆
┌──────────────┐ ┆ │ ① /kortix/* 控制面(health/refresh/env) │ ┆
│ apps/api │──HTTP────▶┆──▶│ ② HMAC 闸门 验签 X-Kortix-User-Context │ ┆
│ 预览代理 │ ┆ │ ③ /file /find /presentation 自己答 │ ┆
│ (两种路由) │──WS──────▶┆ │ ④ /proxy/{port} 转发到盒内任意端口 ───────┼──┐ ┆
└──────────────┘ ┆ │ ⑤ 其余全部 ──反代──▶ opencode │ │ ┆
┆ └───────────────┬─────────────────────────────┘ │ ┆
┆ │ 127.0.0.1:4096 │ ┆
┆ ┌────────▼────────┐ ┌──────────────┐ │ ┆
┆ │ opencode serve │ │ 静态站 :3211 │◀───┘ ┆
┆ │ (agent 运行时) │ │ (同进程) │ ┆
┆ └─────────────────┘ └──────────────┘ ┆
└────────────────────────────────────────────────────────┘
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| 启动编排 | 决定开机次序、写 boot 时间线 | apps/kortix-sandbox-agent-server/src/main.ts:78 main |
| OpenCode 监管 | spawn / 探就绪 / 崩溃重启 / drain | src/opencode.ts:1573 createOpencodeSupervisor |
| 反向代理 | HMAC 闸门 + 路由 + SSE/WS 透传 | src/proxy.ts:400 startProxy |
| 静态站 | 发 HTML 并注入 <base> | src/static-web.ts:494 startStaticWebServer |
| 健康 | 合成 runtimeReady | src/routes/health.ts:140 createHealthRouter |
| 秘密投递 | 写 tmpfs 的 shell env 文件 | src/agent-env-file.ts:151 writeAgentEnvFile |
| 镜像身份 | 算内容哈希 | apps/api/src/snapshots/hash.ts:71 computeSnapshotHash |
| 镜像编排 | 命中缓存 or 构建 | apps/api/src/snapshots/builder.ts:310 ensureSandboxImage |
主线走一遍(高层):
- 控制面创建盒子 → 容器以
kortix-entrypoint起来 →exec守护进程。 - 守护进程按次序开机:静态站 → git 身份 → 克隆仓库 → 解析 OpenCode 配置目录 → 拉起 OpenCode → 起反代。
- 外面的请求经
apps/api的预览代理进入 8000,验签后要么被守护进程自己答掉,要么反代给 OpenCode。 - 关机信号来时,反代先停、静态站再停、最后 kill OpenCode,并把 tmpfs 里的秘密文件擦掉。
3. 开机次序:为什么静态站必须最先起
这节讲一件事:开机的顺序本身就是设计。
3.1 从 PID 1 说起:先确认 /workspace 真的存在
容器 ENTRYPOINT 不是守护进程本身,而是一段 bash(apps/sandbox/entrypoint.sh)。它做两件反直觉的事:
- 轮询
/workspace直到连续两次探测都成功(entrypoint.sh:69-100)。原因写在注释里:Daytona 的运行时可能在容器启动之后才重建/workspace;守护进程如果此刻正把 cwd 落在那儿,cwd 会变成/workspace (deleted),之后每个文件操作都诡异地静默失败。 cd /之后才启动守护进程(entrypoint.sh:109;启动发生在 supervisor 的while循环里,:232-240——循环支持热换/回滚二进制)。cwd 锚在永远存在的/,守护进程之后一律用绝对路径。
# 示意,非源码 —— entrypoint.sh:109(cd /)+ :232-240(supervisor 循环)
cd /
while :; do
"${agent_bin}" "$@" # 不再是裸 exec:可热换二进制,崩溃按预算回滚
done
3.2 六步开机,每步打一个时间戳
main.ts 里的 bootMark 把每一步的相对毫秒写进 bootState.timeline,再从 /kortix/health 吐出来(main.ts:62-64)——线上排查"这 8 秒花在哪"就靠它。
t=0 ① 起静态站 main.ts:76 startStaticWebServer
│ 只读磁盘,零依赖 → 预览在 agent 还没醒时就能用
──────┼─────────────────────────────────────────────────────
② 配 git 身份/凭据 main.ts:88,98
│ 失败只 warn,不致命
──────┼─────────────────────────────────────────────────────
③ 克隆项目仓库 main.ts:124-135 materializeRepo
│ ★ 必须在 ④ 之前 —— 见下
──────┼─────────────────────────────────────────────────────
④ 解析 config dir main.ts:137 resolveOpencodeConfigDir
│ ⑤ 离线满足依赖 main.ts:146 ensureOpencodeConfigDeps
──────┼─────────────────────────────────────────────────────
⑥ spawn opencode main.ts:149,162
⑦ 起反代 + 装信号 main.ts:173-174
⑧ 跑 on_boot(后台) main.ts:188
3.3 三个"为什么"
为什么静态站排第一? 它只读磁盘,不依赖仓库、不依赖 OpenCode。排第一意味着:agent 还在冷启动的那几秒里,用户点开预览已经能看到东西;并且仓库或 OpenCode 挂掉也不会把预览带下水。绑定失败是非致命的——static_web_port 报 null,守护进程照常活着(static-web.ts:520-526)。
为什么克隆必须早于解析配置目录? 因为 OpenCode 的配置目录长在仓库里(<workspace>/.kortix/opencode)。源码注释把踩过的坑写得很直白(main.ts:106-116):在克隆前解析,永远拿不到项目的 opencode.jsonc,于是静默回落到镜像烘焙的默认目录——这个 session 就跑在没有自定义 agent、没有插件、连 default_agent 都不对的配置上。而 OPENCODE_CONFIG_DIR 是 spawn 时固定的,所以 OpenCode 也不能跟克隆并行起。
为什么中间插一步"离线满足依赖"? OpenCode 第一次开 session 时会在配置目录里跑 bun install,而 starter 把 node_modules / bun.lock 都 gitignore 了。于是那次安装会联网重新解析 ^ 版本范围——正常 1.5–6 秒,npm 拥堵时能到分钟级,而且它卡在 runtimeReady 前面。ensureOpencodeConfigDeps 用三级兜底把这件事变成 0.5 秒以内(opencode-config-deps.ts:177-246):
| 级别 | 做法 | 代价 |
|---|---|---|
| 1 | 项目 lock 与烘焙 lock 一致时,symlink 镜像里烘焙好的 node_modules | ~即时,离线,确定 |
| 2 | 在暂存目录用预热的 Bun cache 装,装完再原子替换 | 秒级,离线 |
| 3 | 失败就清掉旧树,让 OpenCode 自己联网装 | 回到原样 |
单测锁住了这几条路径:__tests__/opencode-config-deps.test.ts:18(链接烘焙树)、:42(没声明依赖就 no-op)、:79(lock 不匹配→暂存区安装+原子替换)、:106(暂存失败→清旧树,OpenCode 干净自装)。
3.4 on_boot:项目自己声明的自启动栈
项目清单里的 sandbox.on_boot 是一条 shell 命令,守护进程在仓库就绪且反代起来后后台跑它(main.ts:275-296),输出追加到 /var/log/kortix-on-boot.log:
// apps/kortix-sandbox-agent-server/src/main.ts:283-289
const out = openSync(logPath, 'a')
const child = spawn('bash', ['-lc', onBoot], {
cwd: cfg.projectTarget, env: process.env, detached: true, stdio: ['ignore', out, out],
})
清单的规范形态已是 kortix.yaml(schema v2),老 kortix.toml(v1)只作回落——readProjectManifest 按这个顺序找文件(config.ts:207-222)。解析这条命令的仍是手写正则而不是 TOML/YAML 解析器——守护进程刻意不引解析器依赖,按 format 走两套正则抠值(config.ts:231-261 extractNestedString,resolveSandboxOnBoot 在 config.ts:269-274)。suna 仓库根一度自带的那份 kortix.toml(on_boot = "pnpm dev",开机拉起 dockerd + supabase + API + web 整套本地栈)已随 v2 迁移移除,这个例子如今只留在 resolveSandboxOnBoot 的注释里(config.ts:266)。
失败永远不影响 agent 运行时——child.on('error') 只记一条 warn。
4. 监管 OpenCode:spawn、探活、重启、drain
守护进程对 OpenCode 只做四件事,但每件都有非显然的取舍。
4.1 spawn:注入什么、藏起什么
spawnChild(opencode.ts:570)拼出来的子进程环境有四类东西:
| 类别 | 具体 | 为什么 |
|---|---|---|
| 家目录重定向 | HOME=/opt/kortix/home + 三个 XDG_* | 镜像在这些路径下烘焙了迁移完的 sqlite、Bun cache、浏览器缓存 |
| 配置目录 | OPENCODE_CONFIG_DIR | 第 3.3 节那个"必须先克隆"的原因 |
| shell 钩子 | BASH_ENV=/dev/shm/kortix/agent-env.sh | 让 OpenCode 起的每个 bash -c 都拿到项目秘密(第 8 节) |
| 删掉的 | KORTIX_OPENCODE_DENY_ENV 列出的名字 | 见下 |
最后一条是安全设计:如果 OpenCode 的环境里存在 ANTHROPIC_API_KEY 之类的原生 provider key,它会自动连原生 provider 直连,绕开网关的日志、预算与 BYOK 处理。所以 API 告诉守护进程要抹掉哪些名字,守护进程逐个 delete(opencode.ts:600-618),并在配置里写死 enabled_providers = ['kortix'](opencode.ts:150)。
还有一个很"物理"的坑:拼出来的配置里带着网关的完整模型目录,大约 400KB,远超 Linux 单个环境变量 128KB 的 MAX_ARG_STRLEN。直接塞进 OPENCODE_CONFIG_CONTENT 会让 execve 报 E2BIG、OpenCode 根本起不来。解法是落盘再传路径(opencode.ts:634-639):
const configPath = join(OPENCODE_CONFIG_HOME, 'kortix-opencode.json')
writeFileSync(configPath, opencodeConfig, { mode: 0o600 })
env.OPENCODE_CONFIG = configPath
delete env.OPENCODE_CONFIG_CONTENT
配置本身由 buildOpencodeConfigContent(opencode.ts:40)合成,它叠加在仓库自带的 OpenCode 配置之上,只有三个独立贡献者:Executor MCP、Kortix 网关 provider、Slack 会话的权限覆盖(把阻塞式 question 工具设成 deny)。三者都不适用时返回 undefined,仓库配置原样生效。
还有一条容易漏的补丁:
withModelLimits(opencode.ts:444)给每个模型强行补上上下文窗口。网关的/models不返回每模型上限,而 OpenCode 没有上限就无法估算会话长度、自动压缩永远不触发——长会话最后卡死在 100% 上下文。
4.2 就绪:问业务 API,不 ping 端口
探针打的是 OpenCode 真正要用的那个接口,而不是一个health 路由:
// apps/kortix-sandbox-agent-server/src/opencode.ts:796-808 probeOpencodeSessionApi
const res = await fetch(`${baseUrl}/session?directory=${encodeURIComponent(directory)}`, …)
return res.status >= 200 && res.status < 400
理由写在函数注释里:OpenCode 能绑定端口,但项目目录还不可用——那种状态下端口探针是绿的,真实请求却全挂。所以富一点的启动探针把状态分成三档(probeOpencodeReadiness,opencode.ts:848):down(端口不答)/ listening(答 HTTP 但 /session 还不是 2xx)/ ready。这两档之间的间隔正好把冷启动开销归因成"进程启动慢"还是"OpenCode 内部初始化慢"。
探到 ready 之后要立刻降频。 这是一条实测出来的规则(opencode.ts:16-22):
| 阶段 | 间隔 | 代价 |
|---|---|---|
| 未就绪 | 100ms(READY_POLL_MS) | 快速发现启动完成 |
| 已就绪 | 5s(READY_LIVENESS_MS) | 每台空闲沙箱的 OpenCode 从 ~55% 一核降到 ~2% |
那 55% 就是"暖沙箱单机密度只有 ~14 台"的主因;崩溃本来就由 proc.on('exit') 抓,ready 之后根本不需要高频轮询。
4.3 崩溃重启:指数退避,状态回落到 starting
// apps/kortix-sandbox-agent-server/src/opencode.ts:658-669
proc.on('exit', (code, signal) => {
child = null
state = stopping ? 'down' : 'starting'
if (stopping) return
const delay = restartDelayMs
restartDelayMs = Math.min(restartDelayMs * 2, 30_000)
setTimeout(() => { if (!stopping && binaryPath) void spawnChild(binaryPath) }, delay)
})
退避从 500ms 翻倍到 30s 封顶,markReady() 一旦成功就把退避重置回 500ms(opencode.ts:678-682)。注意 stopping 这个标志:主动 stop() 把状态置为 down(终态),意外退出置为 starting(会自愈)——反代据此决定回 503 还是照常转发。
二进制找不到也不崩。 detectOpencodeBinary(opencode.ts:504)先看 /usr/local/bin/opencode-kortix(打过补丁的构建),再 command -v opencode;都没有就只记一条 warn、状态停在 starting,守护进程照样提供 /kortix/health。
4.4 drain:关的顺序和开的顺序相反
// apps/kortix-sandbox-agent-server/src/shutdown.ts:14-40(节选逻辑)
shredAgentEnvFile() // 先擦秘密
await proxy.stop() // 再停对外入口
await staticWeb.stop()
await opencode.stop(signal) // 最后杀子进程(SIGTERM,5s 不听话就 SIGKILL)
opencode.stop() 里那个 5 秒硬杀的定时器带 .unref()(opencode.ts:745-750),不会把进程吊着不退出。
5. 那道闸门:8000 端口上的反向代理
5.1 路由表
buildOpencodeApp(proxy.ts:192)挂载顺序即优先级:
| 路径 | 谁能进 | 干什么 |
|---|---|---|
/kortix/health | 免鉴权 | 云端探活,永远 200 |
/kortix/refresh | 签名的 X-Kortix-User-Context | 快进仓库 + 重启 OpenCode |
/kortix/env | Authorization: Bearer <沙箱凭据>,且禁止带用户上下文头 | 服务端到服务端同步项目秘密 |
/kortix/abort | 同 /kortix/* 分支(免主闸门) | 打断当前 turn |
/proxy/{port}/* | 主闸门 | 转发到盒内任意 localhost 端口 |
/web-proxy/{scheme}/{host}/* | 主闸门 | 正向代理外网,改写 HTML/CSS 让 iframe 能嵌 |
/file/* /find/* /presentation/* | 主闸门 | 守护进程自己答,不转给 OpenCode |
/* | 主闸门 | 反代给 OpenCode(HTTP + SSE) |
主闸门是一个 Hono 中间件(proxy.ts:234-251):/kortix/ 前缀直接放行,其余全部走 verifyKortixUserContext。
5.2 这道闸门是什么
X-Kortix-User-Context 是一个极简的两段式签名:base64url(payload).base64url(HMAC-SHA256(payload, 沙箱凭据))。守护进程侧只有验证逻辑,纯函数、无 I/O(kortix-user-context.ts:39-68),校验三件事:签名(timingSafeEqual)、JSON 可解析、exp 未过期。
没有配凭据时不是"放行",而是 503。 这条很关键:
// apps/kortix-sandbox-agent-server/src/proxy.ts:238-241
if (!cfg.sandboxToken) {
logger.warn('[proxy] rejecting request: KORTIX_TOKEN not configured')
return c.json({ error: 'daemon not configured', detail: 'KORTIX_TOKEN unset' }, 503)
}
配置缺失默认拒绝,/kortix/health 还会把 auth: 'unconfigured' 明写出来(health.ts:136),不让错配静默降级成一扇敞开的门。__tests__/proxy-auth.test.ts:1165 专门锁住这条("never silently bypass"),:420 / :429 / :440 分别锁无头、坏签名、过期。
凭据名字本身有历史包袱:KORTIX_SANDBOX_TOKEN 是正名,KORTIX_TOKEN 是老镜像里的别名,loadConfig 用 ?? 兜底(config.ts:133),测试 :119 / :130 两条都覆盖。
5.3 五种"还没准备好"
反代的 catch-all 在真正 fetch 之前串着五道判断(proxy.ts:290-341),每一道都回 503 并带上机器可读的 reason:
repo_materialization_failed → 克隆彻底失败
repo_not_materialized → 开了 autoClone 但磁盘上还没仓库
initial_opencode_session_failed → 首个 session 创建失败
initial_opencode_session_pending → 需要首个 session 但还没建好
opencode not ready → 监管器状态 !== 'ok'
为什么要"提前 503"而不是直接 fetch? 注释说得很实在:OpenCode 还没绑定端口时去 fetch 只会得到一串 ECONNREFUSED 噪音日志,而客户端拿到的错误也说不清原因。提前判断把状态"翻译"成了可路由的信号——apps/api 那边就靠识别 opencode not ready 这个字符串决定是原样透传给前端还是继续重试(apps/api/src/sandbox-proxy/routes/preview.ts:1378-1398)。
5.4 SSE 与 WebSocket
- SSE:
Bun.serve的idleTimeout调到 255 秒(proxy.ts:410),因为 OpenCode 的事件流可以长时间无数据,默认 10 秒会把它掐了。请求体以ReadableStream透传,必须带duplex: 'half'(proxy.ts:355-362)。 - PTY WebSocket:Hono 处理不了 upgrade,所以在
Bun.serve的fetch里先拦截/pty/{id}/connect(proxy.ts:414-421),验签后再srv.upgrade。桥接的细节是先排队再发送:上游还没 open 时把消息推进state.queue,open 后一次性冲掉(proxy.ts:450-457)。它还会尝试向 OpenCode 换一张一次性 ticket,404 就优雅回落到直连(proxy.ts:151-180)。四条测试覆盖了桥接、ticket、query 里带签名(给 Platinum 边缘用)、以及温快照恢复后用重载过的凭据(__tests__/proxy-pty-ws.test.ts:159/177/197/220)。
5.5 为什么文件读取不转给 OpenCode
proxy.ts:268-278 的注释交代得很清楚:OpenCode 的 /file/content 是编辑器取向的,只对图片做 base64 内联,其他二进制(Office 文档、PDF、压缩包、sqlite)一律返回 { type:"binary", content:"" }——预览和下载全是 0 字节。所以守护进程接管了整个文件 API,直接从磁盘发(routes/files.ts:11-25)。同理 /find 也收了回来,用 git ls-files(尊重 .gitignore)+ rg --json,并在老镜像上回落到 Node 遍历(routes/find.ts:10-19)。
/presentation 更特别:它故意做成异步的。上游 apps/api 的预览代理给每次尝试只有 15 秒,而一份多页 PPTX 渲染远超这个数——同步接口会超时 502,而且每次重试都重跑一遍转换。于是改成后台跑、客户端轮询:生成中回 202,好了回 200 带文件(routes/presentation.ts:11-25)。