跳到主要内容

数据截至 (上游 commit e55b2a12c9a5)

沙箱内部:守护进程、OpenCode 与内容寻址的镜像

30 秒导读: 每个 session 都有一台一次性的 Linux 盒子。盒子里 Kortix 自己的进程只有一个——kortix-agent 守护进程。它开机、克隆仓库、拉起 OpenCode(真正干活的 agent 运行时)、把 8000 端口反代出去,并顺手当静态站和文件服务。这台盒子的镜像不是"某次构建的产物",而是一串内容哈希:输入不变就复用,输入变了才重建。

本章讲数据面:盒子里到底跑着什么、怎么开机、外面怎么进来、镜像怎么来。 不讲"谁在什么时候要一台盒子"(见 02-session-lifecycle),也不讲盒子里 agent 调的外部工具与模型(见 05-executor-connectors06-llm-gateway-and-metering)。


1. 这是什么(零基础也能懂)

一句话定义: 沙箱 = 一台按 session 分配的一次性 Linux 盒子,里面由一个叫 kortix-agent 的守护进程当"物业",OpenCode 当"干活的租客"。

它解决什么问题。 你让 AI 改一个真实项目的代码,它需要一个能随便折腾的地方:能 rm -rf、能装依赖、能起 dev server、能开浏览器。云端 API 进程里干不了这些。于是每个 session 拿一台真机(microVM),用完丢弃。

盒子里跑三样东西:

进程端口干什么谁启动它
kortix-agent(守护进程)8000反代 + 控制面 + 文件/搜索 API容器 ENTRYPOINT
opencode serve4096(仅 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 }]
}

注意 daemonruntimeReady两件事。守护进程活着不代表这台盒子能用——第 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 / 探就绪 / 崩溃重启 / drainsrc/opencode.ts:1573 createOpencodeSupervisor
反向代理HMAC 闸门 + 路由 + SSE/WS 透传src/proxy.ts:400 startProxy
静态站发 HTML 并注入 <base>src/static-web.ts:494 startStaticWebServer
健康合成 runtimeReadysrc/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

主线走一遍(高层):

  1. 控制面创建盒子 → 容器以 kortix-entrypoint 起来 → exec 守护进程。
  2. 守护进程按次序开机:静态站 → git 身份 → 克隆仓库 → 解析 OpenCode 配置目录 → 拉起 OpenCode → 起反代。
  3. 外面的请求经 apps/api 的预览代理进入 8000,验签后要么被守护进程自己答掉,要么反代给 OpenCode。
  4. 关机信号来时,反代先停、静态站再停、最后 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_portnull,守护进程照常活着(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,resolveSandboxOnBootconfig.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/envAuthorization: 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.serveidleTimeout 调到 255 秒(proxy.ts:410),因为 OpenCode 的事件流可以长时间无数据,默认 10 秒会把它掐了。请求体以 ReadableStream 透传,必须带 duplex: 'half'(proxy.ts:355-362)。
  • PTY WebSocket:Hono 处理不了 upgrade,所以在 Bun.servefetch 里先拦截 /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)。


6. 静态站:一个 <base> 标签解决的问题

它要解决的小问题: agent 写了 /workspace/site/index.html,里面引用了 ./style.css。用户从 https://api.kortix.cloud/v1/p/<id>/3211/open?path=… 打开它——浏览器会把 ./style.css 解析成 API 域名下的路径,404

思路: 服务端在 HTML 的 <head> 里插一个 <base href="…/abs/workspace/site/">,浏览器此后所有相对 URL 都以它为基准。agent 的 HTML 一个字都不用改

难点在于:那个 base URL 是什么? 服务端看到的 req.urlhttp://127.0.0.1:3211 或内网地址,浏览器根本够不着。所以代理链把公网地址塞进头里,resolvePublicBaseUrl(static-web.ts:231-255)按优先级取:

形态场景
X-Forwarded-Prefix完整 URL(https://api…/v1/p/<id>/3211)Kortix 自己的代理约定
X-Forwarded-Prefix纯路径前缀标准约定,配合下面两个头拼
X-Forwarded-Proto / -Host子域路由
都没有用 Bun 看到的curl / 本地调试

上游那一侧就是 forwardToSandbox 里的这一行:

// apps/api/src/sandbox-proxy/routes/preview.ts:1272
headers.set('X-Forwarded-Prefix', `${resolvedOrigin}${redirectPrefix}`)

注意 resolvedOrigin 的协议是算出来的而不是写死 https(路径式侧 preview.ts:1783-1788,origin 式侧 preview-origin.ts:468-473)——本地 dev 是 http,写死 https 会让注入的 <base> 指向 https,每个相对资源都 ERR_SSL_PROTOCOL_ERROR

两条附带的细节:

  • 注入 <base>破坏页内锚点——<a href="#work"> 会变成整页跳转。所以同时注入一小段 JS 拦截 hash 链接改为平滑滚动(static-web.ts:273)。
  • 路径白名单收窄到两个根:/workspace /tmp(static-web.ts:45),其余 403。/home/opt 是刻意拿掉的——/home 下躺着 session 凭据(LLM 网关 key、连接器 PAT、Codex 订阅凭据),而 3211 这个监听口自己没有鉴权,留着就等于谁够得着端口谁就能读(static-web.ts:33-42 的注释)。

端口 3211 是硬契约,不是默认值:config.ts:27-30 的注释点名了三处必须同步的地方(前端的 STATIC_FILE_SERVERconstructHtmlPreviewUrl、starter 的 show 工具)。端到端 curl 测试锁住了绑定、两种 <base> 形态、MIME、目录自动索引、越界 403(__tests__/static-web-curl.e2e.test.ts:70-154)。


7. 就绪判断:永远问 provider,永远别信 DB 行

这是本章的核心论点,两个层面各出现一次。

7.1 沙箱内:health 读 /etc/pt-env 而不是 process.env

runtimeReady 是五个条件的合取(health.ts:102-107):

const runtimeReady =
repoReady &&
!bootState.repoMaterializationError &&
!initialSessionError &&
opencodeState === 'ok' &&
initialSessionReady

其中 repoReady 不只要求"磁盘上有仓库",还要求在正确的分支上。原因写在注释里(health.ts:89-96):克隆路径先把仓库改名到位、切分支,而切分支可能要等远端 fetch 好几秒。少了分支这道闸,曾经出现过 runtimeReady=true 时仓库还在 main、三秒后才切到 session 分支——那三秒里落下的 prompt 会写到错误的分支上。

更微妙的是这两个判断从哪里读。温快照分叉出来的盒子,恢复的是一个进程环境早于 session 的进程;process.env 里根本没有这次 session 的分支名和 KORTIX_PROJECT_AUTO_CLONE。所以它们直接读宿主写进 guest 的文件:

// apps/kortix-sandbox-agent-server/src/routes/health.ts:16-22 wantedSessionBranch
const m = readFileSync('/etc/pt-env', 'utf8').match(/^KORTIX_BRANCH_NAME=(\S+)/m)
if (m?.[1]) return m[1]

sessionWantsRepo(health.ts:33-40)同理。注释记着两次线上事故:分叉后 ~100ms 就报 ready、而收养流程其实还在拉仓库,前端一拥而上打进半初始化的运行时。

7.2 控制面:镜像是否存在,问 provider

同一条原则在镜像侧的措辞更直白(builder.ts:11-13):

The boot path never trusts a DB row to decide "does this image exist?" — it asks the provider every time. The DB row is a cache + audit log only.

实现上有一条务实的例外:当 DB 行记录的正是这次算出来的哈希与名字、且状态为 active 时,走"信任行"的快路径(builder.ts:409-427),因为 Daytona 的 snapshot.get 是一次公网调用、高负载下能飙到好几秒,而它落在每次暖启动的关键路径上。这条捷径的安全网是别处的自愈:一旦创建时报"snapshot not found",重建再试一次。


8. 秘密怎么到达 agent 的 shell

问题: agent 在盒子里跑 bash -c 'curl -H "Authorization: Bearer $STRIPE_KEY" …',这个 $STRIPE_KEY 从哪来?

答案:一个 tmpfs 上的 0600 shell 文件,三个入口 source 它。

┌── BASH_ENV(守护进程 spawn opencode 时设)
/dev/shm/kortix/ │ → OpenCode 起的每个非交互 bash -c
agent-env.sh ──┼── /etc/profile.d/kortix-agent-env.sh(镜像烘焙)
(0600, tmpfs) │ → 登录 shell
└── /etc/bash.bashrc(镜像烘焙)
→ 交互式 shell / 用户开的终端

镜像那两行是写死的(apps/sandbox/Dockerfile:236-239),注释点明了用意:用户改自己的 OpenCode 配置也删不掉这条投递通道。

/dev/shm 是因为它是 RAM 盘:Daytona 的 hibernate/archive 保留磁盘,不会捕获 tmpfs,明文秘密因此永远不落到持久化的容器盘上(agent-env-file.ts:9-14)。守护进程还会主动核对这一点——agentEnvDirIsTmpfs()/proc/mounts,不是 tmpfs 就在启动时报 error(main.ts:118-120)。

渲染这个文件时有一层纵深防御:即便 API 侧已经过滤过,守护进程仍然自己再滤一遍(agent-env-file.ts:51-61 isUnsafeName),拒绝一切会劫持加载器或 shell 的名字:

类别例子
显式黑名单PATH HOME LD_PRELOAD BASH_ENV IFS PROMPT_COMMAND SHELLOPTS
前缀黑名单KORTIX_ OPENCODE_ LD_ DYLD_ BASH_FUNC_
值的约束含 NUL 字节、或超过 128KB,直接跳过(否则 source 会对每个 shell 都失败)

唯一的例外是一份显式白名单 SHELL_SESSION_CREDS(agent-env-file.ts:40-49)——session 自己的身份凭据(KORTIX_CLI_TOKENKORTIX_PROJECT_ID 等)。它们绕过 KORTIX_ 前缀过滤,因为免重启热替换的场景下 OpenCode 进程环境里根本没有这些值(见 11.4),这个文件是唯一能让 git push / CLI 工作的通道。

热更新走 /kortix/env 它是服务端到服务端的控制端点:验的是 Authorization: Bearer <沙箱凭据>,并且明确拒绝任何带 X-Kortix-User-Context 的请求(routes/env.ts:139-142)——带这个头就说明它是从用户可达的代理进来的。上游那边还堵了第二道:apps/api 的预览代理对 8000 端口上的 /kortix/env 直接返回 404(preview.ts:390-392),防止账号成员通过 /v1/p/<id>/8000/kortix/env 往沙箱注入任意环境变量。

只有 refreshModels === true 且确实有变化时才重启 OpenCode(routes/env.ts:217-264);其余情况只重写文件,agent 的下一条命令就拿到新值。测试 __tests__/env-sync-curl.e2e.test.ts:79 用真实 curl 验证了"不重启也生效",proxy-auth.test.ts:1739 验证了"值没变就不重启"。

关机时 shredAgentEnvFile(agent-env-file.ts:176-193)先用随机字节原地覆写、再 unlink,连 atomicWrite 留下的临时文件一起清。


9. 外面怎么进来:两条路由,一条反向隧道

9.1 两条入口路由

形态URL特点
路径式/v1/p/{sandboxId}/{port}/*走 Hono + combinedAuth,重定向要加前缀
子域式p{port}-{sandboxId}.{apiHost}/*Bun.serve 层解析 Host,根挂载,重定向天然是根相对

子域式的存在是为了让 Next/Vite 这类根挂载的应用不被路径前缀改写搞坏(preview-origin.ts:4-10:应用看到自己就在 /,根相对链接、fetch('/api')pushState、CSS url()、service worker、WebSocket 全部落在同源)。它的鉴权模型已从"首请求认证 + 内存 TTL"换成"一次性 ?token= + 签名 cookie"(preview-origin.ts:12-22 的文件头注释):首个请求带 ?token= / ?public_share= 认证成功后,现场铸一枚签名 cookie(preview-session.ts),之后子资源、重定向、WS 握手全部凭 cookie 畅通——而且一次签两枚:iframe 里是三方上下文,发 Partitioned(CHIPS)那枚;顶层标签页发未分区那枚(preview-session.ts:20-28)。用完的 ?token 会在转发前被剥掉(CREDENTIAL_PARAMS,preview-origin.ts:121-122,剥除在 :410),沙箱里的应用永远看不到它。

两条路由最终汇到同一个 forwardToSandbox(preview.ts:333)。它做的事按顺序是:

  1. 一次行读取拿到沙箱记录 + 服务密钥(backend.ts:152 loadSandbox)。
  2. 所有权校验;8000 端口额外加一道 session 可见性校验(preview.ts:374-385)——守护进程端口承载着会话内容和同步过的秘密,只验账号成员资格不够。
  3. 组装上游头:服务密钥、Daytona 预览 token、以及签好名的 X-Kortix-User-Context(backend.ts:289-306)。
  4. 重写 Origin / x-forwarded-host 为上游 origin(preview.ts:508-512)。这是让代理对任何框架都"透明"的关键:浏览器的 Origin 是公网代理域名,上游看到的却是 Daytona 代理域名,Next.js Server Actions / SvelteKit / Django CSRF 会把这个不一致判成攻击。
  5. 总预算的重试:PROXY_RETRY_BUDGET_MS = 50_000,单次尝试上限 15 秒且会被剩余预算压缩(preview-retry-budget.ts:7-62)。50 秒是从 AWS ALB 的 60 秒空闲超时倒推的——必须在负载均衡器掐断之前把自家那张友好的"端口不可达"页面吐出去,否则用户看到的是 Cloudflare 的裸 502。

重试延迟是 [250, 1000, 3000],注释记着为什么不是从前的 [2000, 5000, 8000](preview.ts:408-412):温快照恢复后 virtio-net 偶发漏掉第一个 RX 中断,大约 1 秒的抖动被旧的延迟表放大成了浏览器里肉眼可见的几秒卡顿。

还有一条克制的规则:只有 provider 确认盒子已停/已归档,才把 DB 行标成 error(preview.ts:416-420)。一次瞬时不可达绝不能让一台守护进程健康的盒子被标成失败——这正是 7.1 那条原则的另一面。

9.2 WebSocket 边:两家 provider 走不同的路

Daytona: 浏览器 ──WS──▶ apps/api ──WS──▶ 沙箱 :4096 (opencode 直连)
Platinum: 浏览器 ──WS──▶ apps/api ──WS──▶ 沙箱 :8000 (守护进程桥) ──▶ :4096

原因写在 ws-proxy.ts:16-22:Daytona 能直接暴露 4096;Platinum 不能——OpenCode 绑在 loopback 上,直接公开暴露会绕过守护进程那道签名闸门。所以 Platinum 的 PTY upgrade 一律穿守护进程(preview.ts:716-721),也就是 5.4 那座桥。

9.3 tunnel:方向是反的

apps/api/src/tunnel/ 常被误当成"外部进沙箱"的通道,其实方向相反:它是一条反向隧道,让云端沙箱去访问用户本机的资源。用户机器上的 agent 通过 WebSocket 连到 API 的 relay,沙箱侧则以 HTTP RPC 打进来:

沙箱 ──HTTP POST /v1/tunnel/rpc/{tunnelId}──▶ apps/api relay ──WS──▶ 用户本机 agent

startTunnelService(apps/api/src/tunnel/index.ts:191)启动心跳管理器与跨实例转发器,并在 agent:connect 事件上把该隧道当前有效的权限同步给本机 agent(index.ts:142-161)。每一次 RPC 都要过权限检查(core/permission-checker.ts)、作用域校验(core/scope-validator.ts)、限流(core/rate-limiter.ts),并写审计日志(core/audit-logger.ts:37 startAuditLog)。403 返回的不是普通错误,而是一个带 requestId 的**"需要授权"信封**,前端据此弹出实时审批流(routes/rpc.ts:37)。

因为多副本部署下 WS 连接只挂在某一个实例上,还需要一张 tunnel_rpc_forwards 表做跨实例转发,并用 relayOwnerId + 心跳窗口判断连接是否仍然活着(core/cluster-forwarder.ts:23-38)。


10. 镜像怎么来:内容寻址

10.1 分层:用户的 Dockerfile + Kortix 运行时层

一个 session 镜像 = 用户 Dockerfile 原样保留 + 追加的 Kortix 运行时层(dockerfile-layer.ts:122 buildLayeredDockerfile)。运行时层大致装这些:

  • 系统包:git curl nodejs npm tmux ripgrep + LibreOffice/LaTeX/tesseract 等文档栈 + iproute2 / iputils-arping
  • Python 一层"地板包"(pandas / openpyxl / python-pptx / pymupdf …),给 starter 的技能用。
  • opencode-ai@<pinned>、bun、agent-browser + 一个真正的 Chromium。
  • COPY 进来的 kortix-agentkortix CLI、entrypoint、slack-cli、executor-sdk。
  • ENV KORTIX_WORKSPACE=/workspaceEXPOSE 8000ENTRYPOINT ["/usr/local/bin/kortix-entrypoint"]

iproute2 那条尤其非显然:Platinum 上快照恢复的 VM 会保留快照里烘焙的 IP,直到宿主的 reconfigure_net 在 guest 内跑 ip addr flush/add 加一次免费 arping;缺了这两个包,IP 永远不变、边缘却路由到新分配的 IP——每个请求 502(dockerfile-layer.ts:152-156)。

项目源码不进镜像(dockerfile-layer.ts:14-17)。仓库由守护进程在启动时克隆。这是整套缓存策略的前提:代码改动永不作废镜像,于是绝大多数项目共享同一个全局默认镜像。

10.2 身份:一串 SHA-256 的前 12 位

// apps/api/src/snapshots/hash.ts:76-90(节选)
const blob = [
`dockerfile=${inputs.dockerfile.length}:${inputs.dockerfile}`,
`tree_oid=${inputs.contextTreeOid}`,
`runtime=${runtimeFingerprint}`,
]
if (specSegment) blob.push(`spec=${specSegment}`)
const contentHash = createHash('sha256').update(blob.join('\n')).digest('hex')
return { contentHash, shortHash: contentHash.slice(0, 12), runtimeFingerprint }

四项输入、长度前缀防拼接碰撞、取前 12 位当快照名。名字前缀区分共享默认与项目模板:kortix-default-<hash12> / kortix-tpl-<hash12>(templates.ts:438-440)。

关于 hardware spec 那一段的小心思: 只有真的设了 cpu/memory/disk 才把这段拼进摘要(hash.ts:84-85)。这样"没声明规格"的项目在这个字段上线前后哈希完全一致,不会触发一次全量重建。

10.3 git tree OID 这件事,得说清楚

hash.ts:13-16 把第二项输入描述得很漂亮——用 git 自己对构建上下文的内容寻址哈希,COPY ./scripts/setup.sh 这类依赖白送精确的缓存失效。这是这套设计的正确直觉:git 的 tree OID 本来就是"这棵目录树的内容指纹",拿它当哈希输入,等于免费获得了对上下文里每一个文件的敏感性。

当前实际接线不是这样computeTemplateIdentity 传进去的是一个常量字符串:

// apps/api/src/snapshots/templates.ts:601
contextTreeOid: template.isShared ? 'platform-default' : `template:${template.slug}`,

全仓库搜索 contextTreeOid,除了 hash.ts 的定义与单测的固定样例,只有这一处赋值。也就是说:这一项在今天是逐模板的常量,不随内容变化。真正承担"内容变了就换名字"职责的是第三项 runtimeFingerprint —— buildRuntimeArtifactFingerprint(packages/shared/src/sandbox-runtime-artifact.ts:109;snapshots/runtime-fingerprint.ts 现在只是从 @kortix/shared re-export 的转口)对一组运行时构件做逐字节递归遍历:目录名、文件大小、文件内容、符号链接目标全部喂进同一个 SHA-256,并按 label 排序保证确定性。用户 Dockerfile 的内容则由第一项直接覆盖。

所以准确的说法是:COPY 级别的缓存失效确实存在,但它今天由 runtime fingerprint 的字节遍历提供,而不是由 git tree OID 提供;tree OID 是这套哈希预留好的、语义上更对的位置。

10.4 指纹拆成两份,换出一条"只换 agent"的快路

指纹被拆成 agent 与非 agent 两组构件(templates.ts:904-916):

包含
agentapps/kortix-sandbox-agent-server/src 全树 + 它的 package.json
非 agententrypoint、slack-cli、executor-sdk、CLI 源码与 package.json、manifest-schema,外加 SANDBOX_VERSION / RUNTIME_LAYER_VERSION / OPENCODE_VERSION / AGENT_BROWSER_VERSION 四个常量

两组一起算出 contentHash(镜像身份),只用非 agent 那组算出 swapKey(templates.ts:436-437)。于是有了这条判断:

新身份的 swapKey == 前任存储的 swapKey ⟺ 两者的唯一差别就是 agent 二进制。

此时 maybeSwapAgent(builder.ts:580)不做完整重建,而是让 provider 把新的 agent 二进制 debugfs 换进前任的 rootfs——秒级、只花一个二进制的 CAS 块。任何一点不确定(provider 不支持、swapKey 为 null、前任不可用、换出过程抛错)都回落到完整重建,并且会先把半创建的同名行清掉,免得名字冲突 409 挡住回落(builder.ts:607-610)。

指纹的缓存键是版本常量而不是源码 mtime(templates.ts:748-756)。按 mtime 缓存会让本地每存一次盘、每切一次分支都触发一次 ~30MB 的树遍历,而那正好落在 session 启动的热路径上。

配套还有一个"防歪打正着"的守卫:构建上下文暂存时会比较 dist/kortix-agentsrc/ 的 mtime,源码更新则直接报错(build-context.ts:730-750)。注释记着 2026-06-10 那次事故:改过的 src 配上过期的 dist,结果是"用新哈希发布了旧代码"——比构建失败更糟。

10.5 把慢活全部提前到构建期

镜像里烘焙了一批只为省启动时间的东西:

烘焙的东西省掉的启动开销出处
跑一次 opencode serve 完成 sqlite 一次性迁移每次冷启动 15–35 秒dockerfile-layer.ts:222-238
用真实 starter 配置预热一个 opencode 项目实例6–60 秒 → 2–4 秒dockerfile-layer.ts:284-315
装好配置目录依赖到 /opt/kortix/opencode-config-deps那次联网 bun installdockerfile-layer.ts:265-268
Chromium(经 Playwright,跨架构)运行时下载浏览器dockerfile-layer.ts:335-350
完整模型目录 /opt/kortix/llm-catalog.json无凭据时退化成 ~13 个模型的兜底dockerfile-layer.ts:360
裸仓库 scaffold.git见下dockerfile-layer.ts:364

@opencode-ai/plugin 的版本钉法值得单独说:它必须钉成 OpenCode 二进制的版本,而不是 starter package.json 里写的版本(dockerfile-layer.ts:256-264)。因为 OpenCode 只认与自己二进制匹配的插件 SDK——烘焙了不匹配的版本,它每次启动都联网重新拉一遍,那就是曾经 5–8 秒的 opencode-session-created 空档。

scaffold.git 是个漂亮的技巧:构建上下文里现场用固定的提交时间与作者跑一遍 git init && commit && clone --bare(build-context.ts:1041-1065),使这个骨架仓库的根提交 SHA 与每个 seed 出来的项目相同。于是守护进程可以先本地克隆(~50ms)、再只 fetch 项目相对这个共享根的增量,而不是走缓慢的 git 通道拉整个仓库(dev 隧道下实测 9 秒,git.ts:625-633)。导入的仓库没有共同祖先,自动退化成完整 fetch,同一段代码兼容两种情况。

还有一条跨 provider 的可移植性守卫:同一份组装好的上下文要同时发给 Daytona(BuildKit)和 Platinum(buildah 的经典 imagebuilder)。后者既不认 # syntax= 也不认 RUN heredoc——它会把 heredoc 正文的第一行当成 Dockerfile 指令。所以暂存时直接扫一遍,发现 heredoc 就在源头抛错(build-context.ts:903-914),而不是几分钟后收一个远端的天书失败。这正是为什么 Python 校验写成了 python3 -c '…' 单行式而不是 heredoc。

10.6 缓存判定的四层顺序

ensureSandboxImage(project, {slug, provider, source})

├─① 行完全吻合(同 provider + active + 同 hash + 同名) ─────▶ 直接用,零网络

├─② 问 provider:这个名字 active 吗? ────────────────────▶ 用,并回写行

├─③ 身份漂移了,但**前任还能用**,且 source=session-start
│ ──▶ 立刻用前任启动,后台重建新身份(去重:一个名字一次)

└─④ 内联构建(按 provider+名字 去重)
├─ 先试 agent-only 换出(10.4)
└─ 否则 buildSnapshot,成功后**删掉前任**

第 ③ 层是"session 永不为一次完整镜像构建买单"的兜底(builder.ts:454-495)。预构建、手动构建、CR 合并触发的构建跳过它,因为它们的职责本来就是把新镜像提前做出来。

第 ④ 层末尾的"删掉前任"是硬性不变量(builder.ts:682-707):每次 agent 源码变化都会漏掉一整个 ~8GB 的 rootfs 模板,注释记着修复前观察到的 7 份陈旧副本 = 56GB。

kickStartupPreBuild()(builder.ts:1883)在每个 API 进程启动时幂等地打一次平台默认镜像,让发布之后落地的第一个 session 就命中缓存。它的门禁是"当前默认 provider 是否配置好",而不是写死 daytona——纯 Platinum 部署曾因此跳过预构建。

10.7 构建失败怎么回流成"用 agent 修"

每次构建尝试都写进追加表 project_snapshot_builds(builder.ts:1019 openBuildLog),终态时关行。失败的错误串会过一遍启发式分类器:

// apps/api/src/snapshots/error-classify.ts:88-95
export function classifySnapshotError(raw) {
for (const rule of RULES) if (rule.test.test(message)) return rule.category
return 'unknown'
}

规则顺序敏感,最具体的在前:quota 必须排在 provider 前面(配额消息里通常也带 daytona/snapshot 字样)。每个类别配一段静态元信息,其中最关键的字段是 fixableByAgent(error-classify.ts:117-163):

类别能不能让 agent 修
dockerfile✅ 改仓库里的 Dockerfile,开一个 change request
git✅ 仓库侧配置
unknown✅(丢给 agent 看一眼)
quota / tunnel / provider / timeout / runtime❌ 基础设施,用户重试

这就是"构建失败回流成 agent 任务"的接缝:UI 拿到 fixableByAgent: true 就能把这次失败连同错误正文送进一个新 session,让 agent 去改 Dockerfile 并按 04-git-and-change-requests 的闸门提 CR。

进程重启会把构建行孤儿在 building 状态,reconcileStaleBuilds(builder.ts:938)拿 20 分钟的截止时间去 provider 复核后补上终态。

10.8 三把扫帚

扫帚扫什么判据
reconcileSnapshotQuota(quota-gc.ts:141)provider 上被顶替的模板快照名字在 kortix-default- / kortix-tpl- / kortix-wproj- 三个命名空间内(:40)、无本地行引用、且闲置 ≥7 天;并且只在命名空间超过 60 个时才开始动手(:42)
startTmpReaper(tmp-reaper.ts:70)API 容器本地的 kortix-* 暂存目录mtime 超过 30 分钟。观测到单 pod 泄漏 ~20GB,直到 kubelet DiskPressure 把 pod 驱逐
前任剪枝(builder.ts:695)上一代同模板快照新身份构建成功即删

lastUsedAt 在配额 GC 里承担了一个额外角色:多套环境(笔记本 / dev / prod)共用同一个 provider 组织但各有各的数据库,本地行查不到别人的引用——"最近被用过"是跨环境的护栏。即便真误删了,下一个 session 会撞上"快照不存在"的自愈重建,代价只是一次慢启动。


11. 温快照:一台盒子被冻住的时候

这一节解释镜像之上的第二层缓存:把一台已经开好机的盒子整台冻起来,之后按需 CoW 分叉。

11.1 冻结的触发条件是一个文件

// apps/api/src/snapshots/builder.ts:322-338(节选)
capture: template.isShared ? 'stateful' : 'none',
captureCondition: { cmd: 'test -f /var/run/kortix/opencode-session-id', timeoutSec: 300 },
captureEnv: { KORTIX_WARM_SEED: '1', KORTIX_LLM_HOTSWAP: '1',
KORTIX_LLM_CATALOG_FILE: '/opt/kortix/llm-catalog.json',},

宿主不是"等 HTTP 通了就拍照",而是等一个 pin 文件出现。守护进程在 KORTIX_WARM_SEED=1 时进入 runWarmSeedMode(main.ts:422):物化骨架仓库 → 起 OpenCode → 反复等到 /session 真的 2xx → 预建一个 root 会话 → 写 pin 文件。因此快照里冻住的 OpenCode 是真的热的,不是刚绑上端口的。

那个等待循环的上限是 5 分钟,而且是反复重试而不是一次性等待(main.ts:507-510)。注释记着为什么:曾经用单次 20 秒的等待,有一回差 4 秒没等到 OpenCode,种子就永远没写 pin,导致这个模板之后的每次捕获都撞满 240 秒预算而失败。

11.2 分叉之后的第一个麻烦:所有分叉共享同一个 root 会话 id

快照里只有一个 root 会话、一个 pin。每个分叉都继承同一个 id,而客户端完全按 opencode session id 索引会话状态——结果是"切到别的会话,看到的还是同一个对话"。

解法是一个一次性标记(opencode-fork-root.ts):种子在写 pin 之前先写 /var/run/kortix/opencode-seed-baked-id(顺序很重要——捕获以 pin 为条件,先写标记才能保证继承了 pin 的分叉一定也继承了标记,main.ts:246-252)。分叉启动时:

// apps/kortix-sandbox-agent-server/src/opencode-fork-root.ts:35-40
export function isSharedSeedBakedRoot(existingRootId, seedBakedId): boolean {
return !!existingRootId && !!seedBakedId && existingRootId === seedBakedId
}

命中则忽略继承来的 root、另建一个属于自己的,pin 上去,再退休标记(main.ts:664-704)。这样之后的守护进程重启会走正常的幂等复用路径。孤儿 root 顺手删掉,但删失败无所谓——pin 才是权威。五条单测把边界钉死了:命中轮换 / 是自己的就复用 / 没标记就复用 / 没有既有 root / 空串当作不存在(__tests__/opencode-fork-root.test.ts:11-32)。

11.3 收养:进程比 session 更早存在

分叉恢复的是种子那个进程,它的 process.env 里没有这次 session 的任何东西。宿主把真正的 session env 写进 guest 的 /etc/pt-env,守护进程用两个触发器去认领:SIGHUP,以及每 200–250ms 轮询一次这个文件(main.ts:310-315main.ts:621-626)。认领时按序做:重载 env → 重新 loadConfigserver.reload(cfg2) → 重配 git → 物化仓库 → 起 session 运行时。

server.reload 那一步不能省(proxy.ts:487-490):种子是拿"派生它的那个 session"的凭据启动的,那套凭据绝不能用来服务这个分叉。

11.4 免重启热替换:把凭据从配置里赶出去

原本的收养要杀掉并重启 OpenCode,只为了换两个 per-session 令牌——白白重付快照已经烘焙好的 ~8 秒初始化。因为 OpenCode 只在 spawn 时读一次 provider.options.apiKeymcp.environment

llm-proxy.ts 的解法是让烘焙的配置与 session 无关:

烘焙进配置的: baseURL = http://127.0.0.1:4319 apiKey = "kortix-llm-proxy-injected"(占位)
运行时发生的: setLlmProxyToken(真实令牌) → 进程内代理改写 Authorization 头后转发上游

代理失败关闭:上游或令牌任一为空就返回 503,绝不做开放中继(llm-proxy.ts:96-103)。idleTimeout: 0 是因为模型流可以跑几分钟。

热替换只在四个条件同时成立时启用:开了 hotswap、种子确实烘焙了代理式 provider、当前 OpenCode 健康、仓库干净物化(main.ts:584-590)。任一不满足就回落到 reconfigure + restart。这条路径也解释了第 8 节那份 SHELL_SESSION_CREDS 白名单存在的理由:OpenCode 进程没被重启,它的环境里还是种子那套空凭据,agent 的 shell 只能从 tmpfs 文件里拿到真身份。

11.5 幂等地交付首个 prompt

守护进程重启(比如装依赖时被 OOM killer 干掉后被运行时拉起)会重跑首个 session 的建立逻辑。旧版本无条件 POST 一个新 root 并重发整个 prompt,后果是前一个 root 被孤儿在半个 turn 上(一个永不完成的 bash[running]),任务跑两遍。现在 maybeCreateInitialOpencodeSession(main.ts:645)做三件事(注释 main.ts:629-644):

  1. 复用已有的规范 root(先看 pin,否则取最近活跃的 root)。
  2. 对复用的 root 上那个被打断的 turn 发一次 abort,让流正常收尾而不是永远转圈。
  3. prompt 最多交付一次——只发给还没有任何消息的 root。

其中"最近活跃"的挑选规则 pickMostRecentRoot(main.ts:855)刻意与服务端的 pickCanonicalRoot 保持一致,保证两侧收敛到同一个 root。而 waitForRootList(main.ts:822)坚持等到 OpenCode 给出确定答案再决策:把"启动慢"误判成"没有 root"就会创建重复 root——正是要消灭的那个 bug。


12. 巧妙之处(可以带走的技术)

  1. 让最不依赖别人的服务最先起。 静态站零依赖,所以排第一;它于是同时成为"agent 还没醒时的预览"和"仓库/OpenCode 挂掉时仍然活着的部分"。main.ts:72-77
  2. 探针要打业务接口,不要打端口。 进程能绑定端口但业务不可用,是分布式里最常见的假绿灯。opencode.ts:791-809
  3. 就绪之后立刻降频。 100ms 轮询在单机上看不出来,乘以每台空闲沙箱就是 55% 一核。opencode.ts:16-22
  4. 配置缺失默认拒绝,并把姿态写进 health。 auth: 'unconfigured' 让错配可观测,而不是静默变成一扇敞开的门。proxy.ts:238-241health.ts:136
  5. 读"宿主写的文件"而不是"自己的进程环境"。 快照恢复的进程,其 process.env 在语义上早于 session;/etc/pt-env 才是当下的真相。health.ts:16-40
  6. 超过 128KB 的配置必须落盘。 execveMAX_ARG_STRLEN 是硬限制,踩上去表现为"OpenCode 根本没起来"。opencode.ts:630-639
  7. 秘密放 tmpfs,并主动核对它真的是 tmpfs。 归档保留磁盘、不保留 RAM 盘;但"以为是 tmpfs"和"是 tmpfs"要用 /proc/mounts 验证。agent-env-file.ts:197-204
  8. 把指纹拆成两半,换出一条快路。 只有非 agent 部分逐字节相同,才允许只换 agent 二进制——"绝不发布陈旧镜像"这条不变量因此是可证明的。templates.ts:900-904builder.ts:580-612
  9. 把可移植性检查放在源头。 同一份上下文发给两种构建器,就在暂存时扫一遍 heredoc,而不是等几分钟后收远端天书。build-context.ts:903-914
  10. 重试要有总预算,而不只是次数。 50 秒是从负载均衡器 60 秒空闲超时倒推的——比它先返回,用户才看得到你自己的错误页。preview-retry-budget.ts:1-62

13. 边界与局限

  • README.md 已经落后于 config.ts README 写 KORTIX_PROJECT_TARGET=/workspace/.kortix(README.md:103),实际默认值已改成 /workspace,仓库的 Kortix 文件落在 <workspace>/.kortix/ 下(config.ts:42-45)。以源码为准。
  • contextTreeOid 名不副实。 它在文档里是 git tree OID,在代码里是逐模板常量(第 10.3 节)。今天的内容敏感性由 runtime fingerprint 承担。
  • 项目清单是用正则读的,不引 TOML/YAML 解析器。 守护进程按 format 走两套手写正则(extractNestedString,config.ts:231-261),只认 opencode.config_dirsandbox.on_boot规范写法;写法一偏就静默回落到默认值。注释里还留着一个真实教训:JS 没有 \Z 锚点,得拿 (?![\s\S]) 当串尾锚点(config.ts:238-239)。
  • 子域(origin 式)鉴权已不再依赖单进程内存。 旧的 authedSubdomains 单进程 Map 已随 subdomain.ts 一起删除,换成签名 cookie(preview-session.ts:10-18 的注释明说改动机:多 ECS task 部署下内存态会让第二个请求 401,且 IP+UA 键会让同 NAT 同浏览器的人继承授权)。cookie 绑定 (sandbox label, port),任意 task 都能验。
  • 路径式代理没有 WS。 WebSocket 走 Bun.serve 层的专用模块(ws-proxy.ts:1-14,?token= 鉴权);origin 式侧的 WS 已不再是待办——cookie 能活到第二次请求,包括 WS 握手(preview-origin.ts:16-20),upgrade 同样经 ws-proxy.ts 处理。
  • /kortix/git 是死代码。 注释直说产品没用它,保留为宿主驱动的原语(proxy.ts:211-213)。
  • reconcileStaleBuilds 不再硬编码 daytona。 构建日志行的 metadata 现在带 provider 候选,复核时逐个问过去(builder.ts:959 buildLogProviderCandidates,定义在 :839);任一 provider 说 active 就闭环为 ready,还在 building/unknown 就绝不动——宁可留着,也不把好构建标成失败(builder.ts:978-989)。
  • 本章不覆盖的: 谁在什么时机要求供给一台沙箱(见 02-session-lifecycle);agent 在盒子里调用的外部工具与凭据闸门(见 05-executor-connectors);模型请求出盒之后的路径与计费(见 06-llm-gateway-and-metering);git 与 change request 闸门(见 04-git-and-change-requests)。

14. 代码地图(导航索引)

沙箱侧守护进程apps/kortix-sandbox-agent-server/

主题文件符号
开机编排与时间线src/main.tsmainbootMark
首个 session 的幂等建立src/main.tsmaybeCreateInitialOpencodeSessionresolveExistingRootpickMostRecentRoot
温快照种子模式src/main.tsrunWarmSeedModearmSeedAdoptionreloadSessionEnv
分叉 root 去碰撞src/opencode-fork-root.tsisSharedSeedBakedRootOPENCODE_SEED_BAKED_PIN_PATH
环境契约与配置目录解析src/config.tsloadConfigresolveOpencodeConfigDirresolveSandboxOnBoot
OpenCode 监管src/opencode.tscreateOpencodeSupervisorspawnChildscheduleReadinessProbe
OpenCode 就绪探针src/opencode.tsprobeOpencodeSessionApiprobeOpencodeReadinesswaitForOpencodeReady
注入式 OpenCode 配置src/opencode.tsbuildOpencodeConfigContentwithModelLimitsloadGatewayCatalog
配置目录依赖离线满足src/opencode-config-deps.tsensureOpencodeConfigDeps
反代与闸门src/proxy.tsbuildOpencodeAppstartProxyprepareOpencodePtyWsUpgrade
签名验证src/kortix-user-context.tsverifyKortixUserContextKORTIX_USER_CONTEXT_HEADER
静态站src/static-web.tsstartStaticWebServerresolvePublicBaseUrlinjectBaseALLOWED_ROOTS
健康与 runtimeReadysrc/routes/health.tscreateHealthRouterwantedSessionBranchsessionWantsRepo
仓库快进src/routes/refresh.tscreateRefreshRouter
秘密热同步src/routes/env.tscreateEnvRouterapplyLlmGatewayMode
秘密投递文件src/agent-env-file.tswriteAgentEnvFileshredAgentEnvFileagentEnvDirIsTmpfsDANGEROUS_NAMES
项目秘密状态src/project-env.tscreateProjectEnvStoremergeProjectEnv
免重启凭据代理src/llm-proxy.tscreateCredentialProxyLLM_PROXY_PLACEHOLDER_KEY
OpenCode 事件订阅src/opencode-events.tsstartOpencodeEventLoopdispatchflattenOpencodeError
端口/网页/文件/搜索/导出src/routes/createPortProxyRouterwebProxyRoutercreateFilesRoutercreateFindRoutercreatePresentationRouter
git 凭据助手src/git.tsconfigureGitCredentialHelperconfigureRepoCredentialHelperrunGitCredentialHelper
关机 drainsrc/shutdown.tsinstallShutdownHandlers

外部到沙箱的通道apps/api/src/

主题文件符号
代理装配sandbox-proxy/index.tssandboxProxyAppresolveProvider
沙箱解析与上游头sandbox-proxy/backend.tsloadSandboxresolvePreviewLinkbuildSandboxUpstreamHeaders
转发主逻辑sandbox-proxy/routes/preview.tsforwardToSandboxshouldSyncProjectEnvBeforeProxyresolvePreviewWsUpstream
子域(origin 式)路由sandbox-proxy/preview-origin.tsresolvePreviewRequestisPreviewHost
统一凭据校验sandbox-proxy/preview-auth.tsauthenticatePreviewPrincipalextractPreviewToken
重试预算sandbox-proxy/preview-retry-budget.tsPROXY_RETRY_BUDGET_MSproxyAttemptTimeoutMs
WS 边sandbox-proxy/ws-proxy.tspreparePreviewWsUpgradepreviewWsHandlers
反向隧道tunnel/index.tstunnel/core/startTunnelServicetunnelRelayisTunnelConnectionLive

镜像apps/api/src/snapshots/ 与 Dockerfile

主题文件符号
基础沙箱镜像apps/sandbox/Dockerfile三阶段构建 + /etc/profile.d 钩子
PID 1apps/sandbox/entrypoint.sh/workspace 稳定探测 + cd /
项目自定义镜像.kortix/Dockerfile全套 dev box(Docker + Supabase CLI)
内容哈希snapshots/hash.tscomputeSnapshotHashSnapshotHashInputs
分层 Dockerfilesnapshots/dockerfile-layer.tsbuildLayeredDockerfileDEFAULT_SANDBOX_SLUGSandboxSpec
模板身份snapshots/templates.tscomputeTemplateIdentityresolveTemplateBySlugRUNTIME_LAYER_VERSION
运行时指纹packages/shared/src/sandbox-runtime-artifact.ts(api 侧 snapshots/runtime-fingerprint.ts 仅为 re-export)buildRuntimeArtifactFingerprint
构建编排snapshots/builder.tsensureSandboxImagemaybeSwapAgentkickStartupPreBuildreconcileStaleBuilds
上下文暂存snapshots/build-context.tsstageBuildContextstageScaffoldRepostageAgentBinaryGz
错误分类snapshots/error-classify.tsclassifySnapshotErrordescribeSnapshotError
温快照snapshots/ppwarm-names.tsbuilder.ts(ensurePerProjectWarmImage)perProjectWarmImageNamewarmBuildSlugperProjectWarmEligible
清扫snapshots/quota-gc.tstmp-reaper.tsreconcileSnapshotQuotastartTmpReaper

行为佐证(测试)apps/kortix-sandbox-agent-server/src/__tests__/

测试锁住的行为
proxy-auth.test.ts闸门的全部拒绝路径、health 免鉴权、四种 not-ready、env 同步的重启/不重启
proxy-pty-ws.test.tsPTY 桥接、ticket、query 签名、恢复后用重载凭据
static-web-curl.e2e.test.ts真实 curl 下的 <base> 注入、MIME、目录索引、越界 403
env-sync-curl.e2e.test.ts不重启沙箱即完成 env 同步
opencode-config-deps.test.ts链接烘焙依赖 / 无依赖 no-op / 已满足不动
opencode-fork-root.test.ts分叉 root 轮换的五种边界
resolve-config-dir.test.ts未克隆时回落默认、.jsonc/.json 双认、自定义 config_dir