From 184facaef33f4f99180bf8543f362054c0d08e82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Sun, 16 Aug 2026 11:27:17 -0700 Subject: [PATCH 1/8] =?UTF-8?q?fix(sandbox):=20=E5=8E=BB=E6=8E=89=E6=97=A0?= =?UTF-8?q?=E9=A3=9E=E4=B9=A6=E9=80=9A=E9=81=93=E4=BC=9A=E8=AF=9D=E7=9A=84?= =?UTF-8?q?=E5=BC=BA=E5=88=B6=E6=96=87=E4=BB=B6=E8=AF=BB=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=EF=BC=8C=E6=94=B9=E4=B8=BA=E8=B7=9F=E9=9A=8F=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=20sandbox=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改了什么 forkWorker 装配 worker init 时,readIsolation 表达式此前把「会话没有飞书 transport 通道」当作强制隔离条件: readIsolation: botCfg.readIsolation === true || !larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }) 即只要是 apiOnly bot 或 HTTP virtual(http_async_/http_wait_)会话,无论 owner 有没有配 sandbox,都被强制文件读隔离,owner 无法关闭。现改为: readIsolation: botCfg.readIsolation === true readIsolation 变为 opt-in only,只由显式 per-bot readIsolation 驱动,与普通 聊天会话对称(unset/false → 不隔离)。紧邻注释同步重写为如实描述新语义。 ## 为什么 原设计把这条强制当作凭证 fail-closed 边界(让这类会话读不到含所有兄弟 bot secret 的 bots.json)。但磁盘可读范围应由 owner 自己的 sandbox/readIsolation 配置决定,不该在 no-transport 逻辑里写死强制;单 bot 部署或载荷可信时这条强制 纯属束缚。多 bot 同机的横向读取风险改由 owner 显式配 sandbox 来防。 ## 被接受的取舍(安全边界变化) no-transport 会话默认不再隔离:没配 sandbox 时其 CLI 能以同一 OS 用户身份直接 读宿主 bots.json(含各兄弟 bot 的 app secret)。兄弟凭证的横向读取防护改为依赖 owner 显式开 sandbox / readIsolation / BOTMUX_SANDBOX=1。 两条正交边界原样保留,是放宽后剩下的安全网: 1. 本 bot 自身 transport secret 的 env 扣留(LARK_APP_SECRET/larkAppSecret 仍 gated on larkTransportEnabled)—— no-transport 会话即使能读磁盘 bots.json, 也拿不到注入进程 env 的本 bot secret,Botmux 自身发送链路仍关闭。 2. device-credential 强制隔离(worker.ts credentialIsolationRequired)—— enrolled 设备上独立强制,与文件沙盒 toggle 无关。旧代码把 no-transport 强制 全沙盒会让 fullIsolationCoversCredentials=true 从而跳过 credential-only gate;放宽后无 sandbox 的 no-transport 会话让该 gate 在 enrolled 机器上真正 engage,是正确的 fail-closed 方向。 ## 安全不变量审查(全仓消费点) grep larkTransportEnabled / readIsolation / apiOnly 全部消费点,确认没有别处把 「no-transport ⟹ 已隔离 ⟹ 读不到兄弟凭证」当隐含前提而放松其它检查: - fs-policy 的 !larkTransport 凭证 deny 只在 sandboxRequested 分支内跑(无 sandbox 的 no-transport 会话干脆不建沙盒),语义正确。 - currentBotIsApiOnly 按运行时真实 underReadIsolation() 分派,不隔离时读 bots.json、隔离时读 env/send-cred,两路都给对的 apiOnly 结论。 - adoptSandboxBlocked 仍对 apiOnly/HTTP-virtual 一律拒 adopt(fail-safe 的过度 限制、非放松),本次不动 adopt 范围。 ## 影响面 forkWorker readIsolation 是全 CLI × pty/tmux 共用装配点,本改动是对 no-transport 这一支的严格放宽: - 普通聊天:不受影响(本就只看 botCfg.readIsolation)。 - apiOnly / HTTP virtual / A2A / core-only:从「强制隔离」变「跟随本地 sandbox」 (无配置→不隔离,与普通聊天对称)。 - adopt/restore:forkAdoptWorker 不自设 readIsolation,不受这行影响; device-credential 对 adopt 的拒绝(worker.ts)不变。 mac(Seatbelt)/Linux(bwrap) × pty/tmux × sandbox on/off 逐组合核对:沙盒实际建 与否只由 worker 侧 sandboxRequested 决定,本改动仅改变 readIsolation 是否进入该 或值,未新增平台/后端分叉。 ## 测试 - test/api-only-mode-wiring.test.ts:source-lock 同步到新表达式,并加 not.toContain 断言旧强制析取项已消失;env 扣留 source-lock 未动、保持绿。 - test/session-lifecycle-start.test.ts:新增 5 条行为测试(直接跑 forkWorker 读 init.readIsolation):apiOnly / http_wait_ / http_async_ 无 sandbox → false; no-transport + 显式 readIsolation:true → 仍 true;普通 chat → false。 - 定向 10 文件全绿(api-only-mode-wiring / api-only-transport-boundary / session-lifecycle-start / read-isolation / claude-read-isolation / codex-read-isolation / fs-policy / api-only-card-patch-suppression / dashboard-bot-payload / dashboard-ipc)= 440 pass / 1 skip。 - 反向变异自检:临时还原旧强制表达式 → 3 条 no-transport 行为测试 + source-lock 全变红,2 条控制用例(显式 opt-in / 普通 chat)保持绿,证明新测试精确锁住 no-transport 这一支;已还原。 - pnpm build 绿。 本 PR supersede 未合并的 PR #857(apiTaskFullAccess 例外口):本改动直接去掉强制, 例外口不再需要,建议关闭 #857。基线从 master 出,未基于 #857。 --- .../2026-07-30-api-only-core-only-bot-mode.md | 10 +++- docs/file-sandbox.md | 8 ++++ src/core/worker-pool.ts | 27 +++++++---- test/api-only-mode-wiring.test.ts | 18 ++++--- test/session-lifecycle-start.test.ts | 47 +++++++++++++++++++ 5 files changed, 93 insertions(+), 17 deletions(-) diff --git a/docs/design/2026-07-30-api-only-core-only-bot-mode.md b/docs/design/2026-07-30-api-only-core-only-bot-mode.md index 263f3d247..42fc84e00 100644 --- a/docs/design/2026-07-30-api-only-core-only-bot-mode.md +++ b/docs/design/2026-07-30-api-only-core-only-bot-mode.md @@ -134,7 +134,15 @@ rebase master → 开 PR(中文 + 影响面)→ 发 canary → 配一个 `ap **已证伪并纠正的初稿判断**:①「运行时零改动、只 gate boot 三点」❌——final 前有大量飞书链路。②「getBotClient 是唯一门就够」❌——doc-comment/开放平台/worker uploader/CLI reload 各有旁路。③「send 单点早拒 = 中央 capability」❌——history/quoted/bots/dispatch 各自可达飞书。④「rename/avatar 是 setup-only」❌——是 dashboard runtime 路由。⑤「apiOnly boot hint 足够」❌——secret 若下发到 worker/env/cred 可被恢复。 -**7. 沙箱文件层 no-transport host-authority profile(fs-policy.ts,codex 提权复审收敛)** — HTTP trigger 默认 `workingDir=~`,policy 把整个 home 设 RW;若只 deny 几个 exact 文件,`.dashboard-secret`(daemon IPC 的 trusted-host HMAC,配合 `dashboard-daemons` 端口表可直签 sibling normal-bot daemon 路由绕过全部 gate)、`bots.json.bak/.tmp`、`feishu-session.json`、legacy send-cred 等仍 readWrite;且 policy 语义是 **deepest-prefix wins**,`mandatory deny` 会被更深的 user `sandboxPaths.readWrite` 重开。收口成**权威目录根 profile**: +**7. 沙箱文件层 no-transport host-authority profile(fs-policy.ts,codex 提权复审收敛)** + +> ⚠️ **2026-08 修订(威胁模型放宽)**:no-transport 会话**不再被强制文件读隔离**。此前 `forkWorker` 把「会话无飞书 transport 通道」当作强制隔离条件(`readIsolation = botCfg.readIsolation===true || !larkTransportEnabled(...)`),apiOnly bot / HTTP virtual 会话无论 owner 有没有配 sandbox 都被关进本节所述的 host-authority profile;owner 无法关闭。owner 拍板:**磁盘可读范围应由 owner 自己的 `sandbox`/`readIsolation` 配置决定,不该在 no-transport 逻辑里写死。** 现改为 `readIsolation = botCfg.readIsolation===true`(opt-in only),与普通聊天会话对称。 +> +> 影响:**没配 sandbox/readIsolation 的 no-transport 会话不再建沙盒**,其 CLI 能以同一 OS 用户身份直接读宿主 `~/.botmux/bots.json`(含所有兄弟 bot 的 app secret)等原本被本 profile 遮蔽的文件——这是**被接受的取舍**:多 bot 同机若担心 agent 横向读兄弟凭证,需 owner 显式开 `sandbox`(或 `readIsolation`/`BOTMUX_SANDBOX=1`)。**下面这套 host-authority profile 仍然完整生效——只是触发条件从「no-transport 强制」变成「owner 显式请求沙盒」**:一旦某 no-transport 会话真的开了沙盒,`!larkTransport` 分支照旧冻结权威根、deny bots.json/凭证。 +> +> **两条正交边界不受本次放宽影响,是放宽后剩下的安全网**:① **本 bot 自身 transport secret 的 env 扣留**(`LARK_APP_SECRET`/`larkAppSecret` 仍 gated on `larkTransportEnabled`)——no-transport 会话即使能读磁盘 bots.json,也拿不到注入进程 env 的本 bot secret,Botmux 自身发送链路仍关闭;② **device-credential 强制隔离**(worker.ts `credentialIsolationRequired`)——enrolled 设备上独立强制 mask 设备授权目录/凭证,与文件沙盒 toggle 无关。注意:旧代码里 no-transport 被强制全沙盒会让 `fullIsolationCoversCredentials=true` 从而**跳过** credential-only gate;放宽后无 sandbox 的 no-transport 会话会让该 gate 在 enrolled 机器上真正 engage,是正确的 fail-closed 方向。 + +**下述机制在「owner 显式请求沙盒」时的语义(原文保留)**:HTTP trigger 默认 `workingDir=~`,policy 把整个 home 设 RW;若只 deny 几个 exact 文件,`.dashboard-secret`(daemon IPC 的 trusted-host HMAC,配合 `dashboard-daemons` 端口表可直签 sibling normal-bot daemon 路由绕过全部 gate)、`bots.json.bak/.tmp`、`feishu-session.json`、legacy send-cred 等仍 readWrite;且 policy 语义是 **deepest-prefix wins**,`mandatory deny` 会被更深的 user `sandboxPaths.readWrite` 重开。收口成**权威目录根 profile**: - **权威根 = 目录,不是 exact 文件黑名单**(`computeNoTransportAuthorityRoots`,纯函数 + 导出可单测):**始终冻结 configured(`dirname(dataDir)`)+ default `~/.botmux` 双根**(custom SESSION_DATA_DIR 时 default 根仍存 HMAC/bots.json,二选一会漏),外加 `~/.lark-cli` / `~/.lark-cli-bots` / macOS lark-cli store。整根 deny 自动吸收 `.dashboard-secret/token`、`feishu-session`、bots.json 的 bak/tmp/未来 sidecar、dashboard-daemons 端口表、legacy send-cred。 - **深层重开 fail-closed**:`workingDir` / `userPaths.readWrite/readOnly` / `extraWritePaths` / `readonlyRoots` 落在权威根内的(own BOT_HOME 除外)在进规则集**前** `dropAuthority` 过滤,被抑制项**记录并由 worker 日志**(不静默)。`workingDir` 若 **IS**(或落在)权威根内(own BOT_HOME 除外)→ 抛 `FsPolicyConfigError`(不 silent drop 后 spawn 进未授权 cwd);`workingDir=~`(仅是权威根的祖先)保留,深层 parent deny 自然盖住。 diff --git a/docs/file-sandbox.md b/docs/file-sandbox.md index 048c1af59..8ca3a2f26 100644 --- a/docs/file-sandbox.md +++ b/docs/file-sandbox.md @@ -53,6 +53,14 @@ worker spawnCli → **所有飞书密钥全程不进沙盒**。 +## no-transport 会话(apiOnly / HTTP virtual)跟随本地配置 + +no-transport 会话(core-only `apiOnly` bot、或 `http_async_*`/`http_wait_*` HTTP virtual 会话)**不被自动强制文件隔离**。它们的磁盘可读范围和普通聊天会话一样,只由 bot 自己的 `sandbox`/`readIsolation` 配置决定:没配 → 不隔离(能读宿主 `bots.json` 等);配了 → 照常隔离。 + +> 早先版本曾把「会话没有飞书 transport 通道」当作强制隔离条件(no-transport ⇒ 一律关进沙盒)。现已去掉这条写死的强制,改为跟随 owner 自己的配置——单 bot 部署 / 载荷可信时不再被无谓束缚。**多 bot 同机**、且担心某个半受信任的 no-transport 会话横向读到**兄弟 bot 的凭证**(`bots.json` 里各 bot 的 app secret)时,需 owner **显式**给该 bot 开 `sandbox`(或 `readIsolation` / 全局 `BOTMUX_SANDBOX=1`)。 + +两条与文件沙盒正交、不受此放宽影响的边界仍在:① **本 bot 自己的 transport secret 不进 CLI 进程 env**(gated on transport 能力)——no-transport 会话即使能读磁盘 `bots.json`,也拿不到被注入 env 的本 bot secret,Botmux 自身的发送链路仍关闭;② enrolled 设备上的 **device-credential 强制隔离**独立生效,与本开关无关。 + ## 落盘(改动去向) fs-policy 模型下 agent 在 **readWrite 白名单区(含 workingDir)直接写宿主真实文件**——改动即时落盘,不再是「副本 + 补丁交回」。沙盒的作用是把可写面收敛到白名单:项目目录可写、认证目录可写,白名单之外(别的项目、别的会话、`~/.ssh`/`~/.aws`、`bots.json`、各类密钥)一律读不到写不了。 diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 49bb3dd28..0ffcd9460 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -6836,16 +6836,23 @@ export function forkWorker( // Per-bot local read isolation (enforced worker-side; the worker gates it). // Sibling data needs no app-id enumeration: per-bot dirs are denied wholesale // and per-bot session files by filename pattern (see buildV2DenyPaths). - // HARD credential boundary for a no-transport session (apiOnly bot OR HTTP - // virtual chat): force read isolation so the CLI physically cannot read the - // full bots.json / sibling BOT_HOME / send-cred / lark-cli store — a model - // that deletes/forges the ancestry marker or bypasses the CLI still cannot - // build ANY (sibling) Lark client. The pid-marker gate is only friendly - // early-reject; THIS is the fail-closed boundary. Reuses the existing unified - // fs-policy (mac+Linux fail-closed); a backend that can't isolate locally - // refuses to spawn rather than leak creds. - readIsolation: botCfg.readIsolation === true - || !larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly }), + // Opt-in only, driven purely by explicit per-bot `readIsolation`. A + // no-transport session (apiOnly bot OR HTTP virtual chat) is NO LONGER + // force-isolated: disk read scope now follows the owner's own sandbox config, + // symmetric with a normal chat session (unset/false → not isolated). Accepted + // trade-off: a no-transport session with no sandbox config can read the full + // bots.json / sibling BOT_HOME on disk; protecting sibling creds from lateral + // read on a multi-bot host now depends on the owner explicitly enabling + // sandbox/readIsolation, not on this force. Two adjacent boundaries are + // unchanged and independent: (1) this bot's own transport secret is still + // withheld from the CLI env (gated on larkTransportEnabled below), so a + // no-transport session cannot drive Botmux's own send path even though it can + // read the file; (2) mandatory device-credential isolation (worker.ts) still + // masks the device authority dir / enrolled creds on enrolled hosts. Full-file + // sandbox stays independently driven worker-side by sandboxRequested + // (cfg.sandbox || cfg.readIsolation || BOTMUX_SANDBOX=1); session.sandbox is + // frozen from botCfg.sandbox at create time, so "follow local sandbox" holds. + readIsolation: botCfg.readIsolation === true, readDenyExtraPaths: botCfg.readDenyExtraPaths ?? [], // Identifies THIS daemon lifetime. Stamped onto isolated panes so the worker // can tell a suspend→resume reattach (same boot id, still isolated) from a diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index 8eb4a0e37..7adb9e6f5 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -305,16 +305,22 @@ describe('API-only bot mode — bot-level primitive boundary (source lock)', () expect(fedRoster).toContain('larkTransportEnabled: b.larkTransportEnabled,'); }); - it('no-transport session FORCES read isolation on fresh/resume/restart; adopt is refused at restore', () => { - // fresh-spawn forkWorker (shared by fresh/resume/restart) forces read - // isolation for a no-transport session — the fail-closed credential boundary. + it('no-transport session read isolation FOLLOWS local sandbox config (no forced isolation); adopt is refused at restore', () => { + // fresh-spawn forkWorker (shared by fresh/resume/restart) NO LONGER force- + // isolates a no-transport session. readIsolation is opt-in only, driven purely + // by explicit per-bot `readIsolation`; a no-transport session with no sandbox + // config reads bots.json like a normal chat (accepted trade-off — lateral + // sibling-cred protection now depends on the owner enabling sandbox). const wp = readFileSync(resolve('src/core/worker-pool.ts'), 'utf8'); - expect(wp).toContain('readIsolation: botCfg.readIsolation === true\n || !larkTransportEnabled({ chatId: ds.chatId, apiOnly: botCfg.apiOnly })'); + expect(wp).toContain('readIsolation: botCfg.readIsolation === true,'); + // The old forced-isolation disjunct is gone: readIsolation must NOT be tied to + // transport state anymore. + expect(wp).not.toContain('readIsolation: botCfg.readIsolation === true\n || !larkTransportEnabled('); // Adopt does NOT gate via the init field (the observe branch returns before // fs-policy is built — an init readIsolation would be a dead no-op). Instead // adoptSandboxBlocked refuses a no-transport adopt at daemon restore and - // converts it to cold-start, covering "normal adopt session later flipped to - // apiOnly then restarted". + // converts it to cold-start: adopt attaches to an ALREADY-running external CLI + // that could never be wrapped, so a no-transport turn must cold-start instead. const gate = region(wp, 'export function adoptSandboxBlocked(', 'export function forkAdoptWorker('); expect(gate).toContain('botCfg.apiOnly === true'); expect(gate).toContain("session.chatId.startsWith('http_async_') || session.chatId.startsWith('http_wait_')"); diff --git a/test/session-lifecycle-start.test.ts b/test/session-lifecycle-start.test.ts index 15c40981d..d2d14d4ca 100644 --- a/test/session-lifecycle-start.test.ts +++ b/test/session-lifecycle-start.test.ts @@ -471,6 +471,53 @@ describe('persistent backend target handoff', () => { }); }); +describe('no-transport read isolation follows local sandbox config (not forced)', () => { + // Behavioral lock for the 2026-08 change: a no-transport session (apiOnly bot + // OR HTTP virtual chat) is NO LONGER force-isolated. readIsolation is opt-in + // only — driven purely by explicit per-bot `readIsolation` — so a no-transport + // session with no sandbox config reads the disk like a normal chat. The env + // secret-withhold (asserted in api-only-mode-wiring) is a SEPARATE boundary and + // stays independent of this. + const readInit = () => { + const worker = forkMock.mock.results.at(-1)!.value; + return vi.mocked(worker.send).mock.calls[0][0]; + }; + + it('apiOnly bot WITHOUT sandbox config → readIsolation:false (was forced true)', () => { + vi.mocked(getBot).mockImplementation(() => defaultBot({ apiOnly: true, larkAppSecret: '' })); + const ds = makeDs(); + forkWorker(ds, 'hello', false); + expect(readInit().readIsolation).toBe(false); + }); + + it('HTTP virtual session (http_wait_) on a normal bot WITHOUT sandbox → readIsolation:false', () => { + const ds = makeDs({ chatId: 'http_wait_abc', session: { ...makeDs().session, chatId: 'http_wait_abc' } }); + forkWorker(ds, 'hello', false); + expect(readInit().readIsolation).toBe(false); + }); + + it('HTTP virtual session (http_async_) on a normal bot WITHOUT sandbox → readIsolation:false', () => { + const ds = makeDs({ chatId: 'http_async_xyz', session: { ...makeDs().session, chatId: 'http_async_xyz' } }); + forkWorker(ds, 'hello', false); + expect(readInit().readIsolation).toBe(false); + }); + + it('no-transport session with explicit bot readIsolation:true STILL isolates (follows config)', () => { + vi.mocked(getBot).mockImplementation(() => defaultBot({ apiOnly: true, larkAppSecret: '', readIsolation: true })); + const ds = makeDs(); + forkWorker(ds, 'hello', false); + // Proves the follow-config path: the owner can still opt in; the change only + // removed the FORCED disjunct, not the explicit opt-in. + expect(readInit().readIsolation).toBe(true); + }); + + it('a normal transport-enabled chat is unaffected (readIsolation:false by default)', () => { + const ds = makeDs(); + forkWorker(ds, 'hello', false); + expect(readInit().readIsolation).toBe(false); + }); +}); + describe('CLI runtime session freeze', () => { it('migrates an old agentFrozen session from its own cliPathOverride', () => { vi.mocked(getBot).mockImplementation(() => defaultBot({ From 03cf22ac87974df4c9b09279a483f16308aa261a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Sun, 16 Aug 2026 12:07:11 -0700 Subject: [PATCH 2/8] =?UTF-8?q?fix(sandbox):=20=E4=BF=AE=E6=8C=81=E4=B9=85?= =?UTF-8?q?=20pane=20=E4=BB=8E=E5=BC=BA=E5=88=B6=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=88=B0=20policy-off=20=E6=97=B6=E8=A2=AB?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景(复审阻断点) 去掉 no-transport 强制隔离后,发现一处升级迁移回归:worker.ts 的持久 pane reattach 校验外层门是 `if (appliedIsolationCapabilities.length > 0 && ...)`, 新策略为 OFF(无 sandbox、设备未 enrollment)时 capabilities 为空,整段校验被 跳过,段内为 policy-off 准备的 `marker === null && !hostEntryExistsNoFollow(...)` 分支永不可达。 命中场景:旧版本在 forced no-transport 隔离下创建的 apiOnly / HTTP virtual 持久 pane(tmux/zellij/zmx/herdr)已带 credential/read/write marker 并以全文件沙盒 运行。升级本改动 + daemon 重启后,新 worker 虽收到 readIsolation=false,backend selection 仍把存活 pane 标为 isReattach=true;因上述门被跳过,worker 直接 reattach 回旧 bwrap/Seatbelt 进程,而非 kill + 冷启动。结果该会话 resume/restart 后仍摸不到 宿主 IPC/文件,与"读范围跟随本地配置"矛盾,旧 CLI-data redirect/env 也继续留存。 ## 改了什么 抽出纯函数 `persistentPaneReattachGuardEngaged(capabilities, markerPresentOnDisk)` (adapters/cli/read-isolation.ts)作为 reattach guard 的入口判定: - policy ON(capabilities 非空)→ 恒 engage(覆盖 suspend→resume 与 legacy 两种) - policy OFF 但磁盘存在 boot marker → engage(让 OFF 臂 kill + 冷启动 unconfined) - policy OFF 且无 marker → 不 engage(普通从未隔离会话原样 warm reattach,零误杀) worker.ts reattach gate 改用该 helper;marker 存在性用 no-follow 存在探测(planted/ tampered 叶子读不出也算存在,不能用来强制静默 reattach)。**kill 分支在 kill 前 unlink stale marker**:policy-off 冷启动不写新 marker(stamp 仍 gated on capabilities>0),不清会导致每次 restart 重新 engage 误杀刚冷启动的 pane(kill 循环)。 stamp gate(仅 policy-on 写 marker)保持不变。 ## 正交边界不变 env 扣留(本 bot secret 不进 CLI env)与 device-credential 强制隔离均未触碰。 ## 测试 - read-isolation.test.ts 新增 `persistentPaneReattachGuardEngaged` 真值表 3 条 (policy ON×marker有无、policy OFF+marker→engage、policy OFF 无 marker→不 engage)。 - api-only-mode-wiring.test.ts 新增 worker 装配 source-lock:gate 由 helper 驱动 + kill 前 unlink marker 在 kill 之前(kill 循环防护)。 - backend-gate.test.ts reattach gate source anchor 同步到新表达式。 - 反向变异自检:①helper 丢掉 markerPresentOnDisk 项 → policy-off+marker 行为测试 变红;②worker gate 换回裸 length>0 → 装配 source-lock 变红;均已还原。 - 定向 11 文件 465 pass / 1 skip;pnpm build 绿。 ## 文档 file-sandbox.md + api-only 设计文档:安全口径从"同 OS 用户可读"收紧为"可读写" (能改写宿主配置/直接调 Lark API);env 扣留表述为"只关闭 Botmux 内建 transport 调用链,非恶意代码下凭证隔离";设计文档补升级迁移(持久后端)说明。 --- .../2026-07-30-api-only-core-only-bot-mode.md | 6 ++-- docs/file-sandbox.md | 6 ++-- src/adapters/cli/read-isolation.ts | 30 ++++++++++++++++ src/worker.ts | 36 ++++++++++++++++--- test/api-only-mode-wiring.test.ts | 24 +++++++++++++ test/backend-gate.test.ts | 2 +- test/read-isolation.test.ts | 33 +++++++++++++++++ 7 files changed, 127 insertions(+), 10 deletions(-) diff --git a/docs/design/2026-07-30-api-only-core-only-bot-mode.md b/docs/design/2026-07-30-api-only-core-only-bot-mode.md index 42fc84e00..3797876f9 100644 --- a/docs/design/2026-07-30-api-only-core-only-bot-mode.md +++ b/docs/design/2026-07-30-api-only-core-only-bot-mode.md @@ -138,9 +138,11 @@ rebase master → 开 PR(中文 + 影响面)→ 发 canary → 配一个 `ap > ⚠️ **2026-08 修订(威胁模型放宽)**:no-transport 会话**不再被强制文件读隔离**。此前 `forkWorker` 把「会话无飞书 transport 通道」当作强制隔离条件(`readIsolation = botCfg.readIsolation===true || !larkTransportEnabled(...)`),apiOnly bot / HTTP virtual 会话无论 owner 有没有配 sandbox 都被关进本节所述的 host-authority profile;owner 无法关闭。owner 拍板:**磁盘可读范围应由 owner 自己的 `sandbox`/`readIsolation` 配置决定,不该在 no-transport 逻辑里写死。** 现改为 `readIsolation = botCfg.readIsolation===true`(opt-in only),与普通聊天会话对称。 > -> 影响:**没配 sandbox/readIsolation 的 no-transport 会话不再建沙盒**,其 CLI 能以同一 OS 用户身份直接读宿主 `~/.botmux/bots.json`(含所有兄弟 bot 的 app secret)等原本被本 profile 遮蔽的文件——这是**被接受的取舍**:多 bot 同机若担心 agent 横向读兄弟凭证,需 owner 显式开 `sandbox`(或 `readIsolation`/`BOTMUX_SANDBOX=1`)。**下面这套 host-authority profile 仍然完整生效——只是触发条件从「no-transport 强制」变成「owner 显式请求沙盒」**:一旦某 no-transport 会话真的开了沙盒,`!larkTransport` 分支照旧冻结权威根、deny bots.json/凭证。 +> 影响:**没配 sandbox/readIsolation 的 no-transport 会话不再建沙盒**,其 CLI 能以同一 OS 用户身份对宿主文件有完整**读写**权——不仅能读宿主 `~/.botmux/bots.json`(含所有兄弟 bot 的 app secret)等原本被本 profile 遮蔽的文件,还能据此直接调 Lark API、改写宿主上的任意配置——这是**被接受的取舍**:多 bot 同机若担心 agent 横向读写兄弟凭证/配置,需 owner 显式开 `sandbox`(或 `readIsolation`/`BOTMUX_SANDBOX=1`)。**下面这套 host-authority profile 仍然完整生效——只是触发条件从「no-transport 强制」变成「owner 显式请求沙盒」**:一旦某 no-transport 会话真的开了沙盒,`!larkTransport` 分支照旧冻结权威根、deny bots.json/凭证。 > -> **两条正交边界不受本次放宽影响,是放宽后剩下的安全网**:① **本 bot 自身 transport secret 的 env 扣留**(`LARK_APP_SECRET`/`larkAppSecret` 仍 gated on `larkTransportEnabled`)——no-transport 会话即使能读磁盘 bots.json,也拿不到注入进程 env 的本 bot secret,Botmux 自身发送链路仍关闭;② **device-credential 强制隔离**(worker.ts `credentialIsolationRequired`)——enrolled 设备上独立强制 mask 设备授权目录/凭证,与文件沙盒 toggle 无关。注意:旧代码里 no-transport 被强制全沙盒会让 `fullIsolationCoversCredentials=true` 从而**跳过** credential-only gate;放宽后无 sandbox 的 no-transport 会话会让该 gate 在 enrolled 机器上真正 engage,是正确的 fail-closed 方向。 +> **两条正交边界不受本次放宽影响,是放宽后剩下的安全网**:① **本 bot 自身 transport secret 的 env 扣留**(`LARK_APP_SECRET`/`larkAppSecret` 仍 gated on `larkTransportEnabled`)——这只关闭 **Botmux 内建的 transport 调用链**(本 bot 自己的 send 路径),**不构成恶意代码下的凭证隔离**:不开沙盒时 agent 仍能从磁盘 bots.json 读出 secret 自行调 Lark;② **device-credential 强制隔离**(worker.ts `credentialIsolationRequired`)——enrolled 设备上独立强制 mask 设备授权目录/凭证,与文件沙盒 toggle 无关。注意:旧代码里 no-transport 被强制全沙盒会让 `fullIsolationCoversCredentials=true` 从而**跳过** credential-only gate;放宽后无 sandbox 的 no-transport 会话会让该 gate 在 enrolled 机器上真正 engage,是正确的 fail-closed 方向。 +> +> **升级迁移(持久后端)**:旧版本在 forced-isolation 下创建的 apiOnly/HTTP virtual 持久 pane(tmux/zellij/zmx/herdr)会带 `credential/read/write` marker。升级到本放宽 + daemon 重启后,worker 侧持久 pane reattach guard(`persistentPaneReattachGuardEngaged`)在「新策略 OFF 但磁盘存在旧 marker」时仍会 engage:kill 旧的仍受限 pane + 重选后端 + 冷启动(不再 confined),并在 kill 前清掉 stale marker 防止 restart 循环误杀。避免了「新策略 OFF 却 warm-reattach 回旧 bwrap/Seatbelt 进程」的语义矛盾。 **下述机制在「owner 显式请求沙盒」时的语义(原文保留)**:HTTP trigger 默认 `workingDir=~`,policy 把整个 home 设 RW;若只 deny 几个 exact 文件,`.dashboard-secret`(daemon IPC 的 trusted-host HMAC,配合 `dashboard-daemons` 端口表可直签 sibling normal-bot daemon 路由绕过全部 gate)、`bots.json.bak/.tmp`、`feishu-session.json`、legacy send-cred 等仍 readWrite;且 policy 语义是 **deepest-prefix wins**,`mandatory deny` 会被更深的 user `sandboxPaths.readWrite` 重开。收口成**权威目录根 profile**: diff --git a/docs/file-sandbox.md b/docs/file-sandbox.md index 8ca3a2f26..7abb44a86 100644 --- a/docs/file-sandbox.md +++ b/docs/file-sandbox.md @@ -55,11 +55,11 @@ worker spawnCli ## no-transport 会话(apiOnly / HTTP virtual)跟随本地配置 -no-transport 会话(core-only `apiOnly` bot、或 `http_async_*`/`http_wait_*` HTTP virtual 会话)**不被自动强制文件隔离**。它们的磁盘可读范围和普通聊天会话一样,只由 bot 自己的 `sandbox`/`readIsolation` 配置决定:没配 → 不隔离(能读宿主 `bots.json` 等);配了 → 照常隔离。 +no-transport 会话(core-only `apiOnly` bot、或 `http_async_*`/`http_wait_*` HTTP virtual 会话)**不被自动强制文件隔离**。它们的磁盘可读写范围和普通聊天会话一样,只由 bot 自己的 `sandbox`/`readIsolation` 配置决定:没配 → 不隔离(以同一 OS 用户身份对宿主文件有完整**读写**权,能读宿主 `bots.json`、也能改写宿主配置);配了 → 照常隔离。 -> 早先版本曾把「会话没有飞书 transport 通道」当作强制隔离条件(no-transport ⇒ 一律关进沙盒)。现已去掉这条写死的强制,改为跟随 owner 自己的配置——单 bot 部署 / 载荷可信时不再被无谓束缚。**多 bot 同机**、且担心某个半受信任的 no-transport 会话横向读到**兄弟 bot 的凭证**(`bots.json` 里各 bot 的 app secret)时,需 owner **显式**给该 bot 开 `sandbox`(或 `readIsolation` / 全局 `BOTMUX_SANDBOX=1`)。 +> 早先版本曾把「会话没有飞书 transport 通道」当作强制隔离条件(no-transport ⇒ 一律关进沙盒)。现已去掉这条写死的强制,改为跟随 owner 自己的配置——单 bot 部署 / 载荷可信时不再被无谓束缚。**多 bot 同机**、且担心某个半受信任的 no-transport 会话横向读到**兄弟 bot 的凭证**(`bots.json` 里各 bot 的 app secret)时,需 owner **显式**给该 bot 开 `sandbox`(或 `readIsolation` / 全局 `BOTMUX_SANDBOX=1`)。不开沙盒时 agent 拿到的是同一 OS 用户的宿主读写能力:不仅能读出各 bot secret,还能据此直接调 Lark API、或改写宿主上的任意配置——这正是「载荷可信」这一前提要承担的信任面。 -两条与文件沙盒正交、不受此放宽影响的边界仍在:① **本 bot 自己的 transport secret 不进 CLI 进程 env**(gated on transport 能力)——no-transport 会话即使能读磁盘 `bots.json`,也拿不到被注入 env 的本 bot secret,Botmux 自身的发送链路仍关闭;② enrolled 设备上的 **device-credential 强制隔离**独立生效,与本开关无关。 +两条与文件沙盒正交、不受此放宽影响的边界仍在:① **本 bot 自己的 transport secret 不进 CLI 进程 env**(gated on transport 能力)——这只关闭 **Botmux 内建的 transport 调用链**(本 bot 的 send 路径),**不构成恶意代码下的凭证隔离**:不开沙盒时 agent 仍能从磁盘 `bots.json` 读出 secret 自行调 Lark;② enrolled 设备上的 **device-credential 强制隔离**独立生效,与本开关无关。 ## 落盘(改动去向) diff --git a/src/adapters/cli/read-isolation.ts b/src/adapters/cli/read-isolation.ts index b18f5ce26..068ebd263 100644 --- a/src/adapters/cli/read-isolation.ts +++ b/src/adapters/cli/read-isolation.ts @@ -512,6 +512,36 @@ export function isolatedPaneReattachSafe( } } +/** + * Should the worker's persistent-pane (tmux/zellij/herdr/zmx) reattach guard + * ENGAGE for this spawn? The guard probes a live pane and, if its stamped marker + * does not match the current policy, kills it and cold-spawns. Two spawn shapes + * must engage it: + * + * 1. Policy ON (`appliedIsolationCapabilities` non-empty): a surviving pane + * might be a suspend→resume of the same isolated process (reattach OK) or a + * legacy/mismatched one (kill). Always evaluate. + * 2. Policy OFF (no capabilities) but a boot marker is present on disk: the pane + * may be a still-confined process spawned by an OLDER build under the former + * FORCED no-transport isolation. Blindly reattaching it would keep the CLI + * confined against a policy we no longer want — silently violating "read + * scope follows local config" on resume/restart (the 2026-08 no-transport + * 放宽 upgrade path). Engage so the OFF arm can kill + cold-spawn unconfined. + * + * Policy OFF with NO marker is the ordinary never-isolated session: the guard + * stays disengaged so a normal warm reattach is untouched (no false kill, no + * extra probe). `markerPresentOnDisk` MUST come from a no-follow existence probe + * (a planted/tampered leaf that fails to parse still counts as present, so it can + * never be used to force a silent reattach). Backend/pty applicability is checked + * by the caller. + */ +export function persistentPaneReattachGuardEngaged( + appliedIsolationCapabilities: readonly IsolationCapability[], + markerPresentOnDisk: boolean, +): boolean { + return appliedIsolationCapabilities.length > 0 || markerPresentOnDisk; +} + function dedupe(xs: string[]): string[] { return Array.from(new Set(xs)); } diff --git a/src/worker.ts b/src/worker.ts index 0b4b3a89a..80bf622c2 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -28,6 +28,7 @@ import { buildSeatbeltProfile, isolatedPaneOriginChannel, isolatedPaneReattachSafe, + persistentPaneReattachGuardEngaged, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, @@ -11872,8 +11873,30 @@ async function spawnCli( // so the probe below sees no pane and we cold-spawn fresh isolated. A pane from // this lifetime (suspend→resume) keeps its marker → reattaches normally (it is // still the isolated process). This lets isolated bots use tmux/zellij/herdr. + // + // The MIRROR case is just as load-bearing: policy is now OFF (no sandbox, not + // enrolled → appliedIsolationCapabilities empty), but a pane spawned by an OLDER + // build under the previous FORCED no-transport isolation is still alive AND still + // stamped. That pane runs confined against a policy we no longer want; a bare + // `capabilities.length > 0` gate would skip the check entirely and warm-reattach + // the still-isolated process, silently contradicting "read scope follows local + // config" on resume/restart (the 2026-08 no-transport放宽 upgrade path). So we + // also enter when a boot marker is present on disk for THIS session — then the + // policy-off arm below (no expected capabilities) demands the marker be truly + // absent to reattach, else kills + cold-spawns unconfined. Presence is checked + // by the no-follow existence probe (a planted/tampered leaf that reads as null + // still counts as present, so it cannot be used to force a silent reattach). let persistentPaneOriginChannelId: string | undefined; - if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') { + const stalePaneMarkerPath = join( + isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`, + ); + // When the policy is OFF, a boot marker on disk is the signal that a + // previously-isolated pane may still be alive and must be re-evaluated rather + // than blindly reattached (see persistentPaneReattachGuardEngaged). Probed with + // the no-follow existence check so a planted/tampered leaf still counts. + const stalePaneMarkerPresent = hostEntryExistsNoFollow(stalePaneMarkerPath); + if (persistentPaneReattachGuardEngaged(appliedIsolationCapabilities, stalePaneMarkerPresent) + && persistentSessionName && effectiveBackendType !== 'pty') { const persistentTarget = selectedBackend.persistentBackendTarget; // ZMX ownership is verified against the frozen PID, not just the name — a // same-named session may belong to the user or to a newer generation. @@ -11903,9 +11926,7 @@ async function spawnCli( } const paneLive = paneProbe === 'exists'; if (paneLive) { - const markerPath = join( - isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`, - ); + const markerPath = stalePaneMarkerPath; const marker = readManagedOriginAuthorityFile(markerPath); const originChannelPolicyExpected = !!managedOriginChannelPolicyDigest; // A stamped pane must match even when the new policy is OFF. Otherwise a @@ -11936,6 +11957,13 @@ async function spawnCli( // Missing/legacy marker → pane predates the current policy and may retain // obsolete permissions. Kill it before publishing any new capability. log(`[read-isolation] legacy/unmarked persistent pane for ${cfg.sessionId} — killing + cold-spawning with current policy`); + // Remove the stale on-disk marker BEFORE the kill. If the new policy is + // OFF we cold-spawn unconfined and write NO new marker (see the stamp gate + // below, still keyed on capabilities>0), so a surviving marker would make + // every later restart re-enter here and kill the freshly-spawned pane — an + // infinite kill loop. A policy-ON cold-spawn re-stamps a fresh marker after + // reattach is ruled out, so clearing it here is safe in both directions. + try { unlinkSync(stalePaneMarkerPath); } catch { /* absent / already gone */ } // Capture the name before re-selection: `persistentSessionName` is // reassigned from the new selection below and widens back to // `string | undefined`, but the backing name we are tearing down is diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index 7adb9e6f5..e9e752e83 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -447,6 +447,30 @@ describe('API-only bot mode — no-transport fs-policy authority provenance (wor expect(workerSource).toContain('no-transport suppressed'); }); + it('persistent-pane guard engages on policy-OFF migration and clears the stale marker before killing (no kill-loop)', () => { + // 2026-08 no-transport 放宽 migration: the reattach guard must be driven by + // persistentPaneReattachGuardEngaged (which engages when the policy is OFF but + // a boot marker survives on disk), NOT the old bare `capabilities.length > 0` + // gate that skipped policy-off entirely and warm-reattached a still-confined + // legacy pane. Behavioral truth table lives in read-isolation.test.ts. + expect(workerSource).toContain('const stalePaneMarkerPresent = hostEntryExistsNoFollow(stalePaneMarkerPath);'); + expect(workerSource).toContain( + 'if (persistentPaneReattachGuardEngaged(appliedIsolationCapabilities, stalePaneMarkerPresent)', + ); + // The kill branch MUST unlink the on-disk marker before killing: a policy-off + // cold-spawn writes NO new marker (the stamp is still gated on capabilities>0), + // so a surviving marker would re-engage the guard every restart and kill the + // freshly-spawned pane forever. Assert the unlink precedes the kill. + const killBlock = region(workerSource, + '[read-isolation] legacy/unmarked persistent pane', + 'let willReattachPersistent'); + const unlink = killBlock.indexOf('unlinkSync(stalePaneMarkerPath)'); + const kill = killBlock.indexOf('killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId)'); + expect(unlink).toBeGreaterThan(-1); + expect(kill).toBeGreaterThan(-1); + expect(unlink).toBeLessThan(kill); + }); + it('daemon freezes the actual loaded bots-config path into the worker init message', () => { // getLoadedConfigPath() is host-frozen; the worker must not re-guess from env. const block = region(workerPoolSource, 'apiOnly: botCfg.apiOnly,', 'brand: normalizeBrand(botCfg.brand),'); diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index 490f1fabb..fff41b0eb 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -191,7 +191,7 @@ describe('persistent backend cold-restart ordering', () => { it('limits inconclusive-probe startup rejection to ZMX in both persistent gates', () => { const readIsolationStart = workerSource.indexOf( - 'if (appliedIsolationCapabilities.length > 0 && persistentSessionName', + 'if (persistentPaneReattachGuardEngaged(appliedIsolationCapabilities, stalePaneMarkerPresent)', ); const readIsolationEnd = workerSource.indexOf('let willReattachPersistent', readIsolationStart); const mcpStart = workerSource.indexOf( diff --git a/test/read-isolation.test.ts b/test/read-isolation.test.ts index b7d0540bd..6c7f83eca 100644 --- a/test/read-isolation.test.ts +++ b/test/read-isolation.test.ts @@ -9,6 +9,7 @@ import { buildCredentialIsolationRules, isolatedPaneOriginChannel, isolatedPaneReattachSafe, + persistentPaneReattachGuardEngaged, isolationPaneMarkerContent, ISOLATION_PANE_MARKER_VERSION, isolationPanePolicyDigest, @@ -314,6 +315,38 @@ describe('isolatedPaneReattachSafe', () => { // ─── cold-start migration: START-TIME env contract (bots.json EPERM fix) ────── +describe('persistentPaneReattachGuardEngaged — policy-off migration re-evaluates stale isolated panes', () => { + // The worker's persistent-pane guard probes a live pane and, when its stamped + // marker does not match the current policy, kills it + cold-spawns. This helper + // is the ENTRY decision for that guard. Its correctness is the fix for the + // 2026-08 no-transport 放宽 upgrade path: an apiOnly / HTTP-virtual session that + // was FORCE-isolated by an older build leaves a live confined pane + boot marker; + // after upgrade the new policy is OFF, and without this the guard's outer gate + // (formerly `capabilities.length > 0`) skipped evaluation entirely and warm- + // reattached the still-confined process. + const CAPS_ON = ['credential', 'read', 'write'] as const; + const CAPS_OFF = [] as const; + + it('policy ON always engages the guard (marker present or not)', () => { + // A suspend→resume of the SAME isolated process (marker present) and a fresh + // isolated spawn whose marker was lost (absent) must both be evaluated. + expect(persistentPaneReattachGuardEngaged(CAPS_ON, true)).toBe(true); + expect(persistentPaneReattachGuardEngaged(CAPS_ON, false)).toBe(true); + }); + + it('policy OFF + stale marker present → engages (so the OFF arm kills + cold-spawns unconfined)', () => { + // THE regression: old forced-isolation pane still alive & stamped, new policy + // OFF. Must engage, not silently reattach the confined process. + expect(persistentPaneReattachGuardEngaged(CAPS_OFF, true)).toBe(true); + }); + + it('policy OFF + no marker → does NOT engage (ordinary never-isolated session, no false kill)', () => { + // A normal chat / no-transport session that was never isolated must warm- + // reattach untouched — no extra probe, no spurious kill. + expect(persistentPaneReattachGuardEngaged(CAPS_OFF, false)).toBe(false); + }); +}); + /** * Regression guard (2026-08-03). The bots.json-EPERM fix injects a NEW start-time * env contract (BOTMUX_READ_ISOLATION / BOTMUX_API_ONLY) that only reaches a CLI From 17ab844bc04d0594fd7d02def79af4b80f998954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Sun, 16 Aug 2026 12:41:22 -0700 Subject: [PATCH 3/8] =?UTF-8?q?fix(sandbox):=20=E6=8C=81=E4=B9=85=20pane?= =?UTF-8?q?=20=E8=BF=81=E7=A7=BB=E6=94=B9=E4=B8=BA=20tombstone=20=E6=AD=A3?= =?UTF-8?q?=E5=90=91=E8=AF=81=E6=98=8E=20+=20kill=20=E7=A1=AE=E8=AE=A4?= =?UTF-8?q?=E5=90=8E=E5=86=8D=E6=B8=85=20provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景(复审第二轮阻断点) 上一版 marker 生命周期还有两个阻断分支 + 一个同源根因: 1. **先 unlink 后 kill 丢迁移证据**:kill 抛错/post-kill probe 拒绝时旧 pane 可能 仍活、marker 却已删→下次 policy-off 启动 caps=[]+无 marker→helper 返 false→ 跳过 guard→reattach 回没杀成的旧隔离 pane,原回归重现。 2. **marker 在但 pane 已 missing 不清 marker**:本次冷启正常无沙盒 pane(不写新 marker),旧 marker 残留→下次重启它变 live 被误判杀掉。 3. **根因:隔离 marker 写入是 best-effort**(stamp 处 catch 后仍 spawn),故 "policy-off + 无 marker" 不严格等价 "从未隔离";据"无 marker"直接 warm reattach 无法保证迁移语义。 ## 改了什么 **抽纯状态机 `evaluatePersistentPaneMigration`**(read-isolation.ts,可注入、 纯函数)作为持久 pane 迁移决策唯一真源,返回 reattach / kill-then-cold-spawn (clearAfterKill) / clear-stale-then-cold-spawn / skip。worker.ts 的 guard 只做 IO(probe/kill/clear)并 dispatch 该决策。 **tombstone 正向证明**(根治 issue 3):新增 `.policy-off` tombstone,由 policy-off 冷启动写入,正向证明"此 generation 由新无沙盒策略创建"。policy-off 下**只有拿到 tombstone 且无隔离 marker 才允许 warm reattach**;带旧隔离 marker、 或两文件都无(fail-closed——"无 marker"不再被当作安全)的存活 pane 一律 kill。 tombstone 写失败即拒绝启动(不发布无法证明的 generation)。 **关键时序(fail-closed)**:provenance 文件只在 **kill 成功 + post-kill probe 确认终止之后**才删,删除本身再验证消失(`removeProvenanceOrThrow`:unlink→ no-follow 复查→仍在则抛错),删不掉拒绝继续。绝不在 kill 前删。pane 已 missing 但残留 stale 文件→先清理(验证)再冷启动。 **作用域**:迁移臂只对 no-transport(apiOnly/HTTP virtual)+ isolation-capable (tmux——沙盒只作用 pty/tmux,pty 不持久)会话生效;普通 transport-enabled 聊天 从未被强制隔离,不受 tombstone 要求约束→无误杀。四类后端仍只操作各自精确 target。 ## 崩溃可恢复 kill 后清 marker 前崩溃→下次 pane missing + stale 文件→clear-stale 恢复;清 marker 后 spawn 前崩溃→下次无文件→skip→冷启动写 tombstone;都不留虚假证明。 ## 测试 - read-isolation.test.ts:`evaluatePersistentPaneMigration` 真值表 12 条,覆盖 codex 5 分支(kill/clear/missing/正常 live/transport-enabled 不误杀)+ issue-3 的"NEITHER file→kill"。 - api-only-mode-wiring.test.ts:worker 装配 source-lock 重写——guard 由状态机驱动、 provenance 清理在 post-kill 确认之后(顺序断言)、removeProvenanceOrThrow fail-closed、tombstone 写入 + 写失败拒绝启动;`not.toContain` 旧 helper 名。 - backend-gate.test.ts:gate/kill log anchor 同步到新表达式。 - 反向变异自检:①去掉 tombstone 要求(provenPolicyOffGeneration 放松)→"NEITHER file→kill"变红;②removeProvenanceOrThrow 改吞错→装配 source-lock 变红;均已还原。 - 定向 11 文件 474 pass / 1 skip;pnpm build 绿;git diff --check 干净。 ## 文档 api-only 设计文档升级迁移段重写为 tombstone/provenance 语义 + kill 确认后清 + fail-closed 时序。 --- .../2026-07-30-api-only-core-only-bot-mode.md | 2 +- src/adapters/cli/read-isolation.ts | 156 ++++++++-- src/worker.ts | 276 +++++++++++------- test/api-only-mode-wiring.test.ts | 53 ++-- test/backend-gate.test.ts | 8 +- test/read-isolation.test.ts | 121 ++++++-- 6 files changed, 436 insertions(+), 180 deletions(-) diff --git a/docs/design/2026-07-30-api-only-core-only-bot-mode.md b/docs/design/2026-07-30-api-only-core-only-bot-mode.md index 3797876f9..c5226158e 100644 --- a/docs/design/2026-07-30-api-only-core-only-bot-mode.md +++ b/docs/design/2026-07-30-api-only-core-only-bot-mode.md @@ -142,7 +142,7 @@ rebase master → 开 PR(中文 + 影响面)→ 发 canary → 配一个 `ap > > **两条正交边界不受本次放宽影响,是放宽后剩下的安全网**:① **本 bot 自身 transport secret 的 env 扣留**(`LARK_APP_SECRET`/`larkAppSecret` 仍 gated on `larkTransportEnabled`)——这只关闭 **Botmux 内建的 transport 调用链**(本 bot 自己的 send 路径),**不构成恶意代码下的凭证隔离**:不开沙盒时 agent 仍能从磁盘 bots.json 读出 secret 自行调 Lark;② **device-credential 强制隔离**(worker.ts `credentialIsolationRequired`)——enrolled 设备上独立强制 mask 设备授权目录/凭证,与文件沙盒 toggle 无关。注意:旧代码里 no-transport 被强制全沙盒会让 `fullIsolationCoversCredentials=true` 从而**跳过** credential-only gate;放宽后无 sandbox 的 no-transport 会话会让该 gate 在 enrolled 机器上真正 engage,是正确的 fail-closed 方向。 > -> **升级迁移(持久后端)**:旧版本在 forced-isolation 下创建的 apiOnly/HTTP virtual 持久 pane(tmux/zellij/zmx/herdr)会带 `credential/read/write` marker。升级到本放宽 + daemon 重启后,worker 侧持久 pane reattach guard(`persistentPaneReattachGuardEngaged`)在「新策略 OFF 但磁盘存在旧 marker」时仍会 engage:kill 旧的仍受限 pane + 重选后端 + 冷启动(不再 confined),并在 kill 前清掉 stale marker 防止 restart 循环误杀。避免了「新策略 OFF 却 warm-reattach 回旧 bwrap/Seatbelt 进程」的语义矛盾。 +> **升级迁移(持久后端)**:旧版本在 forced-isolation 下创建的 no-transport 持久 pane 会带 `credential/read/write` 隔离 marker(沙盒只作用于 tmux,故实际只有 tmux pane 可能带 marker)。升级到本放宽 + daemon 重启后,worker 侧持久 pane 迁移状态机(`evaluatePersistentPaneMigration`)按「provenance」判定:policy-off 下**只有拿到 policy-off tombstone(且无隔离 marker)才允许 warm reattach**;带旧隔离 marker、或**两个证明文件都没有**(隔离 marker 写入是 best-effort,"无 marker" 不等于"从未隔离",故 fail-closed)的存活 pane 一律 kill + 重选后端 + 冷启动(不再 confined)。关键时序(fail-closed):provenance 文件**只在 kill 成功且 post-kill probe 确认终止之后**才删除(删除本身再验证消失,删不掉则拒绝启动),绝不在 kill 前删——否则 kill/probe 失败会留下"活着但无证据"的受限 pane,重试时被误判 warm reattach。policy-off 冷启动会写 tombstone 正向标记新 generation(写失败即拒绝启动,避免下次重启误杀);pane 已消失但残留 stale 文件时先清理(验证)再冷启动。四类后端仍只操作各自精确 target。避免了「新策略 OFF 却 warm-reattach 回旧 bwrap/Seatbelt 进程」的语义矛盾。 **下述机制在「owner 显式请求沙盒」时的语义(原文保留)**:HTTP trigger 默认 `workingDir=~`,policy 把整个 home 设 RW;若只 deny 几个 exact 文件,`.dashboard-secret`(daemon IPC 的 trusted-host HMAC,配合 `dashboard-daemons` 端口表可直签 sibling normal-bot daemon 路由绕过全部 gate)、`bots.json.bak/.tmp`、`feishu-session.json`、legacy send-cred 等仍 readWrite;且 policy 语义是 **deepest-prefix wins**,`mandatory deny` 会被更深的 user `sandboxPaths.readWrite` 重开。收口成**权威目录根 profile**: diff --git a/src/adapters/cli/read-isolation.ts b/src/adapters/cli/read-isolation.ts index 068ebd263..2c3508046 100644 --- a/src/adapters/cli/read-isolation.ts +++ b/src/adapters/cli/read-isolation.ts @@ -437,6 +437,30 @@ export function isolatedPaneOriginChannel( } } +/** Directory holding per-session persistent-pane provenance files. */ +export function persistentPaneProvenanceDir(runtimeDataDir: string): string { + return `${runtimeDataDir.replace(/\/+$/, '')}/read-isolation`; +} + +/** ISOLATION marker path (`.boot`) — stamped for a policy-ON sandboxed pane. */ +export function isolationPaneMarkerPath(runtimeDataDir: string, sessionId: string): string { + return `${persistentPaneProvenanceDir(runtimeDataDir)}/${assertSafeAppId(sessionId)}.boot`; +} + +/** TOMBSTONE path (`.policy-off`) — positively proves a live pane was + * cold-spawned by the current NO-SANDBOX policy (see + * {@link evaluatePersistentPaneMigration}). Distinct filename so it survives / + * is cleared independently of the isolation marker. */ +export function policyOffTombstonePath(runtimeDataDir: string, sessionId: string): string { + return `${persistentPaneProvenanceDir(runtimeDataDir)}/${assertSafeAppId(sessionId)}.policy-off`; +} + +/** Tombstone body: a self-describing, version-stamped generation proof. Content + * is diagnostic only — presence (as a real 0600 file) is the signal. */ +export function policyOffTombstoneContent(bootId: string): string { + return JSON.stringify({ version: ISOLATION_PANE_MARKER_VERSION, policyOff: true, bootId }); +} + /** * Decide whether a live persistent pane (tmux/zellij/herdr) may be reattached for * an isolated bot. Isolation is injected at CLI *spawn* time (the Seatbelt @@ -513,33 +537,117 @@ export function isolatedPaneReattachSafe( } /** - * Should the worker's persistent-pane (tmux/zellij/herdr/zmx) reattach guard - * ENGAGE for this spawn? The guard probes a live pane and, if its stamped marker - * does not match the current policy, kills it and cold-spawns. Two spawn shapes - * must engage it: + * Persistent-pane (tmux/zellij/herdr/zmx) reattach migration decision — the pure + * state machine behind the worker's stale-pane guard. Isolation is injected at + * CLI *spawn* time and lives on the RUNNING process, so a pane that survives a + * daemon restart keeps whatever confinement it was born with. This function + * decides, from persisted evidence + the current policy, whether the live pane + * may be warm-reattached or must be killed + cold-spawned under the new policy. + * + * Two provenance files live under `/read-isolation/`: + * · `.boot` — ISOLATION marker: written (best-effort) when a policy-ON + * (sandboxed) pane is spawned. Its capabilities/policy are + * version-checked by {@link isolatedPaneReattachSafe}. + * · `.policy-off` — TOMBSTONE: written when a policy-OFF (no-sandbox) pane + * is cold-spawned, positively proving "this generation was + * created by the current no-sandbox policy". * - * 1. Policy ON (`appliedIsolationCapabilities` non-empty): a surviving pane - * might be a suspend→resume of the same isolated process (reattach OK) or a - * legacy/mismatched one (kill). Always evaluate. - * 2. Policy OFF (no capabilities) but a boot marker is present on disk: the pane - * may be a still-confined process spawned by an OLDER build under the former - * FORCED no-transport isolation. Blindly reattaching it would keep the CLI - * confined against a policy we no longer want — silently violating "read - * scope follows local config" on resume/restart (the 2026-08 no-transport - * 放宽 upgrade path). Engage so the OFF arm can kill + cold-spawn unconfined. + * Why a tombstone and not just "no isolation marker": the isolation stamp is + * BEST-EFFORT (its write is wrapped in try/catch and the spawn proceeds anyway), + * so "no marker" does NOT prove the live process was never isolated — a sandboxed + * pane whose stamp write lost a race/perm/disk error looks identical. Under + * policy-OFF we therefore require POSITIVE proof (tombstone) to warm-reattach; any + * other shape (isolation marker present, or NEITHER file) is treated as + * possibly-still-confined and killed. Absence is never trusted as safe. * - * Policy OFF with NO marker is the ordinary never-isolated session: the guard - * stays disengaged so a normal warm reattach is untouched (no false kill, no - * extra probe). `markerPresentOnDisk` MUST come from a no-follow existence probe - * (a planted/tampered leaf that fails to parse still counts as present, so it can - * never be used to force a silent reattach). Backend/pty applicability is checked - * by the caller. + * The decision is scoped to sessions whose backend can actually be isolated + * (`isolationCapableBackend`, i.e. tmux — pty is never persistent; zellij/zmx/ + * herdr hard-error under sandbox so they can never carry a marker) AND, for the + * migration arm, to `noTransport` sessions (the ONLY ones the removed rule ever + * force-isolated). An ordinary transport-enabled chat was never force-isolated, + * so it is never subjected to the tombstone requirement — no false kills. + * + * Existence flags MUST come from no-follow existence probes (a planted/tampered + * leaf that fails to parse still counts as present, so it can never be used to + * force a silent reattach). Pane liveness is the caller's probe. */ -export function persistentPaneReattachGuardEngaged( - appliedIsolationCapabilities: readonly IsolationCapability[], - markerPresentOnDisk: boolean, -): boolean { - return appliedIsolationCapabilities.length > 0 || markerPresentOnDisk; +export type PersistentPaneMigrationInput = { + /** Current-spawn isolation capabilities (empty ⇒ policy OFF this spawn). */ + appliedIsolationCapabilities: readonly IsolationCapability[]; + /** Backend can carry an isolation sandbox at all (tmux). */ + isolationCapableBackend: boolean; + /** apiOnly bot OR HTTP-virtual chat — the sessions the old rule force-isolated. */ + noTransport: boolean; + /** `.boot` exists on disk (no-follow). */ + isolationMarkerPresent: boolean; + /** `.policy-off` tombstone exists on disk (no-follow). */ + policyOffTombstonePresent: boolean; + /** The persistent pane is currently alive (caller's probe === 'exists'). */ + paneLive: boolean; + /** + * Result of {@link isolatedPaneReattachSafe}(marker, current policy) — only + * meaningful when policy is ON. The caller computes it (it needs the parsed + * marker + policy digest); passed in to keep this function pure. + */ + isolationMarkerReattachSafe: boolean; +}; + +export type PersistentPaneMigrationDecision = + /** Guard does not apply (backend can't isolate, or nothing to evaluate). */ + | { action: 'skip' } + /** Live pane matches the current policy → keep the running process. */ + | { action: 'reattach' } + /** Live pane's provenance is wrong/unknown → kill, then cold-spawn. Marker + + * tombstone must be cleared ONLY AFTER the kill is confirmed (see clearAfterKill). */ + | { action: 'kill-then-cold-spawn'; clearAfterKill: boolean } + /** No live pane, but stale provenance files linger → clear them (verified) then + * cold-spawn fresh, so a later restart doesn't misjudge the new pane. */ + | { action: 'clear-stale-then-cold-spawn' }; + +export function evaluatePersistentPaneMigration( + input: PersistentPaneMigrationInput, +): PersistentPaneMigrationDecision { + const { + appliedIsolationCapabilities, isolationCapableBackend, noTransport, + isolationMarkerPresent, policyOffTombstonePresent, paneLive, + isolationMarkerReattachSafe, + } = input; + const policyOn = appliedIsolationCapabilities.length > 0; + + // A backend that cannot be isolated never carries a marker and cannot have been + // confined, so the migration guard is irrelevant. (Callers also skip pty.) + if (!isolationCapableBackend) return { action: 'skip' }; + + if (policyOn) { + // Policy ON: only a live pane stamped under the CURRENT policy may reattach; + // a legacy/mismatched one is killed. No live pane → nothing to guard. + if (!paneLive) return { action: 'skip' }; + if (isolationMarkerReattachSafe) return { action: 'reattach' }; + return { action: 'kill-then-cold-spawn', clearAfterKill: true }; + } + + // Policy OFF. Only no-transport sessions could ever have been force-isolated by + // the removed rule; an ordinary chat was never confined, so leave it untouched + // (no tombstone requirement, no probe, no false kill). + if (!noTransport) return { action: 'skip' }; + + const hasStaleFiles = isolationMarkerPresent || policyOffTombstonePresent; + + if (paneLive) { + // Warm reattach is allowed ONLY with positive proof the live generation is a + // known policy-off pane: a tombstone present AND no isolation marker. Any + // other shape — isolation marker present, or NEITHER file (absence never + // proves "was never isolated", since the isolation stamp is best-effort) — + // is treated as possibly-still-confined and killed. + const provenPolicyOffGeneration = policyOffTombstonePresent && !isolationMarkerPresent; + if (provenPolicyOffGeneration) return { action: 'reattach' }; + return { action: 'kill-then-cold-spawn', clearAfterKill: true }; + } + + // No live pane. If stale provenance files linger they must be cleared (verified) + // before the fresh cold-spawn, or a later restart would misjudge the new pane. + if (hasStaleFiles) return { action: 'clear-stale-then-cold-spawn' }; + return { action: 'skip' }; } function dedupe(xs: string[]): string[] { diff --git a/src/worker.ts b/src/worker.ts index 80bf622c2..b470f5f99 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -28,7 +28,10 @@ import { buildSeatbeltProfile, isolatedPaneOriginChannel, isolatedPaneReattachSafe, - persistentPaneReattachGuardEngaged, + evaluatePersistentPaneMigration, + isolationPaneMarkerPath, + policyOffTombstonePath, + policyOffTombstoneContent, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, @@ -11887,16 +11890,25 @@ async function spawnCli( // by the no-follow existence probe (a planted/tampered leaf that reads as null // still counts as present, so it cannot be used to force a silent reattach). let persistentPaneOriginChannelId: string | undefined; - const stalePaneMarkerPath = join( - isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`, - ); - // When the policy is OFF, a boot marker on disk is the signal that a - // previously-isolated pane may still be alive and must be re-evaluated rather - // than blindly reattached (see persistentPaneReattachGuardEngaged). Probed with - // the no-follow existence check so a planted/tampered leaf still counts. + const stalePaneMarkerPath = isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId); + const policyOffTombstoneFilePath = policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId); + // no-transport (apiOnly bot OR HTTP virtual chat) is the ONLY session shape the + // removed force-isolation rule ever confined; the policy-off migration arm is + // scoped to it so an ordinary transport-enabled chat is never subjected to the + // tombstone requirement (no false kills). Computed locally — the merge-scoped + // `noTransport` above is out of scope here. + const noTransportSession = cfg.apiOnly === true + || cfg.chatId?.startsWith('http_async_') === true + || cfg.chatId?.startsWith('http_wait_') === true; + const isolationCapableBackend = effectiveBackendType === 'tmux'; + // Existence via no-follow probes so a planted/tampered leaf still counts as + // present and can never be used to force a silent reattach. const stalePaneMarkerPresent = hostEntryExistsNoFollow(stalePaneMarkerPath); - if (persistentPaneReattachGuardEngaged(appliedIsolationCapabilities, stalePaneMarkerPresent) - && persistentSessionName && effectiveBackendType !== 'pty') { + const policyOffTombstonePresent = hostEntryExistsNoFollow(policyOffTombstoneFilePath); + const persistentPaneMigrationEvidence = appliedIsolationCapabilities.length > 0 + || (noTransportSession && isolationCapableBackend + && (stalePaneMarkerPresent || policyOffTombstonePresent)); + if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneMigrationEvidence) { const persistentTarget = selectedBackend.persistentBackendTarget; // ZMX ownership is verified against the frozen PID, not just the name — a // same-named session may belong to the user or to a newer generation. @@ -11925,100 +11937,129 @@ async function spawnCli( ); } const paneLive = paneProbe === 'exists'; - if (paneLive) { - const markerPath = stalePaneMarkerPath; - const marker = readManagedOriginAuthorityFile(markerPath); - const originChannelPolicyExpected = !!managedOriginChannelPolicyDigest; - // A stamped pane must match even when the new policy is OFF. Otherwise a - // disable followed by restart could reattach the still-confined process - // without rebuilding its authority/profile. An unsafe planted marker - // leaf is treated as stamped/unknown by the no-follow existence check. - const policyMatches = appliedIsolationCapabilities.length > 0 - ? isolatedPaneReattachSafe(marker, { - requiredCapabilities: appliedIsolationCapabilities, - exactCapabilities: true, - ...(originChannelPolicyExpected ? { - readIsolation: willReadIsolate, - writeSandbox: willWriteSandbox, - requireOriginChannel: true, - policyDigest: managedOriginChannelPolicyDigest, - } : {}), - }) - : marker === null && !hostEntryExistsNoFollow(markerPath); - if (policyMatches) { - if (originChannelPolicyExpected) { - persistentPaneOriginChannelId = isolatedPaneOriginChannel(marker); - } - // Pane was spawned under the current isolation policy → still confined - // on the running process across daemon restarts; warm reattach preserves - // resume/context + tmux idle-suspend. - log(`[read-isolation] reattaching isolated persistent pane (${cfg.sessionId})`); - } else { - // Missing/legacy marker → pane predates the current policy and may retain - // obsolete permissions. Kill it before publishing any new capability. - log(`[read-isolation] legacy/unmarked persistent pane for ${cfg.sessionId} — killing + cold-spawning with current policy`); - // Remove the stale on-disk marker BEFORE the kill. If the new policy is - // OFF we cold-spawn unconfined and write NO new marker (see the stamp gate - // below, still keyed on capabilities>0), so a surviving marker would make - // every later restart re-enter here and kill the freshly-spawned pane — an - // infinite kill loop. A policy-ON cold-spawn re-stamps a fresh marker after - // reattach is ruled out, so clearing it here is safe in both directions. - try { unlinkSync(stalePaneMarkerPath); } catch { /* absent / already gone */ } - // Capture the name before re-selection: `persistentSessionName` is - // reassigned from the new selection below and widens back to - // `string | undefined`, but the backing name we are tearing down is - // this one and does not change. - const staleSessionName = persistentSessionName; - const stalePersistentTarget = selectedBackend.persistentBackendTarget; - try { - // ZMX keeps its own call here rather than going through the target - // helper: only this path holds the frozen PID, which makes the - // ownership check stricter than the name+label check. - if (effectiveBackendType === 'zmx') { - ZmxBackend.killManagedSession( - persistentSessionName, - cfg.sessionId, - resolvedZmxSessionPid, - ); - } else { - if (stalePersistentTarget) killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId); - else killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName, cfg.sessionId); - } - } catch (e) { - throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${(e as Error).message})`); - } - const postKillProbe = effectiveBackendType === 'zmx' - ? probeOwnedZmxSession(staleSessionName, cfg.sessionId).probe - : (stalePersistentTarget - ? probePersistentBackendTarget(stalePersistentTarget) - : probePersistentSession( - effectiveBackendType as PersistentBackendType, - staleSessionName, - )); - if (shouldRejectPersistentPostKillProbe( - effectiveBackendType as PersistentBackendType, - postKillProbe, - )) { - throw new Error( - `[read-isolation] refusing to start session ${cfg.sessionId}: ` + - `could not confirm stale ${effectiveBackendType} pane termination`, - ); - } + const markerPath = stalePaneMarkerPath; + const marker = paneLive ? readManagedOriginAuthorityFile(markerPath) : null; + const originChannelPolicyExpected = !!managedOriginChannelPolicyDigest; + // isolatedPaneReattachSafe only means anything under a policy-ON spawn; the + // state machine consults it only in that arm. + const isolationMarkerReattachSafe = appliedIsolationCapabilities.length > 0 + && isolatedPaneReattachSafe(marker, { + requiredCapabilities: appliedIsolationCapabilities, + exactCapabilities: true, + ...(originChannelPolicyExpected ? { + readIsolation: willReadIsolate, + writeSandbox: willWriteSandbox, + requireOriginChannel: true, + policyDigest: managedOriginChannelPolicyDigest, + } : {}), + }); + const migration = evaluatePersistentPaneMigration({ + appliedIsolationCapabilities, + isolationCapableBackend, + noTransport: noTransportSession, + isolationMarkerPresent: stalePaneMarkerPresent, + policyOffTombstonePresent, + paneLive, + isolationMarkerReattachSafe, + }); + // Verified removal of a provenance file: unlink then confirm it is truly gone + // (no-follow). A leaf we cannot remove (directory / planted / perm) must FAIL + // CLOSED — never fall through to publish a new generation, or a later restart + // re-reads the stale proof and mis-kills the fresh pane in a loop. + const removeProvenanceOrThrow = (path: string, label: string): void => { + try { unlinkSync(path); } catch { /* may already be absent — verified below */ } + if (hostEntryExistsNoFollow(path)) { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + `could not remove stale ${label} at ${path}`, + ); + } + }; + if (migration.action === 'reattach') { + if (originChannelPolicyExpected) { + persistentPaneOriginChannelId = isolatedPaneOriginChannel(marker); + } + // Pane matches the current policy (isolated pane stamped under it, or a + // no-transport pane positively proven to be a policy-off generation) → + // still valid on the running process across daemon restarts; warm reattach + // preserves resume/context + tmux idle-suspend. + log(`[read-isolation] reattaching persistent pane under current policy (${cfg.sessionId})`); + } else if (migration.action === 'clear-stale-then-cold-spawn') { + // No live pane, but stale provenance files linger (e.g. a prior kill whose + // cold-spawn crashed). Clear BOTH (verified) so the fresh spawn below is not + // misjudged on the next restart. Fail closed if a leaf cannot be removed. + log(`[read-isolation] clearing stale provenance for dead pane before cold-spawn (${cfg.sessionId})`); + if (stalePaneMarkerPresent) removeProvenanceOrThrow(stalePaneMarkerPath, 'isolation marker'); + if (policyOffTombstonePresent) removeProvenanceOrThrow(policyOffTombstoneFilePath, 'policy-off tombstone'); + } else if (migration.action === 'kill-then-cold-spawn') { + // Live pane whose provenance is wrong/unknown (legacy isolated marker, or a + // no-transport pane lacking a policy-off tombstone) → kill before publishing + // any new capability. Provenance files are cleared ONLY AFTER the kill is + // confirmed (below) — clearing before the kill would, if the kill/probe + // fails, leave the still-alive confined pane with no on-disk evidence, so a + // retry would warm-reattach it (codex R2). + log(`[read-isolation] persistent pane provenance mismatch for ${cfg.sessionId} — killing + cold-spawning with current policy`); + // Capture the name before re-selection: `persistentSessionName` is + // reassigned from the new selection below and widens back to + // `string | undefined`, but the backing name we are tearing down is + // this one and does not change. + const staleSessionName = persistentSessionName; + const stalePersistentTarget = selectedBackend.persistentBackendTarget; + try { + // ZMX keeps its own call here rather than going through the target + // helper: only this path holds the frozen PID, which makes the + // ownership check stricter than the name+label check. if (effectiveBackendType === 'zmx') { - resolvedZmxSessionProbe = postKillProbe; - resolvedZmxSessionPid = undefined; + ZmxBackend.killManagedSession( + persistentSessionName, + cfg.sessionId, + resolvedZmxSessionPid, + ); + } else { + if (stalePersistentTarget) killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId); + else killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName, cfg.sessionId); } - // ZMX backend selection consumes the frozen probe. Refresh it before - // re-selecting or the replacement keeps isReattach=true for the pane - // that this gate just proved was removed. - selectedBackend = selectBackend(); - isTmuxMode = selectedBackend.isTmuxMode; - isPipeMode = selectedBackend.isPipeMode; - isZellijMode = selectedBackend.isZellijMode; - backend = selectedBackend.backend; - cliLifetimeNonce++; - persistentSessionName = selectedBackend.persistentSessionName; + } catch (e) { + throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${(e as Error).message})`); } + const postKillProbe = effectiveBackendType === 'zmx' + ? probeOwnedZmxSession(staleSessionName, cfg.sessionId).probe + : (stalePersistentTarget + ? probePersistentBackendTarget(stalePersistentTarget) + : probePersistentSession( + effectiveBackendType as PersistentBackendType, + staleSessionName, + )); + if (shouldRejectPersistentPostKillProbe( + effectiveBackendType as PersistentBackendType, + postKillProbe, + )) { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + `could not confirm stale ${effectiveBackendType} pane termination`, + ); + } + // Kill CONFIRMED — only now is it safe to drop the provenance files. Fail + // closed if either cannot be verifiably removed (else a policy-off restart + // would misjudge the fresh pane and mis-kill it every time). + if (migration.clearAfterKill) { + if (stalePaneMarkerPresent) removeProvenanceOrThrow(stalePaneMarkerPath, 'isolation marker'); + if (policyOffTombstonePresent) removeProvenanceOrThrow(policyOffTombstoneFilePath, 'policy-off tombstone'); + } + if (effectiveBackendType === 'zmx') { + resolvedZmxSessionProbe = postKillProbe; + resolvedZmxSessionPid = undefined; + } + // ZMX backend selection consumes the frozen probe. Refresh it before + // re-selecting or the replacement keeps isReattach=true for the pane + // that this gate just proved was removed. + selectedBackend = selectBackend(); + isTmuxMode = selectedBackend.isTmuxMode; + isPipeMode = selectedBackend.isPipeMode; + isZellijMode = selectedBackend.isZellijMode; + backend = selectedBackend.backend; + cliLifetimeNonce++; + persistentSessionName = selectedBackend.persistentSessionName; } } readIsolationOriginChannelId = managedOriginChannelRequired @@ -13196,15 +13237,17 @@ async function spawnCli( log(`Sandbox ON (${cfg.cliId}, fs-policy ${policy.rules.length} rules): outbox=${sbx.outbox}`); } } - // Fresh sandboxed spawn on a persistent backend: stamp the pane with this - // daemon's boot id so a later reattach can be trusted (see the stale-pane - // guard above). pty needs no marker (never reattached). + // Fresh spawn on a persistent backend: stamp provenance so a later reattach can + // be judged (see the stale-pane guard above). pty needs no marker (never + // reattached). Policy ON → ISOLATION marker; policy OFF on a no-transport, + // isolation-capable (tmux) session → POLICY-OFF TOMBSTONE, positively proving + // this generation is the new no-sandbox policy (so a later restart does not + // mistake it for a possibly-still-confined legacy pane and kill it). if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) { try { - const markerDir = join(isolationRuntimeDataDir, 'read-isolation'); - mkdirSync(markerDir, { recursive: true }); + mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true }); replaceManagedOriginCapabilityFile( - join(markerDir, `${cfg.sessionId}.boot`), + isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId), isolationPaneMarkerContent( cfg.daemonBootId ?? '', appliedIsolationCapabilities, @@ -13219,6 +13262,25 @@ async function spawnCli( ), ); } catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ } + } else if (appliedIsolationCapabilities.length === 0 && persistentSessionName + && !willReattachPersistent && noTransportSession && isolationCapableBackend) { + // Policy-OFF generation proof. Unlike the isolation marker this is NOT + // best-effort: if we cannot durably record that this no-transport pane is a + // known policy-off generation, a later restart would (correctly, fail-closed) + // treat the unproven live pane as possibly-still-confined and kill it. Rather + // than spawn a pane we cannot prove, FAIL CLOSED here. + try { + mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true }); + replaceManagedOriginCapabilityFile( + policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId), + policyOffTombstoneContent(cfg.daemonBootId ?? ''), + ); + } catch (e) { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + `could not record policy-off generation tombstone (${(e as Error).message})`, + ); + } } // 通用启动前缀(wrapperCli):把启动命令重写成 ` `(首 token 当 diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index e9e752e83..17ff4640e 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -447,28 +447,39 @@ describe('API-only bot mode — no-transport fs-policy authority provenance (wor expect(workerSource).toContain('no-transport suppressed'); }); - it('persistent-pane guard engages on policy-OFF migration and clears the stale marker before killing (no kill-loop)', () => { - // 2026-08 no-transport 放宽 migration: the reattach guard must be driven by - // persistentPaneReattachGuardEngaged (which engages when the policy is OFF but - // a boot marker survives on disk), NOT the old bare `capabilities.length > 0` - // gate that skipped policy-off entirely and warm-reattached a still-confined - // legacy pane. Behavioral truth table lives in read-isolation.test.ts. - expect(workerSource).toContain('const stalePaneMarkerPresent = hostEntryExistsNoFollow(stalePaneMarkerPath);'); - expect(workerSource).toContain( - 'if (persistentPaneReattachGuardEngaged(appliedIsolationCapabilities, stalePaneMarkerPresent)', - ); - // The kill branch MUST unlink the on-disk marker before killing: a policy-off - // cold-spawn writes NO new marker (the stamp is still gated on capabilities>0), - // so a surviving marker would re-engage the guard every restart and kill the - // freshly-spawned pane forever. Assert the unlink precedes the kill. + it('persistent-pane guard is driven by evaluatePersistentPaneMigration and clears provenance only AFTER kill is confirmed', () => { + // 2026-08 no-transport 放宽 migration: the reattach guard delegates to the + // pure state machine evaluatePersistentPaneMigration; behavioral truth table + // (incl. the crash/teardown branches) lives in read-isolation.test.ts. Here we + // lock the WORKER WIRING that the pure fn cannot cover. + expect(workerSource).toContain('const migration = evaluatePersistentPaneMigration({'); + // The gate enters for policy-ON, or a no-transport isolation-capable session + // with stale provenance — NOT the old bare `capabilities.length > 0`. + expect(workerSource).toContain('const persistentPaneMigrationEvidence = appliedIsolationCapabilities.length > 0'); + expect(workerSource).not.toContain('persistentPaneReattachGuardEngaged'); + // Provenance removal is VERIFIED (unlink → re-probe → throw if still present), + // so an un-removable leaf fails closed instead of looping kills. + const remover = region(workerSource, + 'const removeProvenanceOrThrow =', 'if (migration.action === '); + expect(remover).toContain('hostEntryExistsNoFollow(path)'); + expect(remover).toContain('could not remove stale'); + // CRITICAL ORDER (codex R2): in the kill branch the provenance clear must come + // AFTER the post-kill probe confirmation, never before the kill — else a failed + // kill/probe leaves a live confined pane with no evidence and a retry reattaches + // it. Assert clearAfterKill runs after shouldRejectPersistentPostKillProbe. const killBlock = region(workerSource, - '[read-isolation] legacy/unmarked persistent pane', - 'let willReattachPersistent'); - const unlink = killBlock.indexOf('unlinkSync(stalePaneMarkerPath)'); - const kill = killBlock.indexOf('killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId)'); - expect(unlink).toBeGreaterThan(-1); - expect(kill).toBeGreaterThan(-1); - expect(unlink).toBeLessThan(kill); + "if (migration.action === 'kill-then-cold-spawn')", + 'selectedBackend = selectBackend();'); + const killCall = killBlock.indexOf('could not kill stale persistent pane'); + const postKillReject = killBlock.indexOf('shouldRejectPersistentPostKillProbe('); + const clear = killBlock.indexOf('if (migration.clearAfterKill) {'); + expect(killCall).toBeGreaterThan(-1); + expect(postKillReject).toBeGreaterThan(killCall); + expect(clear).toBeGreaterThan(postKillReject); + // Policy-OFF cold-spawn of a no-transport isolation-capable pane MUST record a + // tombstone, and FAIL CLOSED if it cannot (else next restart mis-kills it). + expect(workerSource).toContain('policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId)'); + expect(workerSource).toContain('could not record policy-off generation tombstone'); }); it('daemon freezes the actual loaded bots-config path into the worker init message', () => { diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index fff41b0eb..9c977b88c 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -156,7 +156,7 @@ describe('persistent backend cold-restart ordering', () => { // Each `killPersistentBackendTarget` / `ZmxBackend.killManagedSession` gate // must be followed by a re-selection before the backend is used. const gates = [ - workerSource.indexOf('[read-isolation] legacy/unmarked persistent pane'), + workerSource.indexOf('[read-isolation] persistent pane provenance mismatch'), workerSource.indexOf('if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length'), ]; for (const gate of gates) { @@ -191,7 +191,7 @@ describe('persistent backend cold-restart ordering', () => { it('limits inconclusive-probe startup rejection to ZMX in both persistent gates', () => { const readIsolationStart = workerSource.indexOf( - 'if (persistentPaneReattachGuardEngaged(appliedIsolationCapabilities, stalePaneMarkerPresent)', + "if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneMigrationEvidence) {", ); const readIsolationEnd = workerSource.indexOf('let willReattachPersistent', readIsolationStart); const mcpStart = workerSource.indexOf( @@ -218,7 +218,7 @@ describe('persistent backend cold-restart ordering', () => { }); it('verifies read-isolation teardown against the exact captured backend target', () => { - const start = workerSource.indexOf('[read-isolation] legacy/unmarked persistent pane'); + const start = workerSource.indexOf('[read-isolation] persistent pane provenance mismatch'); const end = workerSource.indexOf('let willReattachPersistent', start); const gate = workerSource.slice(start, end); const capture = gate.indexOf( @@ -242,7 +242,7 @@ describe('persistent backend cold-restart ordering', () => { }); it('refreshes the frozen ZMX probe before read-isolation re-selects the backend', () => { - const start = workerSource.indexOf('[read-isolation] legacy/unmarked persistent pane'); + const start = workerSource.indexOf('[read-isolation] persistent pane provenance mismatch'); const end = workerSource.indexOf('let willReattachPersistent', start); const gate = workerSource.slice(start, end); const postKillProbe = gate.indexOf('const postKillProbe ='); diff --git a/test/read-isolation.test.ts b/test/read-isolation.test.ts index 6c7f83eca..885ac53f7 100644 --- a/test/read-isolation.test.ts +++ b/test/read-isolation.test.ts @@ -9,7 +9,7 @@ import { buildCredentialIsolationRules, isolatedPaneOriginChannel, isolatedPaneReattachSafe, - persistentPaneReattachGuardEngaged, + evaluatePersistentPaneMigration, isolationPaneMarkerContent, ISOLATION_PANE_MARKER_VERSION, isolationPanePolicyDigest, @@ -315,35 +315,110 @@ describe('isolatedPaneReattachSafe', () => { // ─── cold-start migration: START-TIME env contract (bots.json EPERM fix) ────── -describe('persistentPaneReattachGuardEngaged — policy-off migration re-evaluates stale isolated panes', () => { - // The worker's persistent-pane guard probes a live pane and, when its stamped - // marker does not match the current policy, kills it + cold-spawns. This helper - // is the ENTRY decision for that guard. Its correctness is the fix for the - // 2026-08 no-transport 放宽 upgrade path: an apiOnly / HTTP-virtual session that - // was FORCE-isolated by an older build leaves a live confined pane + boot marker; - // after upgrade the new policy is OFF, and without this the guard's outer gate - // (formerly `capabilities.length > 0`) skipped evaluation entirely and warm- - // reattached the still-confined process. +describe('evaluatePersistentPaneMigration — policy-on/off pane provenance state machine', () => { + // Pure decision behind the worker's stale-pane guard (worker.ts). Covers the + // 2026-08 no-transport 放宽 upgrade path AND the crash/teardown-failure branches + // codex flagged. `isolationMarkerReattachSafe` is the caller's precomputed + // isolatedPaneReattachSafe() result (only meaningful under policy ON). const CAPS_ON = ['credential', 'read', 'write'] as const; const CAPS_OFF = [] as const; + const base = { + isolationCapableBackend: true, + noTransport: true, + isolationMarkerPresent: false, + policyOffTombstonePresent: false, + paneLive: true, + isolationMarkerReattachSafe: false, + }; + + it('non-isolation-capable backend → skip (zellij/zmx/herdr can never carry a marker)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, isolationCapableBackend: false, + isolationMarkerPresent: true, + })).toEqual({ action: 'skip' }); + }); + + // ── policy ON ── + it('policy ON + live pane stamped under current policy → reattach', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_ON, + isolationMarkerPresent: true, isolationMarkerReattachSafe: true, + })).toEqual({ action: 'reattach' }); + }); + + it('policy ON + live pane whose marker does NOT match → kill + cold-spawn (clear after kill)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_ON, + isolationMarkerPresent: true, isolationMarkerReattachSafe: false, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); + + it('policy ON + no live pane → skip (nothing to guard; fresh spawn stamps)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_ON, paneLive: false, + })).toEqual({ action: 'skip' }); + }); + + // ── policy OFF, no-transport migration arm ── + it('policy OFF + live pane proven policy-off (tombstone, no isolation marker) → reattach', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, + policyOffTombstonePresent: true, isolationMarkerPresent: false, + })).toEqual({ action: 'reattach' }); + }); + + it('policy OFF + live pane with legacy ISOLATION marker → kill + cold-spawn (the core regression)', () => { + // Old forced-isolation pane still alive & stamped; new policy OFF. Must kill, + // not warm-reattach the still-confined process. + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, + isolationMarkerPresent: true, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); + + it('policy OFF + live pane with NEITHER file → kill (absence never proves "never isolated")', () => { + // issue-3 root fix: the isolation stamp is best-effort, so "no marker" cannot + // be trusted as safe. Without positive tombstone proof the live pane is killed. + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, + isolationMarkerPresent: false, policyOffTombstonePresent: false, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); + + it('policy OFF + live pane with BOTH files (marker wins) → kill', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, + isolationMarkerPresent: true, policyOffTombstonePresent: true, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); + + it('policy OFF + pane MISSING but stale marker lingers → clear stale then cold-spawn (no next-restart false kill)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, + paneLive: false, isolationMarkerPresent: true, + })).toEqual({ action: 'clear-stale-then-cold-spawn' }); + }); - it('policy ON always engages the guard (marker present or not)', () => { - // A suspend→resume of the SAME isolated process (marker present) and a fresh - // isolated spawn whose marker was lost (absent) must both be evaluated. - expect(persistentPaneReattachGuardEngaged(CAPS_ON, true)).toBe(true); - expect(persistentPaneReattachGuardEngaged(CAPS_ON, false)).toBe(true); + it('policy OFF + pane MISSING but stale tombstone lingers → clear stale then cold-spawn', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, + paneLive: false, policyOffTombstonePresent: true, + })).toEqual({ action: 'clear-stale-then-cold-spawn' }); }); - it('policy OFF + stale marker present → engages (so the OFF arm kills + cold-spawns unconfined)', () => { - // THE regression: old forced-isolation pane still alive & stamped, new policy - // OFF. Must engage, not silently reattach the confined process. - expect(persistentPaneReattachGuardEngaged(CAPS_OFF, true)).toBe(true); + it('policy OFF + pane MISSING + no files → skip (nothing stale to clear)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, paneLive: false, + })).toEqual({ action: 'skip' }); }); - it('policy OFF + no marker → does NOT engage (ordinary never-isolated session, no false kill)', () => { - // A normal chat / no-transport session that was never isolated must warm- - // reattach untouched — no extra probe, no spurious kill. - expect(persistentPaneReattachGuardEngaged(CAPS_OFF, false)).toBe(false); + it('policy OFF + TRANSPORT-ENABLED chat → skip even with a marker (never force-isolated, no false kill)', () => { + // Ordinary chats were never subject to the removed rule; the tombstone + // requirement must not touch them. + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, noTransport: false, + isolationMarkerPresent: true, + })).toEqual({ action: 'skip' }); }); }); From 1579363f449234c34b09855e05e83d4ada1a09f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Sun, 16 Aug 2026 13:09:49 -0700 Subject: [PATCH 4/8] =?UTF-8?q?fix(sandbox):=20=E6=8C=81=E4=B9=85=20pane?= =?UTF-8?q?=20=E8=BF=81=E7=A7=BB=E7=8A=B6=E6=80=81=E6=9C=BA=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20worker=20=E6=8E=A5=E7=BA=BF/=E6=AD=A3=E4=BA=A4?= =?UTF-8?q?=E8=BE=B9=E7=95=8C/tombstone=20=E5=8F=AF=E4=BF=A1=E8=AF=81?= =?UTF-8?q?=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景(复审第三轮 3 阻断) 上一版状态机语义对,但 worker 接线与 provenance 不变量仍有 3 个真 bug: 1. **NEITHER-file 修复产品路径不可达**:外层 gate 仍要求 marker/tombstone 至少 一个在,两文件都无时根本不 probe/不进状态机→best-effort marker 写失败的旧隔离 pane 升级后仍直接 warm reattach。issue3 只在纯函数测了、产品没修。 2. **`isolationCapableBackend=tmux` 早退破坏 device-credential 正交边界**: `credential` cap 对 enrolled 设备独立注入,credential-only wrapper 用于 zellij/herdr/zmx 且 stamp gate 给它们写 marker;状态机开头 `!isolationCapableBackend → skip` 让这些非 tmux 的 credential-only pane 不再校验旧/缺失/mismatch marker →可能 warm reattach 策略不匹配的 credential pane。 3. **tombstone 非可信 generation-bound 证明**:worker 仅 `lstat` 存在即授权 reattach(空/目录/symlink/坏内容都算);且 policy-on+pane missing 不清旧 tombstone→若 isolated marker best-effort 写失败,未来 policy-off 会误认。 ## 改了什么 **issue1(worker gate)**:入口条件改为 `appliedIsolationCapabilities.length > 0 || (noTransportSession && isolationCapableBackend)`——**不再要求 provenance 已存在**, NEITHER-file 的 no-transport tmux pane 也进状态机,由真实 paneLive 决策 kill。 **issue2(状态机 scope 拆分)**:policy-ON capability 校验对**所有** persistent backend 生效(去掉开头的 `!isolationCapableBackend→skip` 早退);`isolationCapableBackend` (tmux)限制只 scope policy-OFF 文件沙盒迁移臂。credential-only 非 tmux pane 的 mismatch marker 现在正确 kill。 **issue3(tombstone 可信 + 清理 + 时序)**: - 新增 `policyOffTombstoneValid()`——secure-read(worker 用 `readManagedOriginAuthorityFile` 校验真实 0600)后再 schema/version 校验;present(触发清理/保守 kill)与 valid (授权 reattach)分开。bootId 仅诊断、不与当前 boot id 比对(合法 policy-off pane 须跨 restart reattach)。 - policy-on + pane missing + 残留 tombstone → clear-stale(不再 skip)。 - stamp block 互斥:policy-on 写 marker 前清 stale tombstone(best-effort); policy-off 写 tombstone 前清 stale marker(verified,marker dominate 必须清净)。 **可注入 IO seam**:抽 `executePersistentPaneMigration(decision, effects)`——把 kill→confirm→clear→reselect 的有序副作用 + stop-on-failure 语义从 worker 提出来, effects(killStalePane/confirmPaneGone/clearProvenanceVerified/reselectBackend) 可注入 mock。worker 提供真实实现。 ## 测试(行为,非纯 source-lock) - read-isolation.test.ts:状态机真值表扩到 ~18 条(含 credential-only 非 tmux mismatch/match、tombstone present-but-invalid→kill、NEITHER→kill、marker dominate、非 scope 死 pane 仍清);**executor seam 行为测试**(注入 mock 观察 kill→confirm→clear→reselect 顺序 + kill失败/post-kill拒绝/clear失败各自"不调用" 后续);`policyOffTombstoneValid` 校验表。 - api-only-mode-wiring.test.ts:worker 接线 source-lock 重写(gate 无 provenance 前置/executor 驱动/tombstone secure-read 校验/effects 五闭包/写失败拒绝)。 - backend-gate.test.ts:region anchor 同步到 effects 结构。 - 反向变异:①恢复 issue2 早退→credential-only 非 tmux 测试红 ②executor clear 前移到 confirm 前→3 顺序测试红;均已还原。 - 定向 11 文件 487 pass/1 skip;build 绿;git diff --check 干净。 ## 文档 设计文档升级迁移段重写:policy-on 全后端校验、policy-off tombstone secure-read+ bootId 不比对、执行器 fail-closed 时序、无 live pane 必清 provenance。 --- .../2026-07-30-api-only-core-only-bot-mode.md | 6 +- src/adapters/cli/read-isolation.ts | 198 ++++++++++++++---- src/worker.ts | 178 +++++++++------- test/api-only-mode-wiring.test.ts | 59 +++--- test/backend-gate.test.ts | 12 +- test/read-isolation.test.ts | 174 +++++++++++++-- 6 files changed, 449 insertions(+), 178 deletions(-) diff --git a/docs/design/2026-07-30-api-only-core-only-bot-mode.md b/docs/design/2026-07-30-api-only-core-only-bot-mode.md index c5226158e..573bf55a3 100644 --- a/docs/design/2026-07-30-api-only-core-only-bot-mode.md +++ b/docs/design/2026-07-30-api-only-core-only-bot-mode.md @@ -142,7 +142,11 @@ rebase master → 开 PR(中文 + 影响面)→ 发 canary → 配一个 `ap > > **两条正交边界不受本次放宽影响,是放宽后剩下的安全网**:① **本 bot 自身 transport secret 的 env 扣留**(`LARK_APP_SECRET`/`larkAppSecret` 仍 gated on `larkTransportEnabled`)——这只关闭 **Botmux 内建的 transport 调用链**(本 bot 自己的 send 路径),**不构成恶意代码下的凭证隔离**:不开沙盒时 agent 仍能从磁盘 bots.json 读出 secret 自行调 Lark;② **device-credential 强制隔离**(worker.ts `credentialIsolationRequired`)——enrolled 设备上独立强制 mask 设备授权目录/凭证,与文件沙盒 toggle 无关。注意:旧代码里 no-transport 被强制全沙盒会让 `fullIsolationCoversCredentials=true` 从而**跳过** credential-only gate;放宽后无 sandbox 的 no-transport 会话会让该 gate 在 enrolled 机器上真正 engage,是正确的 fail-closed 方向。 > -> **升级迁移(持久后端)**:旧版本在 forced-isolation 下创建的 no-transport 持久 pane 会带 `credential/read/write` 隔离 marker(沙盒只作用于 tmux,故实际只有 tmux pane 可能带 marker)。升级到本放宽 + daemon 重启后,worker 侧持久 pane 迁移状态机(`evaluatePersistentPaneMigration`)按「provenance」判定:policy-off 下**只有拿到 policy-off tombstone(且无隔离 marker)才允许 warm reattach**;带旧隔离 marker、或**两个证明文件都没有**(隔离 marker 写入是 best-effort,"无 marker" 不等于"从未隔离",故 fail-closed)的存活 pane 一律 kill + 重选后端 + 冷启动(不再 confined)。关键时序(fail-closed):provenance 文件**只在 kill 成功且 post-kill probe 确认终止之后**才删除(删除本身再验证消失,删不掉则拒绝启动),绝不在 kill 前删——否则 kill/probe 失败会留下"活着但无证据"的受限 pane,重试时被误判 warm reattach。policy-off 冷启动会写 tombstone 正向标记新 generation(写失败即拒绝启动,避免下次重启误杀);pane 已消失但残留 stale 文件时先清理(验证)再冷启动。四类后端仍只操作各自精确 target。避免了「新策略 OFF 却 warm-reattach 回旧 bwrap/Seatbelt 进程」的语义矛盾。 +> **升级迁移(持久后端)**:worker 侧持久 pane 迁移状态机(`evaluatePersistentPaneMigration`,纯函数)+ 可注入的有序副作用执行器(`executePersistentPaneMigration`)按「provenance」判定: +> - **policy-ON(文件沙盒 OR credential-only)对所有 persistent backend 生效**:credential-only wrapper 在 enrolled 设备上也会用于 zellij/herdr/zmx 并写 marker,故 capability 校验**不限 tmux**;live pane 的 marker 与当前策略 exact-match 才 warm reattach,否则 kill。 +> - **policy-OFF 迁移臂**scope 到 no-transport + tmux(旧强制文件隔离只作用 tmux):**只有拿到 secure-read + schema/version 校验通过的 policy-off tombstone(且无隔离 marker)才允许 warm reattach**——tombstone 仅靠 lstat「存在」不够(空/目录/symlink/坏内容都算存在),必须 `policyOffTombstoneValid` 通过;带旧隔离 marker(marker dominate)、tombstone 缺失/无效、或**两文件都无**(隔离 marker 写入 best-effort,"无 marker"≠"从未隔离",fail-closed)的存活 pane 一律 kill + 重选后端 + 冷启动。tombstone 的 bootId 仅诊断、**不与当前 daemon boot id 比对**(否则合法 policy-off pane 每次重启都冷启)。 +> - **关键时序(fail-closed,在执行器内保证且可单测)**:kill → post-kill probe 确认 → 清 provenance → 重选。kill/probe 失败**在清 provenance 前中止**(证据保留给重试);清 provenance 失败**在重选前中止**(绝不在残留假证明时发布新 generation);清除本身验证消失,删不掉则拒绝启动。 +> - **无 live pane 时**(无论 policy on/off)只要有残留 provenance 就先清理(验证)再冷启动;policy-off 冷启动写 tombstone(清掉任何 stale marker、写失败即拒绝启动),policy-on 冷启动清掉任何 stale tombstone。四类后端仍只操作各自精确 target。避免了「新策略 OFF 却 warm-reattach 回旧 bwrap/Seatbelt 进程」的语义矛盾。 **下述机制在「owner 显式请求沙盒」时的语义(原文保留)**:HTTP trigger 默认 `workingDir=~`,policy 把整个 home 设 RW;若只 deny 几个 exact 文件,`.dashboard-secret`(daemon IPC 的 trusted-host HMAC,配合 `dashboard-daemons` 端口表可直签 sibling normal-bot daemon 路由绕过全部 gate)、`bots.json.bak/.tmp`、`feishu-session.json`、legacy send-cred 等仍 readWrite;且 policy 语义是 **deepest-prefix wins**,`mandatory deny` 会被更深的 user `sandboxPaths.readWrite` 重开。收口成**权威目录根 profile**: diff --git a/src/adapters/cli/read-isolation.ts b/src/adapters/cli/read-isolation.ts index 2c3508046..163a88f94 100644 --- a/src/adapters/cli/read-isolation.ts +++ b/src/adapters/cli/read-isolation.ts @@ -456,11 +456,38 @@ export function policyOffTombstonePath(runtimeDataDir: string, sessionId: string } /** Tombstone body: a self-describing, version-stamped generation proof. Content - * is diagnostic only — presence (as a real 0600 file) is the signal. */ + * is diagnostic-bearing but its PRESENCE-as-valid (not equality to any live boot + * id) is the reattach signal — a legitimate policy-off pane warm-reattaches + * across daemon restarts, so binding to the current boot id would cold-spawn it + * every restart. bootId is kept only for diagnostics. */ export function policyOffTombstoneContent(bootId: string): string { return JSON.stringify({ version: ISOLATION_PANE_MARKER_VERSION, policyOff: true, bootId }); } +/** + * Validate a policy-off tombstone body (already securely read from a real 0600 + * file by the caller — see readManagedOriginAuthorityFile). Returns true only for + * a well-formed CURRENT-version `policyOff:true` record with a non-empty string + * bootId. bootId is NOT compared to the live daemon boot id (a legit policy-off + * pane must reattach across restarts); it only has to be present + a string, so a + * blank/garbage/structurally-wrong tombstone cannot authorize a warm reattach. + * Mirror of {@link isolatedPaneReattachSafe}'s fail-closed parse discipline, but + * for the opposite polarity: here VALID authorizes reattach. + */ +export function policyOffTombstoneValid(content: string | null | undefined): boolean { + try { + const parsed = JSON.parse(content ?? '') as { + version?: unknown; policyOff?: unknown; bootId?: unknown; + }; + return parsed.version === ISOLATION_PANE_MARKER_VERSION + && parsed.policyOff === true + && typeof parsed.bootId === 'string' + && parsed.bootId.trim().length > 0; + } catch { + return false; + } +} + /** * Decide whether a live persistent pane (tmux/zellij/herdr) may be reattached for * an isolated bot. Isolation is injected at CLI *spawn* time (the Seatbelt @@ -556,32 +583,48 @@ export function isolatedPaneReattachSafe( * BEST-EFFORT (its write is wrapped in try/catch and the spawn proceeds anyway), * so "no marker" does NOT prove the live process was never isolated — a sandboxed * pane whose stamp write lost a race/perm/disk error looks identical. Under - * policy-OFF we therefore require POSITIVE proof (tombstone) to warm-reattach; any - * other shape (isolation marker present, or NEITHER file) is treated as + * policy-OFF we therefore require POSITIVE, VALIDATED proof (a tombstone that + * passes secure-read + schema check) to warm-reattach; any other shape (isolation + * marker present, tombstone missing/invalid, or NEITHER file) is treated as * possibly-still-confined and killed. Absence is never trusted as safe. * - * The decision is scoped to sessions whose backend can actually be isolated - * (`isolationCapableBackend`, i.e. tmux — pty is never persistent; zellij/zmx/ - * herdr hard-error under sandbox so they can never carry a marker) AND, for the - * migration arm, to `noTransport` sessions (the ONLY ones the removed rule ever - * force-isolated). An ordinary transport-enabled chat was never force-isolated, - * so it is never subjected to the tombstone requirement — no false kills. + * Scope is split by policy direction: + * · policy ON (file sandbox OR credential-only `credential` cap): the exact- + * capability/policy check runs on EVERY persistent backend — credential-only + * panes exist on zellij/herdr/zmx too, so this must NOT be tmux-scoped. + * · policy OFF migration arm: scoped to `noTransport && isolationCapableBackend` + * (only no-transport tmux was ever file-force-isolated by the removed rule). + * An ordinary transport chat / non-tmux backend is never subjected to the + * tombstone requirement — no false kills — though a DEAD pane's stale + * provenance is still cleared so it cannot mislead a later decision. * * Existence flags MUST come from no-follow existence probes (a planted/tampered * leaf that fails to parse still counts as present, so it can never be used to - * force a silent reattach). Pane liveness is the caller's probe. + * force a silent reattach). `policyOffTombstoneValid` is the secure-read result. + * Pane liveness is the caller's probe. */ export type PersistentPaneMigrationInput = { - /** Current-spawn isolation capabilities (empty ⇒ policy OFF this spawn). */ + /** Current-spawn isolation capabilities (empty ⇒ policy OFF this spawn). May be + * non-empty on ANY persistent backend — `credential` is pushed for enrolled + * hosts independent of the file sandbox, and its wrapper applies to + * tmux/zellij/herdr/zmx alike. So the policy-ON capability check below is NOT + * scoped to tmux. */ appliedIsolationCapabilities: readonly IsolationCapability[]; - /** Backend can carry an isolation sandbox at all (tmux). */ + /** Backend can carry a FILE sandbox (tmux). Scopes ONLY the policy-off + * no-transport migration arm (the removed force-isolation rule only ever + * file-sandboxed tmux); policy-ON capability checks run on every backend. */ isolationCapableBackend: boolean; /** apiOnly bot OR HTTP-virtual chat — the sessions the old rule force-isolated. */ noTransport: boolean; - /** `.boot` exists on disk (no-follow). */ + /** `.boot` exists on disk (no-follow existence — planted/garbage counts). */ isolationMarkerPresent: boolean; - /** `.policy-off` tombstone exists on disk (no-follow). */ + /** `.policy-off` tombstone exists on disk (no-follow existence). Triggers + * CLEANUP / conservative decisions; does NOT by itself authorize a reattach. */ policyOffTombstonePresent: boolean; + /** The `.policy-off` tombstone passed secure-read + schema/version + * validation ({@link policyOffTombstoneValid}). ONLY this authorizes a + * policy-off warm reattach. */ + policyOffTombstoneValid: boolean; /** The persistent pane is currently alive (caller's probe === 'exists'). */ paneLive: boolean; /** @@ -593,12 +636,12 @@ export type PersistentPaneMigrationInput = { }; export type PersistentPaneMigrationDecision = - /** Guard does not apply (backend can't isolate, or nothing to evaluate). */ + /** Guard does not apply (nothing to evaluate). */ | { action: 'skip' } /** Live pane matches the current policy → keep the running process. */ | { action: 'reattach' } - /** Live pane's provenance is wrong/unknown → kill, then cold-spawn. Marker + - * tombstone must be cleared ONLY AFTER the kill is confirmed (see clearAfterKill). */ + /** Live pane's provenance is wrong/unknown → kill, then cold-spawn. Provenance + * files are cleared ONLY AFTER the kill is confirmed (clearAfterKill). */ | { action: 'kill-then-cold-spawn'; clearAfterKill: boolean } /** No live pane, but stale provenance files linger → clear them (verified) then * cold-spawn fresh, so a later restart doesn't misjudge the new pane. */ @@ -609,47 +652,116 @@ export function evaluatePersistentPaneMigration( ): PersistentPaneMigrationDecision { const { appliedIsolationCapabilities, isolationCapableBackend, noTransport, - isolationMarkerPresent, policyOffTombstonePresent, paneLive, - isolationMarkerReattachSafe, + isolationMarkerPresent, policyOffTombstonePresent, policyOffTombstoneValid: tombstoneValid, + paneLive, isolationMarkerReattachSafe, } = input; const policyOn = appliedIsolationCapabilities.length > 0; - - // A backend that cannot be isolated never carries a marker and cannot have been - // confined, so the migration guard is irrelevant. (Callers also skip pty.) - if (!isolationCapableBackend) return { action: 'skip' }; + const anyProvenance = isolationMarkerPresent || policyOffTombstonePresent; if (policyOn) { - // Policy ON: only a live pane stamped under the CURRENT policy may reattach; - // a legacy/mismatched one is killed. No live pane → nothing to guard. - if (!paneLive) return { action: 'skip' }; - if (isolationMarkerReattachSafe) return { action: 'reattach' }; - return { action: 'kill-then-cold-spawn', clearAfterKill: true }; + // Policy ON (file sandbox OR credential-only): runs on EVERY persistent + // backend — credential-only panes on zellij/herdr/zmx carry a marker too, so + // this check must not be scoped to tmux (that would skip their capability + // validation and warm-reattach a stale/mismatched credential pane). + if (paneLive) { + // Only a live pane stamped under the CURRENT policy may reattach; a + // legacy/mismatched one is killed. (isolationMarkerReattachSafe already + // fail-closes on a missing/garbage marker.) + if (isolationMarkerReattachSafe) return { action: 'reattach' }; + return { action: 'kill-then-cold-spawn', clearAfterKill: true }; + } + // No live pane: nothing to reattach. A fresh policy-on spawn re-stamps its + // marker, but any stale tombstone from a prior policy-off generation must be + // cleared first, or a later flip back to policy-off could misread it. + if (anyProvenance) return { action: 'clear-stale-then-cold-spawn' }; + return { action: 'skip' }; } - // Policy OFF. Only no-transport sessions could ever have been force-isolated by - // the removed rule; an ordinary chat was never confined, so leave it untouched - // (no tombstone requirement, no probe, no false kill). - if (!noTransport) return { action: 'skip' }; - - const hasStaleFiles = isolationMarkerPresent || policyOffTombstonePresent; + // Policy OFF. The file-sandbox migration only ever confined no-transport tmux + // sessions, so the tombstone requirement is scoped to them; an ordinary chat + // (or a non-file-sandboxable backend) was never force-isolated and is left + // untouched — EXCEPT we still clear any stale provenance on a dead pane so a + // lingering file can't mislead a future decision. + const inMigrationScope = noTransport && isolationCapableBackend; if (paneLive) { - // Warm reattach is allowed ONLY with positive proof the live generation is a - // known policy-off pane: a tombstone present AND no isolation marker. Any - // other shape — isolation marker present, or NEITHER file (absence never - // proves "was never isolated", since the isolation stamp is best-effort) — - // is treated as possibly-still-confined and killed. - const provenPolicyOffGeneration = policyOffTombstonePresent && !isolationMarkerPresent; + if (!inMigrationScope) return { action: 'skip' }; + // Warm reattach requires POSITIVE, VALIDATED proof the live generation is a + // known policy-off pane: a VALID tombstone AND no isolation marker. Any other + // shape — isolation marker present (dominates), tombstone missing/invalid, or + // NEITHER file (absence never proves "was never isolated", since the isolation + // stamp is best-effort) — is treated as possibly-still-confined and killed. + const provenPolicyOffGeneration = tombstoneValid && !isolationMarkerPresent; if (provenPolicyOffGeneration) return { action: 'reattach' }; return { action: 'kill-then-cold-spawn', clearAfterKill: true }; } - // No live pane. If stale provenance files linger they must be cleared (verified) - // before the fresh cold-spawn, or a later restart would misjudge the new pane. - if (hasStaleFiles) return { action: 'clear-stale-then-cold-spawn' }; + // No live pane. Clear any lingering provenance (verified) before the fresh + // cold-spawn regardless of scope — a stale file must never survive to mislead a + // later restart. + if (anyProvenance) return { action: 'clear-stale-then-cold-spawn' }; return { action: 'skip' }; } +/** + * Injectable side-effect seam for {@link executePersistentPaneMigration}. The + * worker supplies real implementations (backend kill, post-kill probe, verified + * provenance removal, backend re-selection); tests supply mocks to observe the + * ORDER of effects and the "not called" guarantees on each failure path — the + * part a pure truth-table cannot cover. + */ +export type PersistentPaneMigrationEffects = { + /** Kill the stale persistent pane. Throw on failure — caller must NOT proceed. */ + killStalePane: () => void; + /** Probe AFTER the kill; throw (fail-closed) if termination cannot be confirmed. */ + confirmPaneGone: () => void; + /** Remove BOTH provenance files, each verified-gone; throw if any cannot be + * removed (fail-closed — a surviving file would mis-drive the next restart). */ + clearProvenanceVerified: () => void; + /** Re-select the backend so a stale isReattach=true does not target the pane we + * just destroyed. Only called after a confirmed kill + cleared provenance. */ + reselectBackend: () => void; +}; + +/** + * Execute a {@link PersistentPaneMigrationDecision} with strict fail-closed + * ordering. Extracted from the worker so the ordering + "stop on failure" + * guarantees are unit-testable with injected effects: + * + * kill-then-cold-spawn : killStalePane → confirmPaneGone → (clearAfterKill? + * clearProvenanceVerified) → reselectBackend. + * Any throw from killStalePane or confirmPaneGone aborts BEFORE clearing + * provenance (evidence is preserved for the retry) and BEFORE reselect. A + * throw from clearProvenanceVerified aborts BEFORE reselect (never publish a + * new generation while a stale proof lingers). + * clear-stale-then-cold-spawn : clearProvenanceVerified only (no live pane to + * kill; a throw aborts the spawn). + * reattach / skip : no effects. + * + * Returns the action taken so the caller can branch (e.g. set warm-reattach). + */ +export function executePersistentPaneMigration( + decision: PersistentPaneMigrationDecision, + effects: PersistentPaneMigrationEffects, +): PersistentPaneMigrationDecision['action'] { + switch (decision.action) { + case 'reattach': + case 'skip': + return decision.action; + case 'clear-stale-then-cold-spawn': + effects.clearProvenanceVerified(); + return decision.action; + case 'kill-then-cold-spawn': + effects.killStalePane(); // throws → stop (evidence preserved) + effects.confirmPaneGone(); // throws → stop (evidence preserved) + if (decision.clearAfterKill) { + effects.clearProvenanceVerified(); // throws → stop before reselect + } + effects.reselectBackend(); + return decision.action; + } +} + function dedupe(xs: string[]): string[] { return Array.from(new Set(xs)); } diff --git a/src/worker.ts b/src/worker.ts index b470f5f99..ae97e4863 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -29,9 +29,12 @@ import { isolatedPaneOriginChannel, isolatedPaneReattachSafe, evaluatePersistentPaneMigration, + executePersistentPaneMigration, + type PersistentPaneMigrationEffects, isolationPaneMarkerPath, policyOffTombstonePath, policyOffTombstoneContent, + policyOffTombstoneValid, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, @@ -11902,13 +11905,20 @@ async function spawnCli( || cfg.chatId?.startsWith('http_wait_') === true; const isolationCapableBackend = effectiveBackendType === 'tmux'; // Existence via no-follow probes so a planted/tampered leaf still counts as - // present and can never be used to force a silent reattach. + // present (→ triggers cleanup / conservative kill) and can never be used to + // force a silent reattach. const stalePaneMarkerPresent = hostEntryExistsNoFollow(stalePaneMarkerPath); const policyOffTombstonePresent = hostEntryExistsNoFollow(policyOffTombstoneFilePath); - const persistentPaneMigrationEvidence = appliedIsolationCapabilities.length > 0 - || (noTransportSession && isolationCapableBackend - && (stalePaneMarkerPresent || policyOffTombstonePresent)); - if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneMigrationEvidence) { + // The guard must ENTER the state machine whenever it could have anything to + // decide, WITHOUT depending on provenance already being present — else a + // no-transport pane whose best-effort isolation marker write was lost would + // (NEITHER file) skip the guard and warm-reattach still confined (codex R3 #1). + // Enter for: any policy-ON spawn (capability check runs on every persistent + // backend, incl. credential-only zellij/herdr/zmx — codex R3 #2), OR a + // policy-OFF no-transport tmux session (the file-sandbox migration scope). + const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0 + || (noTransportSession && isolationCapableBackend); + if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneGuardApplies) { const persistentTarget = selectedBackend.persistentBackendTarget; // ZMX ownership is verified against the frozen PID, not just the name — a // same-named session may belong to the user or to a newer generation. @@ -11953,12 +11963,19 @@ async function spawnCli( policyDigest: managedOriginChannelPolicyDigest, } : {}), }); + // Tombstone authorizes a policy-off warm reattach ONLY when it passes a SECURE + // read (real 0600 regular file, right owner) + schema/version validation — a + // bare lstat "present" (empty / dir / symlink / garbage) must NOT authorize. + // Presence (above) still drives cleanup; validity drives authorization. + const policyOffTombstoneIsValid = paneLive && policyOffTombstonePresent + && policyOffTombstoneValid(readManagedOriginAuthorityFile(policyOffTombstoneFilePath)); const migration = evaluatePersistentPaneMigration({ appliedIsolationCapabilities, isolationCapableBackend, noTransport: noTransportSession, isolationMarkerPresent: stalePaneMarkerPresent, policyOffTombstonePresent, + policyOffTombstoneValid: policyOffTombstoneIsValid, paneLive, isolationMarkerReattachSafe, }); @@ -11975,92 +11992,80 @@ async function spawnCli( ); } }; + // Capture the stale name/target BEFORE any re-selection below (reselect + // reassigns persistentSessionName and widens it back to string | undefined). + const staleSessionName = persistentSessionName; + const stalePersistentTarget = selectedBackend.persistentBackendTarget; + const migrationEffects: PersistentPaneMigrationEffects = { + killStalePane: () => { + try { + // ZMX keeps its own call here rather than going through the target + // helper: only this path holds the frozen PID, which makes the + // ownership check stricter than the name+label check. + if (effectiveBackendType === 'zmx') { + ZmxBackend.killManagedSession(staleSessionName, cfg.sessionId, resolvedZmxSessionPid); + } else if (stalePersistentTarget) { + killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId); + } else { + killPersistentSession(effectiveBackendType as PersistentBackendType, staleSessionName, cfg.sessionId); + } + } catch (e) { + throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${(e as Error).message})`); + } + }, + confirmPaneGone: () => { + const postKillProbe = effectiveBackendType === 'zmx' + ? probeOwnedZmxSession(staleSessionName, cfg.sessionId).probe + : (stalePersistentTarget + ? probePersistentBackendTarget(stalePersistentTarget) + : probePersistentSession(effectiveBackendType as PersistentBackendType, staleSessionName)); + if (shouldRejectPersistentPostKillProbe(effectiveBackendType as PersistentBackendType, postKillProbe)) { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + `could not confirm stale ${effectiveBackendType} pane termination`, + ); + } + if (effectiveBackendType === 'zmx') { + resolvedZmxSessionProbe = postKillProbe; + resolvedZmxSessionPid = undefined; + } + }, + clearProvenanceVerified: () => { + // Clear BOTH files (verified). Order-independent — both must end absent. + if (stalePaneMarkerPresent) removeProvenanceOrThrow(stalePaneMarkerPath, 'isolation marker'); + if (policyOffTombstonePresent) removeProvenanceOrThrow(policyOffTombstoneFilePath, 'policy-off tombstone'); + }, + reselectBackend: () => { + // ZMX backend selection consumes the frozen probe. Refresh it before + // re-selecting or the replacement keeps isReattach=true for the pane + // that this gate just proved was removed. + selectedBackend = selectBackend(); + isTmuxMode = selectedBackend.isTmuxMode; + isPipeMode = selectedBackend.isPipeMode; + isZellijMode = selectedBackend.isZellijMode; + backend = selectedBackend.backend; + cliLifetimeNonce++; + persistentSessionName = selectedBackend.persistentSessionName; + }, + }; if (migration.action === 'reattach') { if (originChannelPolicyExpected) { persistentPaneOriginChannelId = isolatedPaneOriginChannel(marker); } // Pane matches the current policy (isolated pane stamped under it, or a - // no-transport pane positively proven to be a policy-off generation) → - // still valid on the running process across daemon restarts; warm reattach - // preserves resume/context + tmux idle-suspend. + // no-transport pane with a VALIDATED policy-off tombstone) → still valid on + // the running process across daemon restarts; warm reattach preserves + // resume/context + tmux idle-suspend. log(`[read-isolation] reattaching persistent pane under current policy (${cfg.sessionId})`); } else if (migration.action === 'clear-stale-then-cold-spawn') { - // No live pane, but stale provenance files linger (e.g. a prior kill whose - // cold-spawn crashed). Clear BOTH (verified) so the fresh spawn below is not - // misjudged on the next restart. Fail closed if a leaf cannot be removed. log(`[read-isolation] clearing stale provenance for dead pane before cold-spawn (${cfg.sessionId})`); - if (stalePaneMarkerPresent) removeProvenanceOrThrow(stalePaneMarkerPath, 'isolation marker'); - if (policyOffTombstonePresent) removeProvenanceOrThrow(policyOffTombstoneFilePath, 'policy-off tombstone'); } else if (migration.action === 'kill-then-cold-spawn') { - // Live pane whose provenance is wrong/unknown (legacy isolated marker, or a - // no-transport pane lacking a policy-off tombstone) → kill before publishing - // any new capability. Provenance files are cleared ONLY AFTER the kill is - // confirmed (below) — clearing before the kill would, if the kill/probe - // fails, leave the still-alive confined pane with no on-disk evidence, so a - // retry would warm-reattach it (codex R2). log(`[read-isolation] persistent pane provenance mismatch for ${cfg.sessionId} — killing + cold-spawning with current policy`); - // Capture the name before re-selection: `persistentSessionName` is - // reassigned from the new selection below and widens back to - // `string | undefined`, but the backing name we are tearing down is - // this one and does not change. - const staleSessionName = persistentSessionName; - const stalePersistentTarget = selectedBackend.persistentBackendTarget; - try { - // ZMX keeps its own call here rather than going through the target - // helper: only this path holds the frozen PID, which makes the - // ownership check stricter than the name+label check. - if (effectiveBackendType === 'zmx') { - ZmxBackend.killManagedSession( - persistentSessionName, - cfg.sessionId, - resolvedZmxSessionPid, - ); - } else { - if (stalePersistentTarget) killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId); - else killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName, cfg.sessionId); - } - } catch (e) { - throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${(e as Error).message})`); - } - const postKillProbe = effectiveBackendType === 'zmx' - ? probeOwnedZmxSession(staleSessionName, cfg.sessionId).probe - : (stalePersistentTarget - ? probePersistentBackendTarget(stalePersistentTarget) - : probePersistentSession( - effectiveBackendType as PersistentBackendType, - staleSessionName, - )); - if (shouldRejectPersistentPostKillProbe( - effectiveBackendType as PersistentBackendType, - postKillProbe, - )) { - throw new Error( - `[read-isolation] refusing to start session ${cfg.sessionId}: ` + - `could not confirm stale ${effectiveBackendType} pane termination`, - ); - } - // Kill CONFIRMED — only now is it safe to drop the provenance files. Fail - // closed if either cannot be verifiably removed (else a policy-off restart - // would misjudge the fresh pane and mis-kill it every time). - if (migration.clearAfterKill) { - if (stalePaneMarkerPresent) removeProvenanceOrThrow(stalePaneMarkerPath, 'isolation marker'); - if (policyOffTombstonePresent) removeProvenanceOrThrow(policyOffTombstoneFilePath, 'policy-off tombstone'); - } - if (effectiveBackendType === 'zmx') { - resolvedZmxSessionProbe = postKillProbe; - resolvedZmxSessionPid = undefined; - } - // ZMX backend selection consumes the frozen probe. Refresh it before - // re-selecting or the replacement keeps isReattach=true for the pane - // that this gate just proved was removed. - selectedBackend = selectBackend(); - isTmuxMode = selectedBackend.isTmuxMode; - isPipeMode = selectedBackend.isPipeMode; - isZellijMode = selectedBackend.isZellijMode; - backend = selectedBackend.backend; - cliLifetimeNonce++; - persistentSessionName = selectedBackend.persistentSessionName; } + // Ordered, fail-closed side effects (kill → confirm → clear → reselect) live + // in executePersistentPaneMigration so the ordering + stop-on-failure + // guarantees are unit-testable with injected mocks. + executePersistentPaneMigration(migration, migrationEffects); } readIsolationOriginChannelId = managedOriginChannelRequired ? (persistentPaneOriginChannelId ?? randomBytes(32).toString('hex')) @@ -13246,6 +13251,10 @@ async function spawnCli( if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) { try { mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true }); + // Mutual exclusivity: a policy-ON generation must not carry a stale + // policy-off tombstone (else a later flip to policy-off could read it as a + // no-sandbox generation). Best-effort like the marker write itself. + try { unlinkSync(policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId)); } catch { /* absent */ } replaceManagedOriginCapabilityFile( isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId), isolationPaneMarkerContent( @@ -13271,6 +13280,15 @@ async function spawnCli( // than spawn a pane we cannot prove, FAIL CLOSED here. try { mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true }); + // Mutual exclusivity: clear any stale isolation marker BEFORE recording the + // tombstone, so this policy-off generation is never seen as still-confined. + // Verified (fail-closed) — a lingering marker DOMINATES the tombstone in the + // guard, so leaving one would defeat the tombstone entirely. + const staleMarker = isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId); + try { unlinkSync(staleMarker); } catch { /* absent */ } + if (hostEntryExistsNoFollow(staleMarker)) { + throw new Error(`stale isolation marker survived removal at ${staleMarker}`); + } replaceManagedOriginCapabilityFile( policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId), policyOffTombstoneContent(cfg.daemonBootId ?? ''), diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index 17ff4640e..392be55ed 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -447,39 +447,46 @@ describe('API-only bot mode — no-transport fs-policy authority provenance (wor expect(workerSource).toContain('no-transport suppressed'); }); - it('persistent-pane guard is driven by evaluatePersistentPaneMigration and clears provenance only AFTER kill is confirmed', () => { - // 2026-08 no-transport 放宽 migration: the reattach guard delegates to the - // pure state machine evaluatePersistentPaneMigration; behavioral truth table - // (incl. the crash/teardown branches) lives in read-isolation.test.ts. Here we - // lock the WORKER WIRING that the pure fn cannot cover. + it('persistent-pane guard: state-machine + injectable executor wiring (behavioral tests in read-isolation)', () => { + // The reattach guard delegates the DECISION to evaluatePersistentPaneMigration + // and the ORDERED, fail-closed side effects to executePersistentPaneMigration. + // Behavioral truth table + failure-path ordering live in read-isolation.test.ts + // (real behavioral tests, not source-locks). Here we lock the WORKER WIRING. expect(workerSource).toContain('const migration = evaluatePersistentPaneMigration({'); - // The gate enters for policy-ON, or a no-transport isolation-capable session - // with stale provenance — NOT the old bare `capabilities.length > 0`. - expect(workerSource).toContain('const persistentPaneMigrationEvidence = appliedIsolationCapabilities.length > 0'); + expect(workerSource).toContain('executePersistentPaneMigration(migration, migrationEffects)'); expect(workerSource).not.toContain('persistentPaneReattachGuardEngaged'); - // Provenance removal is VERIFIED (unlink → re-probe → throw if still present), - // so an un-removable leaf fails closed instead of looping kills. + // issue #1: the gate must ENTER without requiring provenance to be present, so a + // NEITHER-file no-transport tmux pane still reaches the state machine (else the + // best-effort-marker-lost pane silently warm-reattaches). Enter for any policy-ON + // spawn OR a policy-OFF no-transport tmux session. + expect(workerSource).toContain( + 'const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0\n' + + ' || (noTransportSession && isolationCapableBackend);', + ); + // issue #3: tombstone authorization requires a SECURE read + schema validation, + // not a bare lstat "present". + expect(workerSource).toContain('policyOffTombstoneValid(readManagedOriginAuthorityFile(policyOffTombstoneFilePath))'); + // Provenance removal is VERIFIED (unlink → re-probe → throw if still present). const remover = region(workerSource, - 'const removeProvenanceOrThrow =', 'if (migration.action === '); + 'const removeProvenanceOrThrow =', 'const staleSessionName = persistentSessionName;'); expect(remover).toContain('hostEntryExistsNoFollow(path)'); expect(remover).toContain('could not remove stale'); - // CRITICAL ORDER (codex R2): in the kill branch the provenance clear must come - // AFTER the post-kill probe confirmation, never before the kill — else a failed - // kill/probe leaves a live confined pane with no evidence and a retry reattaches - // it. Assert clearAfterKill runs after shouldRejectPersistentPostKillProbe. - const killBlock = region(workerSource, - "if (migration.action === 'kill-then-cold-spawn')", - 'selectedBackend = selectBackend();'); - const killCall = killBlock.indexOf('could not kill stale persistent pane'); - const postKillReject = killBlock.indexOf('shouldRejectPersistentPostKillProbe('); - const clear = killBlock.indexOf('if (migration.clearAfterKill) {'); - expect(killCall).toBeGreaterThan(-1); - expect(postKillReject).toBeGreaterThan(killCall); - expect(clear).toBeGreaterThan(postKillReject); + // The effects wire the real kill/probe/clear/reselect; the executor enforces + // ordering + stop-on-failure (proven behaviorally in read-isolation.test.ts). + const effects = region(workerSource, + 'const migrationEffects: PersistentPaneMigrationEffects = {', + 'executePersistentPaneMigration(migration, migrationEffects)'); + expect(effects).toContain('killStalePane:'); + expect(effects).toContain('confirmPaneGone:'); + expect(effects).toContain('shouldRejectPersistentPostKillProbe('); + expect(effects).toContain('clearProvenanceVerified:'); + expect(effects).toContain('reselectBackend:'); // Policy-OFF cold-spawn of a no-transport isolation-capable pane MUST record a - // tombstone, and FAIL CLOSED if it cannot (else next restart mis-kills it). - expect(workerSource).toContain('policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId)'); + // tombstone, clearing any stale isolation marker first, and FAIL CLOSED if it + // cannot (else the next restart mis-kills it). + expect(workerSource).toContain('policyOffTombstoneContent(cfg.daemonBootId ?? \'\')'); expect(workerSource).toContain('could not record policy-off generation tombstone'); + expect(workerSource).toContain('stale isolation marker survived removal at'); }); it('daemon freezes the actual loaded bots-config path into the worker init message', () => { diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index 9c977b88c..b10ca1c32 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -154,9 +154,11 @@ describe('persistent backend cold-restart ordering', () => { expect(thunk).toBeGreaterThan(-1); // Each `killPersistentBackendTarget` / `ZmxBackend.killManagedSession` gate - // must be followed by a re-selection before the backend is used. + // must be followed by a re-selection before the backend is used. The + // read-isolation kill now lives in the migrationEffects closures; the mcp gate + // is still inline. const gates = [ - workerSource.indexOf('[read-isolation] persistent pane provenance mismatch'), + workerSource.indexOf('const migrationEffects: PersistentPaneMigrationEffects = {'), workerSource.indexOf('if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length'), ]; for (const gate of gates) { @@ -191,7 +193,7 @@ describe('persistent backend cold-restart ordering', () => { it('limits inconclusive-probe startup rejection to ZMX in both persistent gates', () => { const readIsolationStart = workerSource.indexOf( - "if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneMigrationEvidence) {", + "if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneGuardApplies) {", ); const readIsolationEnd = workerSource.indexOf('let willReattachPersistent', readIsolationStart); const mcpStart = workerSource.indexOf( @@ -218,7 +220,7 @@ describe('persistent backend cold-restart ordering', () => { }); it('verifies read-isolation teardown against the exact captured backend target', () => { - const start = workerSource.indexOf('[read-isolation] persistent pane provenance mismatch'); + const start = workerSource.indexOf('const staleSessionName = persistentSessionName;'); const end = workerSource.indexOf('let willReattachPersistent', start); const gate = workerSource.slice(start, end); const capture = gate.indexOf( @@ -242,7 +244,7 @@ describe('persistent backend cold-restart ordering', () => { }); it('refreshes the frozen ZMX probe before read-isolation re-selects the backend', () => { - const start = workerSource.indexOf('[read-isolation] persistent pane provenance mismatch'); + const start = workerSource.indexOf('const migrationEffects: PersistentPaneMigrationEffects = {'); const end = workerSource.indexOf('let willReattachPersistent', start); const gate = workerSource.slice(start, end); const postKillProbe = gate.indexOf('const postKillProbe ='); diff --git a/test/read-isolation.test.ts b/test/read-isolation.test.ts index 885ac53f7..c37b5b659 100644 --- a/test/read-isolation.test.ts +++ b/test/read-isolation.test.ts @@ -10,6 +10,9 @@ import { isolatedPaneOriginChannel, isolatedPaneReattachSafe, evaluatePersistentPaneMigration, + executePersistentPaneMigration, + policyOffTombstoneContent, + policyOffTombstoneValid, isolationPaneMarkerContent, ISOLATION_PANE_MARKER_VERSION, isolationPanePolicyDigest, @@ -319,26 +322,23 @@ describe('evaluatePersistentPaneMigration — policy-on/off pane provenance stat // Pure decision behind the worker's stale-pane guard (worker.ts). Covers the // 2026-08 no-transport 放宽 upgrade path AND the crash/teardown-failure branches // codex flagged. `isolationMarkerReattachSafe` is the caller's precomputed - // isolatedPaneReattachSafe() result (only meaningful under policy ON). + // isolatedPaneReattachSafe() result (only meaningful under policy ON); + // `policyOffTombstoneValid` is the caller's secure-read + schema check. const CAPS_ON = ['credential', 'read', 'write'] as const; + const CRED_ONLY = ['credential'] as const; const CAPS_OFF = [] as const; const base = { isolationCapableBackend: true, noTransport: true, isolationMarkerPresent: false, policyOffTombstonePresent: false, + policyOffTombstoneValid: false, paneLive: true, isolationMarkerReattachSafe: false, }; - it('non-isolation-capable backend → skip (zellij/zmx/herdr can never carry a marker)', () => { - expect(evaluatePersistentPaneMigration({ - ...base, appliedIsolationCapabilities: CAPS_OFF, isolationCapableBackend: false, - isolationMarkerPresent: true, - })).toEqual({ action: 'skip' }); - }); - - // ── policy ON ── + // ── policy ON — runs on EVERY persistent backend (issue #2: credential-only on + // zellij/herdr/zmx must still be capability-checked, NOT skipped as non-tmux) ── it('policy ON + live pane stamped under current policy → reattach', () => { expect(evaluatePersistentPaneMigration({ ...base, appliedIsolationCapabilities: CAPS_ON, @@ -353,42 +353,70 @@ describe('evaluatePersistentPaneMigration — policy-on/off pane provenance stat })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); }); - it('policy ON + no live pane → skip (nothing to guard; fresh spawn stamps)', () => { + it('policy ON credential-only on a NON-tmux backend + mismatched marker → kill (issue #2: not skipped)', () => { + // enrolled host, credential-only wrapper on zellij/herdr/zmx (isolationCapableBackend + // false because file sandbox is tmux-only). The capability check must STILL run. + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CRED_ONLY, isolationCapableBackend: false, + isolationMarkerPresent: true, isolationMarkerReattachSafe: false, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); + + it('policy ON credential-only on a NON-tmux backend + matching marker → reattach', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CRED_ONLY, isolationCapableBackend: false, + isolationMarkerPresent: true, isolationMarkerReattachSafe: true, + })).toEqual({ action: 'reattach' }); + }); + + it('policy ON + no live pane + stale tombstone lingering → clear stale (else a later policy-off misreads it)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_ON, paneLive: false, + policyOffTombstonePresent: true, + })).toEqual({ action: 'clear-stale-then-cold-spawn' }); + }); + + it('policy ON + no live pane + no provenance → skip (fresh spawn stamps)', () => { expect(evaluatePersistentPaneMigration({ ...base, appliedIsolationCapabilities: CAPS_ON, paneLive: false, })).toEqual({ action: 'skip' }); }); - // ── policy OFF, no-transport migration arm ── - it('policy OFF + live pane proven policy-off (tombstone, no isolation marker) → reattach', () => { + // ── policy OFF, no-transport tmux migration arm ── + it('policy OFF + live pane with VALID tombstone, no isolation marker → reattach', () => { expect(evaluatePersistentPaneMigration({ ...base, appliedIsolationCapabilities: CAPS_OFF, - policyOffTombstonePresent: true, isolationMarkerPresent: false, + policyOffTombstonePresent: true, policyOffTombstoneValid: true, isolationMarkerPresent: false, })).toEqual({ action: 'reattach' }); }); + it('policy OFF + live pane with tombstone PRESENT but INVALID → kill (lstat-present is not proof; issue #3)', () => { + // Empty / dir / symlink / garbage tombstone lstat-exists but fails secure-read; + // must NOT authorize a warm reattach of a possibly-confined pane. + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, + policyOffTombstonePresent: true, policyOffTombstoneValid: false, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); + it('policy OFF + live pane with legacy ISOLATION marker → kill + cold-spawn (the core regression)', () => { - // Old forced-isolation pane still alive & stamped; new policy OFF. Must kill, - // not warm-reattach the still-confined process. expect(evaluatePersistentPaneMigration({ ...base, appliedIsolationCapabilities: CAPS_OFF, isolationMarkerPresent: true, })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); }); - it('policy OFF + live pane with NEITHER file → kill (absence never proves "never isolated")', () => { - // issue-3 root fix: the isolation stamp is best-effort, so "no marker" cannot - // be trusted as safe. Without positive tombstone proof the live pane is killed. + it('policy OFF + live pane with NEITHER file → kill (absence never proves "never isolated"; issue #3)', () => { expect(evaluatePersistentPaneMigration({ ...base, appliedIsolationCapabilities: CAPS_OFF, isolationMarkerPresent: false, policyOffTombstonePresent: false, })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); }); - it('policy OFF + live pane with BOTH files (marker wins) → kill', () => { + it('policy OFF + live pane with valid tombstone AND isolation marker (marker dominates) → kill', () => { expect(evaluatePersistentPaneMigration({ ...base, appliedIsolationCapabilities: CAPS_OFF, - isolationMarkerPresent: true, policyOffTombstonePresent: true, + isolationMarkerPresent: true, policyOffTombstonePresent: true, policyOffTombstoneValid: true, })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); }); @@ -412,14 +440,114 @@ describe('evaluatePersistentPaneMigration — policy-on/off pane provenance stat })).toEqual({ action: 'skip' }); }); - it('policy OFF + TRANSPORT-ENABLED chat → skip even with a marker (never force-isolated, no false kill)', () => { - // Ordinary chats were never subject to the removed rule; the tombstone - // requirement must not touch them. + it('policy OFF + TRANSPORT-ENABLED chat + LIVE pane → skip even with a marker (never force-isolated, no false kill)', () => { expect(evaluatePersistentPaneMigration({ ...base, appliedIsolationCapabilities: CAPS_OFF, noTransport: false, isolationMarkerPresent: true, })).toEqual({ action: 'skip' }); }); + + it('policy OFF + non-migration-scope + DEAD pane with stale marker → still clears (file must not linger)', () => { + // Even outside the migration scope, a dead pane's stale provenance is cleared + // so it cannot mislead a future decision. + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, noTransport: false, + paneLive: false, isolationMarkerPresent: true, + })).toEqual({ action: 'clear-stale-then-cold-spawn' }); + }); +}); + +describe('executePersistentPaneMigration — ordered, fail-closed IO seam', () => { + // Behavioral (not source-lock): inject mock effects, observe call ORDER and the + // "not called" guarantees on each failure path — exactly what codex asked for. + const makeEffects = () => { + const calls: string[] = []; + const eff = { + killStalePane: () => { calls.push('kill'); }, + confirmPaneGone: () => { calls.push('confirm'); }, + clearProvenanceVerified: () => { calls.push('clear'); }, + reselectBackend: () => { calls.push('reselect'); }, + }; + return { calls, eff }; + }; + + it('reattach / skip → no side effects at all', () => { + for (const action of ['reattach', 'skip'] as const) { + const { calls, eff } = makeEffects(); + executePersistentPaneMigration({ action }, eff); + expect(calls).toEqual([]); + } + }); + + it('kill-then-cold-spawn (clearAfterKill) → kill → confirm → clear → reselect, in order', () => { + const { calls, eff } = makeEffects(); + executePersistentPaneMigration({ action: 'kill-then-cold-spawn', clearAfterKill: true }, eff); + expect(calls).toEqual(['kill', 'confirm', 'clear', 'reselect']); + }); + + it('kill FAILS → stops before confirm/clear/reselect (evidence preserved for retry)', () => { + const { calls, eff } = makeEffects(); + eff.killStalePane = () => { calls.push('kill'); throw new Error('kill failed'); }; + expect(() => executePersistentPaneMigration( + { action: 'kill-then-cold-spawn', clearAfterKill: true }, eff, + )).toThrow('kill failed'); + expect(calls).toEqual(['kill']); // NOT clear, NOT reselect + }); + + it('post-kill confirm REJECTS → stops before clear/reselect (marker preserved)', () => { + const { calls, eff } = makeEffects(); + eff.confirmPaneGone = () => { calls.push('confirm'); throw new Error('still alive'); }; + expect(() => executePersistentPaneMigration( + { action: 'kill-then-cold-spawn', clearAfterKill: true }, eff, + )).toThrow('still alive'); + expect(calls).toEqual(['kill', 'confirm']); // NOT clear, NOT reselect + }); + + it('provenance clear FAILS → stops before reselect (never publish a new generation)', () => { + const { calls, eff } = makeEffects(); + eff.clearProvenanceVerified = () => { calls.push('clear'); throw new Error('unlink failed'); }; + expect(() => executePersistentPaneMigration( + { action: 'kill-then-cold-spawn', clearAfterKill: true }, eff, + )).toThrow('unlink failed'); + expect(calls).toEqual(['kill', 'confirm', 'clear']); // NOT reselect + }); + + it('clear-stale-then-cold-spawn → clear only (no kill of a dead pane, no reselect)', () => { + const { calls, eff } = makeEffects(); + executePersistentPaneMigration({ action: 'clear-stale-then-cold-spawn' }, eff); + expect(calls).toEqual(['clear']); + }); + + it('clear-stale clear FAILS → throws, aborts the spawn', () => { + const { calls, eff } = makeEffects(); + eff.clearProvenanceVerified = () => { calls.push('clear'); throw new Error('rmdir'); }; + expect(() => executePersistentPaneMigration({ action: 'clear-stale-then-cold-spawn' }, eff)) + .toThrow('rmdir'); + expect(calls).toEqual(['clear']); + }); +}); + +describe('policyOffTombstoneValid — secure-read schema/version check', () => { + it('accepts a well-formed current-version tombstone (bootId diagnostic, not compared)', () => { + expect(policyOffTombstoneValid(policyOffTombstoneContent('boot-xyz'))).toBe(true); + // A DIFFERENT bootId is still valid — legit panes reattach across daemon restarts. + expect(policyOffTombstoneValid(policyOffTombstoneContent('some-other-boot'))).toBe(true); + }); + + it('rejects empty / garbage / wrong-shape bodies (lstat-present must not authorize)', () => { + expect(policyOffTombstoneValid(null)).toBe(false); + expect(policyOffTombstoneValid(undefined)).toBe(false); + expect(policyOffTombstoneValid('')).toBe(false); + expect(policyOffTombstoneValid(' ')).toBe(false); + expect(policyOffTombstoneValid('not json')).toBe(false); + expect(policyOffTombstoneValid(JSON.stringify({ policyOff: true, bootId: 'x' }))).toBe(false); // no version + expect(policyOffTombstoneValid(JSON.stringify({ version: 1, policyOff: true, bootId: 'x' }))).toBe(false); // stale version + expect(policyOffTombstoneValid(JSON.stringify({ version: ISOLATION_PANE_MARKER_VERSION, policyOff: false, bootId: 'x' }))).toBe(false); + expect(policyOffTombstoneValid(JSON.stringify({ version: ISOLATION_PANE_MARKER_VERSION, policyOff: true }))).toBe(false); // no bootId + expect(policyOffTombstoneValid(JSON.stringify({ version: ISOLATION_PANE_MARKER_VERSION, policyOff: true, bootId: '' }))).toBe(false); + // An isolation marker must NOT validate as a tombstone. + expect(policyOffTombstoneValid(isolationPaneMarkerContent('boot', ['credential']))).toBe(false); + }); }); /** From 57d7e817f231617d53bfa01da6e3cfe6a74fd334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Sun, 16 Aug 2026 13:20:56 -0700 Subject: [PATCH 5/8] =?UTF-8?q?fix(sandbox):=20=E6=8C=81=E4=B9=85=20pane?= =?UTF-8?q?=20guard=20=E5=85=A5=E5=8F=A3=E7=BA=B3=E5=85=A5=20stale=20prove?= =?UTF-8?q?nance=EF=BC=8Cdead-pane=20=E6=B8=85=E7=90=86=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E5=85=A8=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景(复审第四轮阻断) 上一版把 dead-pane「clear-stale」分支写进了纯状态机,但 worker 外层入口 `persistentPaneGuardApplies = caps>0 || (noTransport && tmux)` 仍不覆盖它,导致 该分支在 migration scope 外不可达。真实回归(codex R4): 1. 普通 transport tmux 在 policy-ON 下留下一个当前策略有效的 isolation marker; 2. pane 消失后关闭 sandbox,冷启动一个 policy-OFF 未隔离新 pane——此时 caps=[]、 noTransport=false,外门直接跳过状态机,旧 marker 没被清; 3. 之后重新启用同一 policy,旧 marker 对新的未隔离 pane 通过 isolatedPaneReattachSafe→未隔离进程被 warm-reattach 成"已隔离"。 这与状态机新增的「policy-OFF + dead pane + 任意 provenance → clear-stale」用例、 以及设计文档「无 live pane 时无论 policy on/off 都清 provenance」的承诺相矛盾。 根因仍是纯函数修对、产品入口挡住分支(与 R2/R3 同类)。 ## 改了什么 worker gate 增加第三个析取项: const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0 || (noTransportSession && isolationCapableBackend) || stalePaneMarkerPresent || policyOffTombstonePresent; 任何会话(含 transport-enabled、任意 backend)只要磁盘上有 stale marker/tombstone 就进状态机,dead pane 的残留证明在 cold-spawn 前于**所有** backend 被清理。 不引入误杀:transport-enabled 的 LIVE pane + stale marker 经状态机仍返回 skip (policyOn=false && !inMigrationScope && paneLive → skip),只有 DEAD pane 才 clear-stale。两种情况纯状态机均已有用例(read-isolation.test.ts)。 ## 测试 - api-only-mode-wiring source-lock 更新到新 gate(含 provenance 析取项)。 - 反向变异:gate 去掉 provenance 析取项 → source-lock 变红,已还原。 - 定向 11 文件 487 pass / 1 skip;build 绿。 --- src/worker.ts | 21 ++++++++++++++------- test/api-only-mode-wiring.test.ts | 14 +++++++++----- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index ae97e4863..ceef3d22b 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -11910,14 +11910,21 @@ async function spawnCli( const stalePaneMarkerPresent = hostEntryExistsNoFollow(stalePaneMarkerPath); const policyOffTombstonePresent = hostEntryExistsNoFollow(policyOffTombstoneFilePath); // The guard must ENTER the state machine whenever it could have anything to - // decide, WITHOUT depending on provenance already being present — else a - // no-transport pane whose best-effort isolation marker write was lost would - // (NEITHER file) skip the guard and warm-reattach still confined (codex R3 #1). - // Enter for: any policy-ON spawn (capability check runs on every persistent - // backend, incl. credential-only zellij/herdr/zmx — codex R3 #2), OR a - // policy-OFF no-transport tmux session (the file-sandbox migration scope). + // decide, WITHOUT depending on provenance already being present for the live- + // pane arms — else a no-transport pane whose best-effort isolation marker write + // was lost would (NEITHER file) skip the guard and warm-reattach still confined + // (codex R3 #1). Enter for: + // · any policy-ON spawn (capability check runs on every persistent backend, + // incl. credential-only zellij/herdr/zmx — codex R3 #2), OR + // · a policy-OFF no-transport tmux session (the file-sandbox migration scope), OR + // · ANY session (incl. transport-enabled, any backend) that has stale + // provenance on disk — so a dead pane's leftover marker/tombstone is cleared + // before cold-spawn on EVERY backend. Otherwise a transport chat that turned + // sandbox OFF leaves a matching marker that would later warm-reattach a fresh + // UNisolated pane as "isolated" when sandbox is re-enabled (codex R4). const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0 - || (noTransportSession && isolationCapableBackend); + || (noTransportSession && isolationCapableBackend) + || stalePaneMarkerPresent || policyOffTombstonePresent; if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneGuardApplies) { const persistentTarget = selectedBackend.persistentBackendTarget; // ZMX ownership is verified against the frozen PID, not just the name — a diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index 392be55ed..9eb121a8d 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -455,13 +455,17 @@ describe('API-only bot mode — no-transport fs-policy authority provenance (wor expect(workerSource).toContain('const migration = evaluatePersistentPaneMigration({'); expect(workerSource).toContain('executePersistentPaneMigration(migration, migrationEffects)'); expect(workerSource).not.toContain('persistentPaneReattachGuardEngaged'); - // issue #1: the gate must ENTER without requiring provenance to be present, so a - // NEITHER-file no-transport tmux pane still reaches the state machine (else the - // best-effort-marker-lost pane silently warm-reattaches). Enter for any policy-ON - // spawn OR a policy-OFF no-transport tmux session. + // issue #1 + #4: the gate must ENTER without requiring provenance for the + // live-pane arms (a NEITHER-file no-transport tmux pane still reaches the + // state machine), AND must ALSO enter on ANY session/backend that has stale + // provenance on disk — so a dead pane's leftover marker/tombstone is cleared + // before cold-spawn even for a transport-enabled chat that turned sandbox OFF + // (else re-enabling sandbox warm-reattaches a fresh UNisolated pane as + // "isolated" against the stale matching marker — codex R4). expect(workerSource).toContain( 'const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0\n' - + ' || (noTransportSession && isolationCapableBackend);', + + ' || (noTransportSession && isolationCapableBackend)\n' + + ' || stalePaneMarkerPresent || policyOffTombstonePresent;', ); // issue #3: tombstone authorization requires a SECURE read + schema validation, // not a bare lstat "present". From 229ae379378d823ba0b4a86a70a0b5c7ce24a4a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Sun, 16 Aug 2026 13:30:45 -0700 Subject: [PATCH 6/8] =?UTF-8?q?docs(sandbox):=20=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=AE=A2=E8=A7=82=E7=BC=BA=E9=99=B7/?= =?UTF-8?q?=E4=B8=8D=E5=8F=98=E9=87=8F=E6=8F=8F=E8=BF=B0=EF=BC=8C=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E5=86=85=E9=83=A8=20review=20=E8=BD=AE=E6=AC=A1?= =?UTF-8?q?=E5=AD=97=E6=A0=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 进公开 git 历史的 source/test 注释不带内部 review 编排:把 no-transport 持久 pane 迁移相关注释里的「R3/R4」轮次标记与「exactly what … asked」等协作措辞,改写成 客观的缺陷标签 + 不变量描述(所述边界/时序不变)。纯注释改动,无逻辑变更: `pnpm build` 绿,定向 read-isolation/backend-gate/api-only-mode-wiring 3 文件 125 pass,source-lock 断言不受影响。 --- src/worker.ts | 8 ++++---- test/api-only-mode-wiring.test.ts | 2 +- test/read-isolation.test.ts | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index ceef3d22b..fc563122e 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -11912,16 +11912,16 @@ async function spawnCli( // The guard must ENTER the state machine whenever it could have anything to // decide, WITHOUT depending on provenance already being present for the live- // pane arms — else a no-transport pane whose best-effort isolation marker write - // was lost would (NEITHER file) skip the guard and warm-reattach still confined - // (codex R3 #1). Enter for: + // was lost would (NEITHER file) skip the guard and warm-reattach still confined. + // Enter for: // · any policy-ON spawn (capability check runs on every persistent backend, - // incl. credential-only zellij/herdr/zmx — codex R3 #2), OR + // incl. credential-only zellij/herdr/zmx), OR // · a policy-OFF no-transport tmux session (the file-sandbox migration scope), OR // · ANY session (incl. transport-enabled, any backend) that has stale // provenance on disk — so a dead pane's leftover marker/tombstone is cleared // before cold-spawn on EVERY backend. Otherwise a transport chat that turned // sandbox OFF leaves a matching marker that would later warm-reattach a fresh - // UNisolated pane as "isolated" when sandbox is re-enabled (codex R4). + // UNisolated pane as "isolated" when sandbox is re-enabled. const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0 || (noTransportSession && isolationCapableBackend) || stalePaneMarkerPresent || policyOffTombstonePresent; diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index 9eb121a8d..f51ee750d 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -461,7 +461,7 @@ describe('API-only bot mode — no-transport fs-policy authority provenance (wor // provenance on disk — so a dead pane's leftover marker/tombstone is cleared // before cold-spawn even for a transport-enabled chat that turned sandbox OFF // (else re-enabling sandbox warm-reattaches a fresh UNisolated pane as - // "isolated" against the stale matching marker — codex R4). + // "isolated" against the stale matching marker). expect(workerSource).toContain( 'const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0\n' + ' || (noTransportSession && isolationCapableBackend)\n' diff --git a/test/read-isolation.test.ts b/test/read-isolation.test.ts index c37b5b659..affb090d7 100644 --- a/test/read-isolation.test.ts +++ b/test/read-isolation.test.ts @@ -320,8 +320,8 @@ describe('isolatedPaneReattachSafe', () => { describe('evaluatePersistentPaneMigration — policy-on/off pane provenance state machine', () => { // Pure decision behind the worker's stale-pane guard (worker.ts). Covers the - // 2026-08 no-transport 放宽 upgrade path AND the crash/teardown-failure branches - // codex flagged. `isolationMarkerReattachSafe` is the caller's precomputed + // 2026-08 no-transport 放宽 upgrade path AND the crash/teardown-failure branches. + // `isolationMarkerReattachSafe` is the caller's precomputed // isolatedPaneReattachSafe() result (only meaningful under policy ON); // `policyOffTombstoneValid` is the caller's secure-read + schema check. const CAPS_ON = ['credential', 'read', 'write'] as const; @@ -459,7 +459,7 @@ describe('evaluatePersistentPaneMigration — policy-on/off pane provenance stat describe('executePersistentPaneMigration — ordered, fail-closed IO seam', () => { // Behavioral (not source-lock): inject mock effects, observe call ORDER and the - // "not called" guarantees on each failure path — exactly what codex asked for. + // "not called" guarantees on each failure path. const makeEffects = () => { const calls: string[] = []; const eff = { From cd3e64f1edaa2909841ee3c0abc276743eaf6502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Mon, 17 Aug 2026 22:05:17 -0700 Subject: [PATCH 7/8] =?UTF-8?q?fix(sandbox):=20=E6=8C=81=E4=B9=85=20pane?= =?UTF-8?q?=20=E8=BF=81=E7=A7=BB=E6=8E=A2=E6=B4=BB=E6=94=B9=E4=B8=89?= =?UTF-8?q?=E6=80=81=EF=BC=8Cunknown=20=E5=85=A8=E5=90=8E=E7=AB=AF=20fail-?= =?UTF-8?q?closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 缺陷 持久 pane 迁移状态机 evaluatePersistentPaneMigration 把 pane 存活性建模为 boolean(paneLive = paneProbe === 'exists'),但真实后端探活是三态 SessionProbe = 'exists' | 'missing' | 'unknown'。worker 把 unknown 压成 paneLive=false,导致两条不安全路径: 1. 初始探活 unknown:no-transport tmux 会话升级后带 legacy 隔离 marker,tmux 控制面抖动返回 unknown → 走 dead-pane 臂 clear-stale,**在没杀掉 pane 的 情况下删掉隔离 marker**——而那个 pane 可能仍然活着且仍被旧沙盒关着。 2. kill 后确认 unknown:迁移 teardown 复用共享 helper shouldRejectPersistentPostKillProbe,它只对 ZMX 拒 unknown;tmux/herdr/ zellij 的 post-kill unknown 被放行 → 继续清证明+重选后端。叠加 tmux killSession catch{} 吞掉包括 timeout 在内的错误、zellij spawnSync 不校验 退出码,kill 未确认就把新 generation 证明落盘。 ## 修复 - 状态机入参 paneLive:boolean → paneProbe:SessionProbe(三态)。unknown 不再 当 dead:只要有隔离风险(policy-ON / 在 policy-off 迁移 scope / 磁盘有任何 provenance)就返回新的 refuse-inconclusive-probe 决策;仅当完全无隔离风险 (policy-OFF、非 scope、无 provenance)才 skip,避免普通会话因探活抖动无谓 起不来。只有权威 missing 才清 stale / cold-spawn。 - 迁移 teardown 的 post-kill 确认改为对所有后端要求权威 missing(exists 与 unknown 都 fail-closed),不再借用 ZMX-only 的共享 helper;共享 helper 与 mcp-gateway 那条既有 gate 保持原样、未改语义。 - 移除 worker 里 ZMX 专有的初始 unknown 早退 throw,unknown 统一经状态机 (单一决策点),ZMX 的 fail-closed 结果不变。 - 执行器 executePersistentPaneMigration 新增 refuseInconclusiveProbe effect (必抛,不碰 provenance、不 reselect)。 pre-spawn 发布证明的顺序刻意不动:修复后 spawn 失败遗留的孤儿证明,下次启动 在 missing→clear-stale、unknown→refuse、exists→仅当真有匹配 pane 才合法 reattach 三种探活结果下都安全;反而把发布挪到 spawn 后会新开一个「pane 活着 但证明未落盘、下次重启被误杀」的窗口。 ## 测试 - read-isolation.test.ts 新增 7 条三态用例(policy-ON/OFF × unknown × 有/无 provenance × 是否在 scope),executor seam 新增 refuse-only 顺序断言。 - api-only-mode-wiring / backend-gate source-lock 同步为新语义(迁移臂用 postKillProbe !== 'missing' + refuseInconclusiveProbe;mcp-gateway gate 仍 ZMX-scoped 共享 helper,验证两条 gate 语义已分开)。 - 反向变异自检三处(unknown 压回 dead、去掉 policy-ON unknown 拒、post-kill 改回共享 helper)均确认对应测试变红后还原。 - 定向 11 文件 494 pass / 1 skip;pnpm build 绿;git diff --check 干净。 Co-Authored-By: Claude --- src/adapters/cli/read-isolation.ts | 85 +++++++++++++++++++++++++----- src/worker.ts | 43 ++++++++++----- test/api-only-mode-wiring.test.ts | 13 ++++- test/backend-gate.test.ts | 33 +++++++----- test/read-isolation.test.ts | 74 +++++++++++++++++++++++--- 5 files changed, 202 insertions(+), 46 deletions(-) diff --git a/src/adapters/cli/read-isolation.ts b/src/adapters/cli/read-isolation.ts index 163a88f94..6d88b449b 100644 --- a/src/adapters/cli/read-isolation.ts +++ b/src/adapters/cli/read-isolation.ts @@ -21,6 +21,7 @@ */ import { createHash } from 'node:crypto'; +import type { SessionProbe } from '../backend/types.js'; import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, @@ -601,7 +602,17 @@ export function isolatedPaneReattachSafe( * Existence flags MUST come from no-follow existence probes (a planted/tampered * leaf that fails to parse still counts as present, so it can never be used to * force a silent reattach). `policyOffTombstoneValid` is the secure-read result. - * Pane liveness is the caller's probe. + * + * Pane liveness is TRI-STATE (`paneProbe`: exists | missing | unknown), NOT a + * boolean. `unknown` (the backend could not answer) is never collapsed into + * "dead": a still-alive, still-confined pane whose probe is momentarily `unknown` + * would otherwise have its provenance cleared and be cold-spawned around, silently + * downgrading confinement. On `unknown` the machine returns + * `refuse-inconclusive-probe` (fail-closed) whenever anything is at stake — policy + * ON, in the policy-off migration scope, or ANY provenance on disk — and only + * `skip`s when a wholly unconcerned session (policy OFF, out of scope, no + * provenance) sees probe flakiness, so an ordinary chat never fails to start. + * Only an authoritative `missing` clears stale provenance / cold-spawns. */ export type PersistentPaneMigrationInput = { /** Current-spawn isolation capabilities (empty ⇒ policy OFF this spawn). May be @@ -625,8 +636,13 @@ export type PersistentPaneMigrationInput = { * validation ({@link policyOffTombstoneValid}). ONLY this authorizes a * policy-off warm reattach. */ policyOffTombstoneValid: boolean; - /** The persistent pane is currently alive (caller's probe === 'exists'). */ - paneLive: boolean; + /** The persistent pane's liveness probe — TRI-STATE, NOT a boolean. `exists` + * and `missing` are authoritative; `unknown` means the probe could not answer + * (flaky/unavailable backend). Collapsing `unknown` into "dead" is the bug this + * field prevents: a still-alive, still-confined pane whose probe is momentarily + * `unknown` must never have its provenance cleared nor be cold-spawned around. + * Only an authoritative `missing` proves the pane is gone. */ + paneProbe: SessionProbe; /** * Result of {@link isolatedPaneReattachSafe}(marker, current policy) — only * meaningful when policy is ON. The caller computes it (it needs the parsed @@ -645,7 +661,14 @@ export type PersistentPaneMigrationDecision = | { action: 'kill-then-cold-spawn'; clearAfterKill: boolean } /** No live pane, but stale provenance files linger → clear them (verified) then * cold-spawn fresh, so a later restart doesn't misjudge the new pane. */ - | { action: 'clear-stale-then-cold-spawn' }; + | { action: 'clear-stale-then-cold-spawn' } + /** The liveness probe is INCONCLUSIVE (`unknown`) in a context where acting would + * be unsafe — clearing provenance the pane might still own, or cold-spawning + * around a pane a later `exists` probe would warm-reattach unvalidated. The + * caller MUST refuse to start rather than guess (fail-closed). Only reached when + * the guard is security-concerned; an ordinary transport chat with no provenance + * skips on `unknown` instead (no gratuitous start-failures on probe flakiness). */ + | { action: 'refuse-inconclusive-probe' }; export function evaluatePersistentPaneMigration( input: PersistentPaneMigrationInput, @@ -653,10 +676,24 @@ export function evaluatePersistentPaneMigration( const { appliedIsolationCapabilities, isolationCapableBackend, noTransport, isolationMarkerPresent, policyOffTombstonePresent, policyOffTombstoneValid: tombstoneValid, - paneLive, isolationMarkerReattachSafe, + paneProbe, isolationMarkerReattachSafe, } = input; const policyOn = appliedIsolationCapabilities.length > 0; const anyProvenance = isolationMarkerPresent || policyOffTombstonePresent; + const paneLive = paneProbe === 'exists'; + // TRI-STATE liveness. `unknown` is NOT "dead": the backend (tmux/zellij/herdr/ + // zmx) could not answer, so the pane may still be alive AND still confined under + // its original (possibly obsolete) policy. Acting on `unknown` — clearing + // provenance the pane might still own, or cold-spawning around it so a later + // `exists` probe warm-reattaches an unvalidated generation — is exactly the + // silent-downgrade this guard exists to prevent. We fail closed on `unknown` + // whenever there is anything at stake (policy ON, in the policy-off migration + // scope, or ANY provenance on disk); only a truly unconcerned session (policy + // OFF, out of scope, no provenance) skips on `unknown` so probe flakiness on an + // ordinary chat never blocks startup. Only an authoritative `missing` is trusted + // as "the pane is gone". + const inMigrationScope = noTransport && isolationCapableBackend; + const guardConcerned = policyOn || inMigrationScope || anyProvenance; if (policyOn) { // Policy ON (file sandbox OR credential-only): runs on EVERY persistent @@ -670,9 +707,13 @@ export function evaluatePersistentPaneMigration( if (isolationMarkerReattachSafe) return { action: 'reattach' }; return { action: 'kill-then-cold-spawn', clearAfterKill: true }; } - // No live pane: nothing to reattach. A fresh policy-on spawn re-stamps its - // marker, but any stale tombstone from a prior policy-off generation must be - // cleared first, or a later flip back to policy-off could misread it. + // Not authoritatively alive. An `unknown` probe under policy ON must not clear + // a still-confined pane's marker nor cold-spawn around it — fail closed. + if (paneProbe === 'unknown') return { action: 'refuse-inconclusive-probe' }; + // Authoritative `missing`: nothing to reattach. A fresh policy-on spawn + // re-stamps its marker, but any stale tombstone from a prior policy-off + // generation must be cleared first, or a later flip back to policy-off could + // misread it. if (anyProvenance) return { action: 'clear-stale-then-cold-spawn' }; return { action: 'skip' }; } @@ -682,8 +723,6 @@ export function evaluatePersistentPaneMigration( // (or a non-file-sandboxable backend) was never force-isolated and is left // untouched — EXCEPT we still clear any stale provenance on a dead pane so a // lingering file can't mislead a future decision. - const inMigrationScope = noTransport && isolationCapableBackend; - if (paneLive) { if (!inMigrationScope) return { action: 'skip' }; // Warm reattach requires POSITIVE, VALIDATED proof the live generation is a @@ -696,9 +735,19 @@ export function evaluatePersistentPaneMigration( return { action: 'kill-then-cold-spawn', clearAfterKill: true }; } - // No live pane. Clear any lingering provenance (verified) before the fresh - // cold-spawn regardless of scope — a stale file must never survive to mislead a - // later restart. + // Not authoritatively alive under policy OFF. + if (paneProbe === 'unknown') { + // Inconclusive. Clearing provenance now could delete the marker/tombstone of a + // pane that is actually still alive (and, if it predates the 放宽, still + // confined) — and cold-spawning would let a later `exists` probe warm-reattach + // that unvalidated pane. Fail closed whenever the guard is concerned; a wholly + // unconcerned session (out of scope, no provenance) just skips. + return guardConcerned ? { action: 'refuse-inconclusive-probe' } : { action: 'skip' }; + } + + // Authoritative `missing`. Clear any lingering provenance (verified) before the + // fresh cold-spawn regardless of scope — a stale file must never survive to + // mislead a later restart. if (anyProvenance) return { action: 'clear-stale-then-cold-spawn' }; return { action: 'skip' }; } @@ -721,6 +770,10 @@ export type PersistentPaneMigrationEffects = { /** Re-select the backend so a stale isReattach=true does not target the pane we * just destroyed. Only called after a confirmed kill + cleared provenance. */ reselectBackend: () => void; + /** Refuse to start the session because the liveness probe was inconclusive + * (`unknown`) where acting would be unsafe. MUST throw — there is no safe + * fall-through. */ + refuseInconclusiveProbe: () => never; }; /** @@ -736,6 +789,9 @@ export type PersistentPaneMigrationEffects = { * new generation while a stale proof lingers). * clear-stale-then-cold-spawn : clearProvenanceVerified only (no live pane to * kill; a throw aborts the spawn). + * refuse-inconclusive-probe : refuseInconclusiveProbe (always throws — the probe + * was `unknown` where clearing/cold-spawning is unsafe). + * NO provenance is touched and NO reselect happens. * reattach / skip : no effects. * * Returns the action taken so the caller can branch (e.g. set warm-reattach). @@ -748,6 +804,9 @@ export function executePersistentPaneMigration( case 'reattach': case 'skip': return decision.action; + case 'refuse-inconclusive-probe': + effects.refuseInconclusiveProbe(); // always throws — no safe fall-through + return decision.action; case 'clear-stale-then-cold-spawn': effects.clearProvenanceVerified(); return decision.action; diff --git a/src/worker.ts b/src/worker.ts index fc563122e..fab26792e 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -11932,9 +11932,12 @@ async function spawnCli( const zmxOwnedProbe = effectiveBackendType === 'zmx' ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid) : undefined; - // ZMX ownership is label/PID-sensitive, so an inconclusive ZMX probe must - // fail closed. Other persistent backends retain the upstream semantics: - // their target probe returning unknown is not proof that a pane exists. + // ZMX ownership is label/PID-sensitive, so an inconclusive ZMX probe is not + // proof of anything. Other persistent backends: their target probe returning + // unknown is likewise not proof that a pane exists. Liveness is passed TRI-STATE + // (paneProbe) into the state machine, which fail-closes on `unknown` for EVERY + // backend (refuse-inconclusive-probe) — no longer only ZMX, and no longer + // collapsed into "dead" (which would clear a still-confined pane's provenance). const paneProbe = zmxOwnedProbe?.probe ?? (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing'); if ( @@ -11947,12 +11950,6 @@ async function spawnCli( 'ZMX session appeared after the frozen launch probe', ); } - if (effectiveBackendType === 'zmx' && paneProbe === 'unknown') { - throw new Error( - `[read-isolation] refusing to start session ${cfg.sessionId}: ` + - `could not verify existing ${effectiveBackendType} pane`, - ); - } const paneLive = paneProbe === 'exists'; const markerPath = stalePaneMarkerPath; const marker = paneLive ? readManagedOriginAuthorityFile(markerPath) : null; @@ -11983,7 +11980,7 @@ async function spawnCli( isolationMarkerPresent: stalePaneMarkerPresent, policyOffTombstonePresent, policyOffTombstoneValid: policyOffTombstoneIsValid, - paneLive, + paneProbe, isolationMarkerReattachSafe, }); // Verified removal of a provenance file: unlink then confirm it is truly gone @@ -12026,10 +12023,19 @@ async function spawnCli( : (stalePersistentTarget ? probePersistentBackendTarget(stalePersistentTarget) : probePersistentSession(effectiveBackendType as PersistentBackendType, staleSessionName)); - if (shouldRejectPersistentPostKillProbe(effectiveBackendType as PersistentBackendType, postKillProbe)) { + // Migration teardown fail-closes on ANY non-`missing` post-kill probe for + // EVERY backend — not just ZMX. `exists` (kill didn't take) and `unknown` + // (kill unconfirmed: tmux swallows kill errors incl. timeout, zellij does + // not check its spawnSync exit) both mean "the confined pane may still be + // alive", so publishing a new generation around it would silently keep the + // old confinement. Only an authoritative `missing` confirms termination. + // (This is STRICTER than the shared shouldRejectPersistentPostKillProbe, + // which the separate mcp-gateway gate still uses with its own semantics.) + if (postKillProbe !== 'missing') { throw new Error( `[read-isolation] refusing to start session ${cfg.sessionId}: ` - + `could not confirm stale ${effectiveBackendType} pane termination`, + + `could not confirm stale ${effectiveBackendType} pane termination ` + + `(post-kill probe: ${postKillProbe})`, ); } if (effectiveBackendType === 'zmx') { @@ -12054,6 +12060,17 @@ async function spawnCli( cliLifetimeNonce++; persistentSessionName = selectedBackend.persistentSessionName; }, + refuseInconclusiveProbe: (): never => { + // The liveness probe was `unknown` where acting would be unsafe (the pane + // may still be alive AND still confined under an obsolete policy). No + // provenance is touched, no reselect — fail closed and let the next launch + // re-probe once the backend is answering again. + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + `could not verify existing ${effectiveBackendType} pane ` + + `(liveness probe: ${paneProbe})`, + ); + }, }; if (migration.action === 'reattach') { if (originChannelPolicyExpected) { @@ -12068,6 +12085,8 @@ async function spawnCli( log(`[read-isolation] clearing stale provenance for dead pane before cold-spawn (${cfg.sessionId})`); } else if (migration.action === 'kill-then-cold-spawn') { log(`[read-isolation] persistent pane provenance mismatch for ${cfg.sessionId} — killing + cold-spawning with current policy`); + } else if (migration.action === 'refuse-inconclusive-probe') { + log(`[read-isolation] inconclusive liveness probe (${paneProbe}) for ${cfg.sessionId} — refusing to start rather than clear/cold-spawn around a possibly-live confined pane`); } // Ordered, fail-closed side effects (kill → confirm → clear → reselect) live // in executePersistentPaneMigration so the ordering + stop-on-failure diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index f51ee750d..9268caccc 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -482,7 +482,18 @@ describe('API-only bot mode — no-transport fs-policy authority provenance (wor 'executePersistentPaneMigration(migration, migrationEffects)'); expect(effects).toContain('killStalePane:'); expect(effects).toContain('confirmPaneGone:'); - expect(effects).toContain('shouldRejectPersistentPostKillProbe('); + // Tri-state fix: migration teardown fail-closes on ANY non-`missing` post-kill + // probe for EVERY backend (kill unconfirmed on `unknown` — tmux swallows kill + // errors, zellij ignores its exit — must not publish a new generation). This is + // STRICTER than the shared shouldRejectPersistentPostKillProbe (ZMX-only + // unknown), which the migration path deliberately no longer uses. + expect(effects).toContain("postKillProbe !== 'missing'"); + expect(effects).not.toContain('shouldRejectPersistentPostKillProbe('); + // Tri-state fix: an inconclusive (`unknown`) liveness probe fails closed via a + // dedicated effect — never clear provenance / cold-spawn around a possibly-live + // confined pane. + expect(effects).toContain('refuseInconclusiveProbe:'); + expect(workerSource).toContain('could not verify existing ${effectiveBackendType} pane'); expect(effects).toContain('clearProvenanceVerified:'); expect(effects).toContain('reselectBackend:'); // Policy-OFF cold-spawn of a no-transport isolation-capable pane MUST record a diff --git a/test/backend-gate.test.ts b/test/backend-gate.test.ts index b10ca1c32..f93af2099 100644 --- a/test/backend-gate.test.ts +++ b/test/backend-gate.test.ts @@ -191,7 +191,7 @@ describe('persistent backend cold-restart ordering', () => { expect(gate).toContain('resolvedZmxSessionProbe = postKillProbe'); }); - it('limits inconclusive-probe startup rejection to ZMX in both persistent gates', () => { + it('read-isolation gate fail-closes on an inconclusive probe for EVERY backend; mcp-gateway keeps its ZMX-scoped semantics', () => { const readIsolationStart = workerSource.indexOf( "if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneGuardApplies) {", ); @@ -200,23 +200,30 @@ describe('persistent backend cold-restart ordering', () => { 'if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length', ); const mcpEnd = workerSource.indexOf('// The plugin set is stable only', mcpStart); - const gates = [ - workerSource.slice(readIsolationStart, readIsolationEnd), - workerSource.slice(mcpStart, mcpEnd), - ]; + const readIsolationGate = workerSource.slice(readIsolationStart, readIsolationEnd); + const mcpGate = workerSource.slice(mcpStart, mcpEnd); expect(readIsolationStart).toBeGreaterThan(-1); expect(readIsolationEnd).toBeGreaterThan(readIsolationStart); expect(mcpStart).toBeGreaterThan(-1); expect(mcpEnd).toBeGreaterThan(mcpStart); - for (const gate of gates) { - expect(gate).toContain( - "if (effectiveBackendType === 'zmx' && paneProbe === 'unknown')", - ); - expect(gate).not.toContain("if (paneProbe === 'unknown')"); - expect(gate).toContain('shouldRejectPersistentPostKillProbe('); - expect(gate).not.toContain("postKillProbe !== 'missing'"); - } + + // ── read-isolation gate (this PR): liveness is TRI-STATE. `unknown` is routed + // through the state machine (refuse-inconclusive-probe) for ALL backends, so + // the OLD ZMX-only early `unknown` throw is GONE, and the post-kill confirm + // requires an authoritative `missing` (NOT the ZMX-scoped shared helper). ── + expect(readIsolationGate).not.toContain( + "if (effectiveBackendType === 'zmx' && paneProbe === 'unknown')", + ); + expect(readIsolationGate).toContain('paneProbe,'); // passed tri-state into the state machine + expect(readIsolationGate).toContain("postKillProbe !== 'missing'"); + expect(readIsolationGate).not.toContain('shouldRejectPersistentPostKillProbe('); + expect(readIsolationGate).toContain('refuseInconclusiveProbe:'); + + // ── mcp-gateway gate (pre-existing, unchanged): still ZMX-scoped unknown + + // shared helper. Not in scope for the no-transport tri-state fix. ── + expect(mcpGate).toContain("if (effectiveBackendType === 'zmx' && paneProbe === 'unknown')"); + expect(mcpGate).toContain('shouldRejectPersistentPostKillProbe('); }); it('verifies read-isolation teardown against the exact captured backend target', () => { diff --git a/test/read-isolation.test.ts b/test/read-isolation.test.ts index affb090d7..daf046673 100644 --- a/test/read-isolation.test.ts +++ b/test/read-isolation.test.ts @@ -333,7 +333,7 @@ describe('evaluatePersistentPaneMigration — policy-on/off pane provenance stat isolationMarkerPresent: false, policyOffTombstonePresent: false, policyOffTombstoneValid: false, - paneLive: true, + paneProbe: 'exists' as const, isolationMarkerReattachSafe: false, }; @@ -371,14 +371,14 @@ describe('evaluatePersistentPaneMigration — policy-on/off pane provenance stat it('policy ON + no live pane + stale tombstone lingering → clear stale (else a later policy-off misreads it)', () => { expect(evaluatePersistentPaneMigration({ - ...base, appliedIsolationCapabilities: CAPS_ON, paneLive: false, + ...base, appliedIsolationCapabilities: CAPS_ON, paneProbe: 'missing', policyOffTombstonePresent: true, })).toEqual({ action: 'clear-stale-then-cold-spawn' }); }); it('policy ON + no live pane + no provenance → skip (fresh spawn stamps)', () => { expect(evaluatePersistentPaneMigration({ - ...base, appliedIsolationCapabilities: CAPS_ON, paneLive: false, + ...base, appliedIsolationCapabilities: CAPS_ON, paneProbe: 'missing', })).toEqual({ action: 'skip' }); }); @@ -423,20 +423,20 @@ describe('evaluatePersistentPaneMigration — policy-on/off pane provenance stat it('policy OFF + pane MISSING but stale marker lingers → clear stale then cold-spawn (no next-restart false kill)', () => { expect(evaluatePersistentPaneMigration({ ...base, appliedIsolationCapabilities: CAPS_OFF, - paneLive: false, isolationMarkerPresent: true, + paneProbe: 'missing', isolationMarkerPresent: true, })).toEqual({ action: 'clear-stale-then-cold-spawn' }); }); it('policy OFF + pane MISSING but stale tombstone lingers → clear stale then cold-spawn', () => { expect(evaluatePersistentPaneMigration({ ...base, appliedIsolationCapabilities: CAPS_OFF, - paneLive: false, policyOffTombstonePresent: true, + paneProbe: 'missing', policyOffTombstonePresent: true, })).toEqual({ action: 'clear-stale-then-cold-spawn' }); }); it('policy OFF + pane MISSING + no files → skip (nothing stale to clear)', () => { expect(evaluatePersistentPaneMigration({ - ...base, appliedIsolationCapabilities: CAPS_OFF, paneLive: false, + ...base, appliedIsolationCapabilities: CAPS_OFF, paneProbe: 'missing', })).toEqual({ action: 'skip' }); }); @@ -452,9 +452,61 @@ describe('evaluatePersistentPaneMigration — policy-on/off pane provenance stat // so it cannot mislead a future decision. expect(evaluatePersistentPaneMigration({ ...base, appliedIsolationCapabilities: CAPS_OFF, noTransport: false, - paneLive: false, isolationMarkerPresent: true, + paneProbe: 'missing', isolationMarkerPresent: true, })).toEqual({ action: 'clear-stale-then-cold-spawn' }); }); + + // ── TRI-STATE liveness: `unknown` must NEVER be collapsed into "dead". The + // original bug modeled paneLive:boolean, so a flaky `unknown` probe took the + // dead-pane path and CLEARED the provenance of a possibly-live confined pane + // (or cold-spawned around it). `unknown` now fail-closes wherever anything is + // at stake, and only `skip`s a wholly unconcerned session. ── + it('policy ON + UNKNOWN probe → refuse (never clear a still-confined pane on a flaky probe)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_ON, paneProbe: 'unknown', + isolationMarkerPresent: true, + })).toEqual({ action: 'refuse-inconclusive-probe' }); + }); + + it('policy ON + UNKNOWN probe + no provenance → still refuse (policy-on is always concerned)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_ON, paneProbe: 'unknown', + })).toEqual({ action: 'refuse-inconclusive-probe' }); + }); + + it('policy OFF + no-transport tmux + UNKNOWN probe + legacy marker → refuse (the core tri-state fix)', () => { + // The initial-`unknown` scenario: a flaky tmux probe on an upgraded + // no-transport session with a leftover isolation marker. Must NOT clear-stale + // (the pane may still be alive AND confined). + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, paneProbe: 'unknown', + isolationMarkerPresent: true, + })).toEqual({ action: 'refuse-inconclusive-probe' }); + }); + + it('policy OFF + no-transport tmux + UNKNOWN probe + NO provenance → refuse (in migration scope = concerned)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, paneProbe: 'unknown', + })).toEqual({ action: 'refuse-inconclusive-probe' }); + }); + + it('policy OFF + UNKNOWN probe + stale provenance out of migration scope → refuse (provenance = concerned)', () => { + // Transport-enabled chat / non-tmux backend, but a stale marker is on disk: an + // `unknown` probe must not clear it (the file might belong to a live pane). + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, noTransport: false, + isolationCapableBackend: false, paneProbe: 'unknown', isolationMarkerPresent: true, + })).toEqual({ action: 'refuse-inconclusive-probe' }); + }); + + it('policy OFF + UNKNOWN probe + NOTHING at stake (out of scope, no provenance) → skip (no false start-failure)', () => { + // Ordinary transport chat, non-file-sandbox backend, no provenance: probe + // flakiness must NOT block startup — there is nothing to clear or protect. + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, noTransport: false, + isolationCapableBackend: false, paneProbe: 'unknown', + })).toEqual({ action: 'skip' }); + }); }); describe('executePersistentPaneMigration — ordered, fail-closed IO seam', () => { @@ -467,6 +519,7 @@ describe('executePersistentPaneMigration — ordered, fail-closed IO seam', () = confirmPaneGone: () => { calls.push('confirm'); }, clearProvenanceVerified: () => { calls.push('clear'); }, reselectBackend: () => { calls.push('reselect'); }, + refuseInconclusiveProbe: (): never => { calls.push('refuse'); throw new Error('inconclusive probe'); }, }; return { calls, eff }; }; @@ -525,6 +578,13 @@ describe('executePersistentPaneMigration — ordered, fail-closed IO seam', () = .toThrow('rmdir'); expect(calls).toEqual(['clear']); }); + + it('refuse-inconclusive-probe → refuse ONLY (never kill/confirm/clear/reselect), throws', () => { + const { calls, eff } = makeEffects(); + expect(() => executePersistentPaneMigration({ action: 'refuse-inconclusive-probe' }, eff)) + .toThrow('inconclusive probe'); + expect(calls).toEqual(['refuse']); // NOT kill, NOT clear, NOT reselect + }); }); describe('policyOffTombstoneValid — secure-read schema/version check', () => { From 474a952a3f20bb88288f0d2336b8aae45481edef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 18 Aug 2026 02:31:35 -0700 Subject: [PATCH 8/8] =?UTF-8?q?fix(sandbox):=20=E6=8C=81=E4=B9=85=20pane?= =?UTF-8?q?=20=E8=AF=81=E6=98=8E=E6=94=B9=20PENDING=E2=86=92COMMIT=20?= =?UTF-8?q?=E4=B8=A4=E9=98=B6=E6=AE=B5=EF=BC=8C=E9=97=AD=E5=90=88=E4=BB=A3?= =?UTF-8?q?=E9=99=85=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 缺陷(独立于三态修复的第 2 个) read-isolation 持久 pane 证明(isolation marker / policy-off tombstone)此前在 backend.spawn() 之前无条件写入。但 spawn() 可能把本次 launch 绑定到一个晚到的 同名 pane:zellij / TmuxBackend 在 spawn 内动态把 fresh 翻成 reattach, TmuxPipe/herdr/zmx 则在重名时抛错。于是"给还不存在的 pane 预写的有效证明"会被 一个外来/未知隔离态的 pane 洗白——下次重启 probe=exists+证明 valid 即被接受, 证明是循环的。三态修复挡不住它(这次结果是 exists 且"匹配证明"正是本 worker 自写)。 ## 修复(per-backend,非统一 bootstrap) 关键事实:四个持久后端里三个在 spawn() 同步返回时就"可归属 fresh"(TmuxPipe new-session 同步抛错 / herdr agent-start 同步返回创建的 pane、重名抛错 / zmx createFreshSession 内部已用 bootstrapPath+launchPid+release-token 握手自证), 只有 zellij 因 pty.spawn 异步创建 session 而没有同步可归属信号;且 policy-off tombstone 是 tmux-only。所以按后端分别处理,不引入统一 bootstrap。 - **证明拆 PENDING→COMMITTED 两态**:pre-spawn 写 PENDING(带随机 nonce 的记录, 两个 validator 都拒、presence 仍进保守 guard);spawn 同步返回后仅在确认 fresh 非 reattach 时 compare-before-replace 换成 committed(带四元 generation fence)。 - **PENDING 是状态机支配输入**:pendingProvenancePresent 先于一切判定(所有后端、 两个 policy 方向、无视 tmux scope)——exists→kill、unknown→refuse、missing→clear。 堵住原先 policy-OFF+live+!inMigrationScope 直接 skip 的洞(enrolled 非 tmux pane 留 pending、credential policy 翻 OFF 后会 warm-reattach 未决 generation)。 - **late-flip teardown**:预测 fresh 但 spawn 返回 actual isReattach===true → 立刻 teardown exact target→确认权威 missing→refuse。 - **PENDING 是 spawn 前 fail-closed 准入前置**:policy-ON 与 policy-OFF 两臂的 pending 写失败都抛错拒启动(policy-ON 之前误设 best-effort catch 吞错——写失败后若 late-flip 反接旧 pane,pendingProvenanceCommit 为空会跳过整个 teardown 块、让未归属 pane 继续跑)。 - **exact-target teardown(不 name-only)**:teardown 走纯策略 persistentTeardownKillKind ——herdr 隔离/MCP agent 落在共享 host `botmux`,name-only kill 会杀光所有 bot 的 agent; 改用 captured persistentBackendTarget(herdr agent scope)/ ZMX frozen-PID 身份路径; 所有后端 post-kill 只接受权威 missing,否则保留 pending。 - **bump ISOLATION_PANE_MARKER_VERSION 10→11 + 严格 state==='committed'**:v10 marker 正是 "漏洞态 pre-spawn 直接发布、无 state",若容忍无-state 则升级后**存量**被洗白的旧 marker 仍被当有效。版本 bump + 严格 committed 强制每个 pre-v11 无-state marker 冷启一次,闭合 存量风险。tombstone 从未正式发布,直接严格 committed 不给旧无-state 兼容。 - **zellij(选项 B)**:无同步可归属信号,isolation-capable zellij 永不 commit → 证明停在 pending → 每次 restart/suspend-resume 都 cold-spawn(暂不 warm-reattach)。 ## 影响面 credential-isolated zellij persistent pane 暂不 warm-reattach(availability 降级,非安全 降级——cold-spawn 恒 fail-closed)。恢复它需一个 fresh-launch attributable ack 协议,留独立 follow-up。tmux/herdr/zmx warm-reattach 不变。普通聊天、非持久后端不受影响。 ## 测试 - read-isolation.test.ts:PENDING 支配真值表(exists/unknown/missing × scope)+ 两条 policy 翻转回归 + pending/committed validator 严格表 + v10 无-state marker 拒(存量迁移)+ persistentTeardownKillKind 行为测试(herdr→target 不 name / zmx→frozen-PID / tmux 有无 target)。 - api-only-mode-wiring source-lock:PENDING 写入两臂 fail-closed(policy-ON 无 best-effort catch) + 支配入参 + 提交块(late-flip teardown / exact-target 分发 / fence + compare-before-replace / commit-fail teardown / postKill!=='missing' 保留 pending)。worker-pipe source-lock 计数 CliSpawnSupersededError 5→6(commit-fail 分支正确 re-throw superseded 非吞)。 - 反向变异 5 处(去三态 unknown 保护 / 去 PENDING 支配 / 去 late-flip teardown / 验证器容忍无-state / teardown name-only / policy-ON 吞错)均确认对应测试变红后还原。 - 定向 11 文件 504 pass/1 skip + 全部读 worker.ts 源码的 source-lock 测试通过;config-dir 与 worker-dsh 两红为既有环境基线(与 master 一致),零新增回归。pnpm build 绿;git diff --check 干净。 - 顺带清理:docs/design 内部复审轮次字样 scrub 成中性(公开史规范)。 Co-Authored-By: Claude ## 对称竞态补修(predicted-reattach → actual-fresh) 上一版只处理了 predicted-fresh→actual-reattach,漏了对称方向:herdr backend 在 isReattach=true 但 botmux agent 消失时会静默 agent start(fresh),而 worker 已按 willReattachPersistent=true 跳过 PENDING + credential wrapper(13316/13453/13499) → enrolled 主机上起个未包 credential boundary 的 CLI 并继承旧 committed marker。 跨后端核实:仅 herdr 生产路径有此转换(TmuxPipe 冻结 _isReattach / Zellij reattaching 只 false→true / ZMX 已显式抛 / TmuxBackend 仅测试面),故只在 herdr 修: - **herdr backend 冻结**:isReattach=true 但 getAgent 空 → 抛错(镜像 ZMX),绝不内部 fresh;worker 下一轮 cold path 才写 PENDING + 组 wrapper。 - **selector owned-session 分支改 agent 级三态预测**(不用 hasSession&&hasAgent——两个 boolean 会把 unknown 压 false 重新 fail-open):host unknown→refuse;host missing→迁 shared cold;host exists+agent unknown→refuse;host exists+agent exists→同 host warm reattach;host exists+agent missing→**同 host 内 cold start(isReattach:false,不 teardown 仍活的 host)**。避免 backend 冻结在 session 级预测下 throw-loop。 - 回归:herdr backend true→missing 冻结(TOCTOU 防线)+ selector owned-session 5 态表 (owned-session 5 态表)。反向变异(selector 退回 session 级预测)确认变红后还原。 Co-Authored-By: Claude --- .../2026-07-30-api-only-core-only-bot-mode.md | 20 +- src/adapters/backend/herdr-backend.ts | 26 +- .../backend/session-backend-selector.ts | 63 ++++- src/adapters/cli/read-isolation.ts | 132 +++++++++- src/worker.ts | 233 +++++++++++++++--- test/api-only-mode-wiring.test.ts | 56 ++++- test/herdr-backend.test.ts | 19 +- test/read-isolation.test.ts | 165 +++++++++++++ test/tmux-reattach-backend.test.ts | 59 +++++ test/worker-pipe-initial-screen-order.test.ts | 6 +- 10 files changed, 707 insertions(+), 72 deletions(-) diff --git a/docs/design/2026-07-30-api-only-core-only-bot-mode.md b/docs/design/2026-07-30-api-only-core-only-bot-mode.md index 573bf55a3..94a8b07aa 100644 --- a/docs/design/2026-07-30-api-only-core-only-bot-mode.md +++ b/docs/design/2026-07-30-api-only-core-only-bot-mode.md @@ -1,6 +1,6 @@ # PR D · API-only (core-only / headless) bot mode — 设计方案 -> ⚠️ **阅读顺序**:下面「架构现状 / 需要 gate 的耦合点」是**首版初稿**,其中「核心控制回路已完全 Feishu-free、只需 gate boot 三点」的判断**经 codex 两轮复审已被推翻**。真正落地的设计以文末 **两个「修订」段** 为准(中央 `larkTransportEnabled` 会话边界 + bot 级 `assertLarkTransport` 原语边界)。初稿保留仅作演进记录。 +> ⚠️ **阅读顺序**:下面「架构现状 / 需要 gate 的耦合点」是**首版初稿**,其中「核心控制回路已完全 Feishu-free、只需 gate boot 三点」的判断**经复审已被推翻**。真正落地的设计以文末 **两个「修订」段** 为准(中央 `larkTransportEnabled` 会话边界 + bot 级 `assertLarkTransport` 原语边界)。初稿保留仅作演进记录。 ## 目标 让 botmux 作为 **core-only 控制 Server**:riff 在 Sandbox 里纯 HTTP API 驱动 botmux → botmux 直接控 CLI Agent,**全程无需真实飞书 Bot 凭证**。 @@ -8,7 +8,7 @@ ## 架构现状(已核实) - **一个 daemon 进程 = 一个 bot**。pm2 ecosystem 有 botmux-0..3(`BOTMUX_BOT_INDEX` 经 `loadBotConfigAtIndex` 选 config)+ botmux-dashboard。 - dashboard(:3000,内网 IP 可达)代理 `/api/trigger` + `/api/sessions/:id/trigger-result` 到 per-bot daemon(`registry.getByAppId(larkAppId)`)。riff 用 dashboard `activeToken` 鉴权。 -- ~~**核心控制回路走 `asyncReturnSessionId` 时已完全 Feishu-free**,只需 gate boot 层~~ ← **首版误判,已被 codex 推翻**:final_output 之前还有 roster 探测、worker 辅助 UI、botmux ask、doc 轮询、allowedUsers 解析等多条飞书链路;且 apiOnly 只是 boot hint、trigger 仍可指向真实 chat。正确设计见文末修订段。 +- ~~**核心控制回路走 `asyncReturnSessionId` 时已完全 Feishu-free**,只需 gate boot 层~~ ← **首版误判,经复审推翻**:final_output 之前还有 roster 探测、worker 辅助 UI、botmux ask、doc 轮询、allowedUsers 解析等多条飞书链路;且 apiOnly 只是 boot hint、trigger 仍可指向真实 chat。正确设计见文末修订段。 ## 需要 gate 的耦合点(全部在 boot 层) | # | file:line | 作用 | 处理 | @@ -68,9 +68,9 @@ rebase master → 开 PR(中文 + 影响面)→ 发 canary → 配一个 `ap --- -## 修订(codex 复审后):从「boot 三点」升级为「中央 transport 能力边界」 +## 修订(复审后):从「boot 三点」升级为「中央 transport 能力边界」 -首版只 gate 了 boot 的 3 个飞书订阅/探测点,误判「运行时零飞书」。codex 复审指出:final_output 前仍有多条飞书链路未 gate,且 apiOnly 只是 boot hint。修订按中央能力边界收口: +首版只 gate 了 boot 的 3 个飞书订阅/探测点,误判「运行时零飞书」。复审指出:final_output 前仍有多条飞书链路未 gate,且 apiOnly 只是 boot hint。修订按中央能力边界收口: **核心不变量** `larkTransportEnabled(ds)`(core/types.ts):apiOnly bot 或 HTTP virtual session(http_async_*/http_wait_*)→ 返回 false = 该会话禁止一切飞书副作用。所有 seam fail-closed 于此,新增无飞书 surface 自动被覆盖。 @@ -93,9 +93,9 @@ rebase master → 开 PR(中文 + 影响面)→ 发 canary → 配一个 `ap --- -## 修订 2(codex 第 3 轮复审后):bot 级原语边界 +## 修订 2(进一步复审后):bot 级原语边界 -会话级 `larkTransportEnabled` 仍不够——它只覆盖「知道自己在哪个 session」的调用方。codex 指出还有 3 类旁路: +会话级 `larkTransportEnabled` 仍不够——它只覆盖「知道自己在哪个 session」的调用方。复审指出还有 3 类旁路: 1. **sessionReply 返回伪 messageId**:no-op 返回 `http_async_*` 被存进 streamCardId,下一条 screen_update 走 `updateMessage` 仍直调飞书 → 改为返回 `''`(空 id,falsy guard 天然跳过 patch)。 2. **agent 直接 `botmux send`**:CLI 无 capability 门 → apiOnly 配了真 secret 会真发飞书。 3. **非 session 全局路径**:v3 distillation / runtime-update / restart-report / overload DM 等直接 send/update,不经会话。 @@ -111,7 +111,7 @@ rebase master → 开 PR(中文 + 影响面)→ 发 canary → 配一个 `ap --- -## 最终架构(canonical,codex 7 轮复审收敛) +## 最终架构(canonical,多轮复审收敛) 前面「初稿 / 修订 / 修订2」记录演进;**以本节为准**。核心契约:**apiOnly bot 或 HTTP virtual session(http_async_/http_wait_)= 零飞书网络(读+写)**。分层: @@ -134,7 +134,7 @@ rebase master → 开 PR(中文 + 影响面)→ 发 canary → 配一个 `ap **已证伪并纠正的初稿判断**:①「运行时零改动、只 gate boot 三点」❌——final 前有大量飞书链路。②「getBotClient 是唯一门就够」❌——doc-comment/开放平台/worker uploader/CLI reload 各有旁路。③「send 单点早拒 = 中央 capability」❌——history/quoted/bots/dispatch 各自可达飞书。④「rename/avatar 是 setup-only」❌——是 dashboard runtime 路由。⑤「apiOnly boot hint 足够」❌——secret 若下发到 worker/env/cred 可被恢复。 -**7. 沙箱文件层 no-transport host-authority profile(fs-policy.ts,codex 提权复审收敛)** +**7. 沙箱文件层 no-transport host-authority profile(fs-policy.ts,安全复审收敛)** > ⚠️ **2026-08 修订(威胁模型放宽)**:no-transport 会话**不再被强制文件读隔离**。此前 `forkWorker` 把「会话无飞书 transport 通道」当作强制隔离条件(`readIsolation = botCfg.readIsolation===true || !larkTransportEnabled(...)`),apiOnly bot / HTTP virtual 会话无论 owner 有没有配 sandbox 都被关进本节所述的 host-authority profile;owner 无法关闭。owner 拍板:**磁盘可读范围应由 owner 自己的 `sandbox`/`readIsolation` 配置决定,不该在 no-transport 逻辑里写死。** 现改为 `readIsolation = botCfg.readIsolation===true`(opt-in only),与普通聊天会话对称。 > @@ -153,6 +153,6 @@ rebase master → 开 PR(中文 + 影响面)→ 发 canary → 配一个 `ap - **权威根 = 目录,不是 exact 文件黑名单**(`computeNoTransportAuthorityRoots`,纯函数 + 导出可单测):**始终冻结 configured(`dirname(dataDir)`)+ default `~/.botmux` 双根**(custom SESSION_DATA_DIR 时 default 根仍存 HMAC/bots.json,二选一会漏),外加 `~/.lark-cli` / `~/.lark-cli-bots` / macOS lark-cli store。整根 deny 自动吸收 `.dashboard-secret/token`、`feishu-session`、bots.json 的 bak/tmp/未来 sidecar、dashboard-daemons 端口表、legacy send-cred。 - **深层重开 fail-closed**:`workingDir` / `userPaths.readWrite/readOnly` / `extraWritePaths` / `readonlyRoots` 落在权威根内的(own BOT_HOME 除外)在进规则集**前** `dropAuthority` 过滤,被抑制项**记录并由 worker 日志**(不静默)。`workingDir` 若 **IS**(或落在)权威根内(own BOT_HOME 除外)→ 抛 `FsPolicyConfigError`(不 silent drop 后 spawn 进未授权 cwd);`workingDir=~`(仅是权威根的祖先)保留,深层 parent deny 自然盖住。 - **外置 BOTS_CONFIG fail-closed**:daemon 用 `getLoadedConfigPath()` **冻结实际 loaded config path** 传 worker(不让 worker 用 BOTS_CONFIG env 重猜)。落在**任何根外** → 抛 `FsPolicyConfigError('external-bots-config')`(不静默 mask `dirname`,否则 `/tmp/bots.json` 遮 `/tmp`、`/etc/bots.json` 遮 `/etc`、`project/bots.json` 遮项目根,废掉 core CLI)。**「落在根内」是必要非充分**——white-in-black + deepest-prefix-wins 下,root 内更深的可信 carve-out(own BOT_HOME RW / bin RO / attachments RW / outbox / install-root)会把落其下的 config 重新开放(连 `.bak/.tmp` sidecar 一并暴露)。故 buildFsPolicy 在**完整 rules merge 后**再用 `accessForPath` 自检:loaded config **自身和 dirname 都必须 deny**,任一 RO/RW → 抛 `FsPolicyConfigError('bots-config-in-carveout')`(dirname 检查顺带覆盖同目录 sidecar,且未来新增 carve-out 自动 fail-closed,无需枚举文件名)。worker 把该异常(及上面两类)统一转成 hard spawn-abort + 诊断,绝不 fail-open。 - - **carve-out 最小**:own BOT_HOME RW(除 send-cred deny)+ own bots-info/sessions-self/bot-openids-self RO + own turn-sends RW + CLI 运行必需(.data-dir/.dashboard-port/bin/claude-plugin/lark-scopes/install root)。**模型 CLI 的 authPaths(如 codex-app 的 `~/.codex`)始终保留 RW**——那是模型自己的登录态,不是飞书凭证;混淆会击穿核心功能(本轮 codex 抓到的回归)。redirect 到 BOT_HOME 的 CODEX_HOME 走 `resolveRedirectedAdapterAuthPaths` 单一真源:redirected 丢宿主 `~/.codex`(防泄漏,BOT_HOME 副本已 provision),cold-start 未 redirect 时保留宿主登录源。 + - **carve-out 最小**:own BOT_HOME RW(除 send-cred deny)+ own bots-info/sessions-self/bot-openids-self RO + own turn-sends RW + CLI 运行必需(.data-dir/.dashboard-port/bin/claude-plugin/lark-scopes/install root)。**模型 CLI 的 authPaths(如 codex-app 的 `~/.codex`)始终保留 RW**——那是模型自己的登录态,不是飞书凭证;混淆会击穿核心功能(本轮复审抓到的回归)。redirect 到 BOT_HOME 的 CODEX_HOME 走 `resolveRedirectedAdapterAuthPaths` 单一真源:redirected 丢宿主 `~/.codex`(防泄漏,BOT_HOME 副本已 provision),cold-start 未 redirect 时保留宿主登录源。 - **测试**:fs-policy.test 60 测含 no-transport 矩阵(双根冻结 / `~/.lark-cli` 敌意 nested RW/RO 拦截 / 外置 config `/tmp` `/etc` `project` 三形态 `external-bots-config` fail-closed + kind 断言 / **config 落 carve-out(BOT_HOME/bin/attachments/outbox/install 5 形态)`bots-config-in-carveout` fail-closed,denied 子目录(`conf/`、`data/`)config + dirname + sidecar 全 deny 正向** / workingDir=权威根 抛错、workingDir=~ 保留 / `computeNoTransportAuthorityRoots` 去重 / **真 codex-app adapter redirect→own CODEX_HOME 可用 + 宿主 ~/.codex 按 redirect 语义 drop/keep**);api-only-mode-wiring 补 worker 真实装配 source-lock(worker 传双根 + frozen loaded config + FsPolicyConfigError→spawn-abort + 日志抑制项;daemon 冻结 getLoadedConfigPath)——负向验证删 worker freeze / 禁用 carve-out 自检 即红(关闭 codex「删 freeze 仍全绿」缺口)。 + **测试**:fs-policy.test 60 测含 no-transport 矩阵(双根冻结 / `~/.lark-cli` 敌意 nested RW/RO 拦截 / 外置 config `/tmp` `/etc` `project` 三形态 `external-bots-config` fail-closed + kind 断言 / **config 落 carve-out(BOT_HOME/bin/attachments/outbox/install 5 形态)`bots-config-in-carveout` fail-closed,denied 子目录(`conf/`、`data/`)config + dirname + sidecar 全 deny 正向** / workingDir=权威根 抛错、workingDir=~ 保留 / `computeNoTransportAuthorityRoots` 去重 / **真 codex-app adapter redirect→own CODEX_HOME 可用 + 宿主 ~/.codex 按 redirect 语义 drop/keep**);api-only-mode-wiring 补 worker 真实装配 source-lock(worker 传双根 + frozen loaded config + FsPolicyConfigError→spawn-abort + 日志抑制项;daemon 冻结 getLoadedConfigPath)——负向验证删 worker freeze / 禁用 carve-out 自检 即红(关闭「删 freeze 仍全绿」缺口)。 diff --git a/src/adapters/backend/herdr-backend.ts b/src/adapters/backend/herdr-backend.ts index cb1a4146d..43b205e4e 100644 --- a/src/adapters/backend/herdr-backend.ts +++ b/src/adapters/backend/herdr-backend.ts @@ -372,11 +372,18 @@ export class HerdrBackend implements SessionBackend { cliPid?: number; cliCwd?: string; + /** Default managed agent name for a Botmux-launched CLI (the single source of + * truth shared by the constructor default and the selector's agent-precise + * reattach probe). */ + static defaultAgentName(): string { + return 'botmux'; + } + constructor( readonly sessionName: string, private readonly opts: HerdrBackendOptions = {}, ) { - this.agentName = opts.agentName ?? 'botmux'; + this.agentName = opts.agentName ?? HerdrBackend.defaultAgentName(); if (opts.externalTarget?.paneId) this.paneId = opts.externalTarget.paneId; } @@ -537,6 +544,23 @@ export class HerdrBackend implements SessionBackend { if (existing) { this.actuallyReattached = true; this.paneId = existing.pane_id; + } else if (this.opts.isReattach) { + // FREEZE the reattach decision (mirrors ZmxBackend: "never turn a stale + // reattach into a new CLI after the backing session disappeared"). The + // worker predicted reattach from an earlier probe and therefore SKIPPED + // the cold-path setup that only runs on !willReattachPersistent — the + // PENDING generation proof AND the credential-only Seatbelt/bwrap wrapper. + // If the `botmux` agent vanished between that probe and here, silently + // `agent start`ing a fresh CLI would launch it WITHOUT the credential + // boundary (unsafe on an enrolled host) and leave the old committed marker + // in place to later reattach it as "isolated". Post-spawn teardown can't + // undo an already-executed unwrapped CLI, so we must refuse HERE: throw so + // the worker's next launch takes the cold path (write PENDING + assemble + // the wrapper BEFORE creating the agent). + throw new Error( + `herdr agent ${this.agentName} in ${this.sessionName} disappeared before reattach; ` + + `refusing to silently start a fresh (unwrapped) generation`, + ); } else if (herdrUsesPaneAgentStart()) { this.paneId = this.startPaneAgent(bin, args, opts); } else { diff --git a/src/adapters/backend/session-backend-selector.ts b/src/adapters/backend/session-backend-selector.ts index bc3ae397a..086e0e92a 100644 --- a/src/adapters/backend/session-backend-selector.ts +++ b/src/adapters/backend/session-backend-selector.ts @@ -362,16 +362,59 @@ export function selectSessionBackend(opts: { + 'close it explicitly before enabling isolation or MCP', ); } - } else if (HerdrBackend.hasSession(ownedSessionName)) { - return { - backend: new HerdrBackend(ownedSessionName, { isReattach: true }), - isTmuxMode: false, - isPipeMode: true, - isZellijMode: false, - persistentSessionName: ownedSessionName, - persistentBackendTarget: { backendType: 'herdr', sessionName: ownedSessionName }, - isReattach: true, - }; + } else { + // Owned isolation/MCP host. The reattach decision must be AGENT-precise, not + // session-level: herdr can keep a live host session whose `botmux` agent row + // has disappeared (killSession's own comment records that session dir, agent + // metadata and process state diverge). Predicting reattach from the SESSION + // alone would, when the agent is gone, make HerdrBackend.spawn's frozen + // reattach guard throw every launch (kill-loop) — and the worker would have + // skipped the cold-path setup (PENDING proof + credential-only wrapper, gated + // on !willReattachPersistent), so a silent fresh-start would run UNWRAPPED. + // + // Use TRI-STATE probes (never `hasSession && hasAgent` — those collapse + // `unknown` to false and re-introduce fail-open). Table: + // host unknown → refuse (no kill, no spawn) + // host missing → fall through to the shared-host cold path + // host exists, agent unknown → refuse + // host exists, agent exists → reattach the same owned host + // host exists, agent missing → COLD start IN the same owned host + // (isReattach:false → worker writes + // PENDING + assembles the wrapper, then + // Herdr `agent start`s a new generation); + // no teardown of the still-live host. + const ownedAgentName = HerdrBackend.defaultAgentName(); + const hostProbe = HerdrBackend.probeSession(ownedSessionName); + if (hostProbe === 'unknown') { + throw new Error( + `owned herdr session ${ownedSessionName} probe inconclusive; ` + + 'refusing isolation/MCP reattach-vs-fresh decision', + ); + } + if (hostProbe === 'exists') { + const agentProbe = HerdrBackend.probeAgent(ownedSessionName, ownedAgentName); + if (agentProbe === 'unknown') { + throw new Error( + `owned herdr agent ${ownedAgentName} in ${ownedSessionName} probe inconclusive; ` + + 'refusing isolation/MCP reattach-vs-fresh decision', + ); + } + const agentLive = agentProbe === 'exists'; + return { + backend: new HerdrBackend(ownedSessionName, { + // agent missing on a live host → in-place cold start (create the agent, + // NOT the session, which already exists). + isReattach: agentLive, + }), + isTmuxMode: false, + isPipeMode: true, + isZellijMode: false, + persistentSessionName: ownedSessionName, + persistentBackendTarget: { backendType: 'herdr', sessionName: ownedSessionName }, + isReattach: agentLive, + }; + } + // host missing → fall through to the shared-host cold path below. } // Every fresh agent actively launched by this machine's Botmux shares the diff --git a/src/adapters/cli/read-isolation.ts b/src/adapters/cli/read-isolation.ts index 6d88b449b..a41b55f3a 100644 --- a/src/adapters/cli/read-isolation.ts +++ b/src/adapters/cli/read-isolation.ts @@ -325,10 +325,18 @@ export function buildSeatbeltProfile( // · 9 → 10: credential-only Seatbelt/bwrap panes receive a private rotating // managed-origin channel for capability-gated daemon IPC. A warm pane with // the v9 marker lacks both the env and the private read carve-out. +// · 10 → 11: provenance proofs gain a two-phase `state:'pending'|'committed'` +// lifecycle (generational-race fix). A v10 marker was written UNCONDITIONALLY +// BEFORE spawn (the vulnerable path) and has NO `state` field, so a late-winner +// pane may already wear a "valid" v10 marker it never earned. Requiring v11 + +// strict `state:'committed'` forces every pre-existing no-state marker to +// cold-spawn ONCE under the new pending→commit contract — closing the INSTALLED +// BASE risk, not just new spawns. (A legacy no-state marker is now version- +// rejected, so validators no longer need to tolerate `state===undefined`.) // #709 (→8) merged first; this PR (#714) rebased on top and takes 9. Numbers stay // strictly monotonic — a pane at any intermediate version must be rejected so it // cold-spawns under the current contract rather than bypassing a migration. -export const ISOLATION_PANE_MARKER_VERSION = 10; +export const ISOLATION_PANE_MARKER_VERSION = 11; export type IsolationCapability = 'credential' | 'read' | 'write'; @@ -420,6 +428,11 @@ export function isolationPaneMarkerContent( version: ISOLATION_PANE_MARKER_VERSION, bootId, capabilities: normalizeIsolationCapabilities(capabilities), + // Committed = an attributably-fresh generation was established (the proof is + // written PENDING before the pane exists, then rewritten committed only after + // spawn confirms a fresh, non-reattached generation). isolatedPaneReattachSafe + // refuses anything whose state is present-but-not-'committed'. + state: 'committed', ...(policy ?? {}), }); } @@ -460,9 +473,48 @@ export function policyOffTombstonePath(runtimeDataDir: string, sessionId: string * is diagnostic-bearing but its PRESENCE-as-valid (not equality to any live boot * id) is the reattach signal — a legitimate policy-off pane warm-reattaches * across daemon restarts, so binding to the current boot id would cold-spawn it - * every restart. bootId is kept only for diagnostics. */ + * every restart. bootId is kept only for diagnostics. + * + * `state:'committed'` is REQUIRED for authorization: a proof is written first as + * PENDING (see {@link provenancePendingContent}) before the pane is created, and + * only rewritten to committed once the fresh generation is attributably + * established (see the generational-race guard in worker.ts). A pending record + * never authorizes a reattach — {@link policyOffTombstoneValid} rejects it. */ export function policyOffTombstoneContent(bootId: string): string { - return JSON.stringify({ version: ISOLATION_PANE_MARKER_VERSION, policyOff: true, bootId }); + return JSON.stringify({ version: ISOLATION_PANE_MARKER_VERSION, policyOff: true, bootId, state: 'committed' }); +} + +/** + * PENDING provenance body: written to the FINAL proof path BEFORE `backend.spawn()` + * for a predicted-fresh persistent launch, then rewritten to the committed body + * only after the fresh generation is attributably established. It carries a random + * `nonce` (compare-before-replace at commit time, so a superseded generation's + * deferred callback can't overwrite a newer pending) and, deliberately, NEITHER a + * committed `state` NOR the structural fields the validators require — so both + * {@link policyOffTombstoneValid} and {@link isolatedPaneReattachSafe} reject it + * outright. Its on-disk PRESENCE still drives the conservative guard: a pending + * file means "this system KNOWS a generation's attribution is incomplete", which + * is STRONGER than legacy provenance and dominates the migration scope (a live + * pane with a pending proof is always killed + cold-spawned; see + * {@link evaluatePersistentPaneMigration}). + */ +export function provenancePendingContent(nonce: string): string { + return JSON.stringify({ version: ISOLATION_PANE_MARKER_VERSION, state: 'pending', nonce }); +} + +/** Extract the pending nonce for the compare-before-replace at commit time. + * Returns the nonce string only for a well-formed pending record read from a + * secure 0600 file; null otherwise (so a garbage/committed/absent file never + * matches a live launch's nonce). */ +export function provenancePendingNonce(content: string | null | undefined): string | null { + try { + const parsed = JSON.parse(content ?? '') as { state?: unknown; nonce?: unknown }; + return parsed.state === 'pending' && typeof parsed.nonce === 'string' && parsed.nonce.length > 0 + ? parsed.nonce + : null; + } catch { + return null; + } } /** @@ -474,16 +526,24 @@ export function policyOffTombstoneContent(bootId: string): string { * blank/garbage/structurally-wrong tombstone cannot authorize a warm reattach. * Mirror of {@link isolatedPaneReattachSafe}'s fail-closed parse discipline, but * for the opposite polarity: here VALID authorizes reattach. + * + * A `state:'pending'` record is explicitly rejected (an incomplete generation + * proof must never authorize). `state` is now REQUIRED to equal 'committed': the + * v11 version bump means every legitimate proof carries it, so a missing/other + * state is refused (this is what forces a pre-v11 no-state marker — possibly + * washed onto a late-winner pane under the old pre-spawn-write path — to + * cold-spawn once instead of being trusted). */ export function policyOffTombstoneValid(content: string | null | undefined): boolean { try { const parsed = JSON.parse(content ?? '') as { - version?: unknown; policyOff?: unknown; bootId?: unknown; + version?: unknown; policyOff?: unknown; bootId?: unknown; state?: unknown; }; return parsed.version === ISOLATION_PANE_MARKER_VERSION && parsed.policyOff === true && typeof parsed.bootId === 'string' - && parsed.bootId.trim().length > 0; + && parsed.bootId.trim().length > 0 + && parsed.state === 'committed'; } catch { return false; } @@ -522,10 +582,18 @@ export function isolatedPaneReattachSafe( writeSandbox?: unknown; originChannelId?: unknown; policyDigest?: unknown; + state?: unknown; }; if (parsed.version !== ISOLATION_PANE_MARKER_VERSION || typeof parsed.bootId !== 'string' || parsed.bootId.trim().length === 0 + // A committed generation proof is REQUIRED. A PENDING record (written before + // the pane is attributably established) must never authorize; and with the + // v11 bump every legitimate marker carries state:'committed', so a + // missing/other state (e.g. a washed pre-v11 no-state marker) is refused → + // cold-spawn once. (Version check above already rejects pre-v11; this keeps + // the contract explicit and rejects a same-version pending.) + || parsed.state !== 'committed' || !Array.isArray(parsed.capabilities) || parsed.capabilities.some(capability => typeof capability !== 'string' @@ -643,6 +711,17 @@ export type PersistentPaneMigrationInput = { * `unknown` must never have its provenance cleared nor be cold-spawned around. * Only an authoritative `missing` proves the pane is gone. */ paneProbe: SessionProbe; + /** A PENDING provenance file (marker OR tombstone whose secure-read body parses + * as `state:'pending'`) is present on disk. This is STRONGER than legacy + * provenance and DOMINATES everything below: it means the system explicitly + * knows a generation's fresh-attribution never completed (crash between + * pending-write and commit, or a late-flip/collision that was never committed). + * A pending file is evaluated FIRST, on ALL backends and BOTH policy directions, + * independent of the tmux migration scope — `exists`→kill, `unknown`→refuse, + * `missing`→clear. Its no-follow presence also keeps isolationMarkerPresent / + * policyOffTombstonePresent true (the file exists), but the pending branch runs + * before any of the committed-provenance logic. */ + pendingProvenancePresent: boolean; /** * Result of {@link isolatedPaneReattachSafe}(marker, current policy) — only * meaningful when policy is ON. The caller computes it (it needs the parsed @@ -676,11 +755,28 @@ export function evaluatePersistentPaneMigration( const { appliedIsolationCapabilities, isolationCapableBackend, noTransport, isolationMarkerPresent, policyOffTombstonePresent, policyOffTombstoneValid: tombstoneValid, - paneProbe, isolationMarkerReattachSafe, + paneProbe, pendingProvenancePresent, isolationMarkerReattachSafe, } = input; const policyOn = appliedIsolationCapabilities.length > 0; const anyProvenance = isolationMarkerPresent || policyOffTombstonePresent; const paneLive = paneProbe === 'exists'; + + // ── PENDING dominates everything (all backends, both policy directions, ANY + // scope). A pending provenance file means the system EXPLICITLY knows a + // generation's fresh-attribution never completed — a crash between the + // pre-spawn pending-write and the post-spawn commit, or a late-flip/collision + // that was never committed. This is STRONGER than legacy provenance, so it is + // judged BEFORE the migration-scope logic (which would otherwise `skip` a + // live pane out of the tmux scope and warm-reattach an undetermined + // generation — e.g. an enrolled zellij pane whose credential policy later + // flipped OFF). Only an authoritative `missing` clears it; `unknown` refuses + // (never erase evidence of a possibly-live pane); `exists` kills + cold-spawns. + if (pendingProvenancePresent) { + if (paneProbe === 'exists') return { action: 'kill-then-cold-spawn', clearAfterKill: true }; + if (paneProbe === 'unknown') return { action: 'refuse-inconclusive-probe' }; + return { action: 'clear-stale-then-cold-spawn' }; // authoritative missing + } + // TRI-STATE liveness. `unknown` is NOT "dead": the backend (tmux/zellij/herdr/ // zmx) could not answer, so the pane may still be alive AND still confined under // its original (possibly obsolete) policy. Acting on `unknown` — clearing @@ -821,6 +917,30 @@ export function executePersistentPaneMigration( } } +/** + * Which kill/probe primitive a persistent-pane teardown must use, so it targets + * the EXACT just-launched pane and never a shared host. Pure so the worker's + * inline teardown and the migration effects share one behaviorally-tested policy: + * + * · 'zmx' — identity-verified kill against the frozen managed PID + owned probe. + * · 'target' — the recorded PersistentBackendTarget (REQUIRED when one exists): + * a herdr isolated/MCP agent lives as `{sessionName:'botmux', + * agentName:}` on the SHARED host, so a name-only kill of + * 'botmux' would tear down every bot's agent. The target scopes the + * kill to this agent. + * · 'name' — last-resort name-only kill, ONLY when no target was recorded + * (legacy tmux/zellij that own their whole session by name). + */ +export type PersistentTeardownKillKind = 'zmx' | 'target' | 'name'; +export function persistentTeardownKillKind(input: { + backendType: string; + hasBackendTarget: boolean; +}): PersistentTeardownKillKind { + if (input.backendType === 'zmx') return 'zmx'; + if (input.hasBackendTarget) return 'target'; + return 'name'; +} + function dedupe(xs: string[]): string[] { return Array.from(new Set(xs)); } diff --git a/src/worker.ts b/src/worker.ts index fab26792e..201f324f3 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -31,10 +31,13 @@ import { evaluatePersistentPaneMigration, executePersistentPaneMigration, type PersistentPaneMigrationEffects, + persistentTeardownKillKind, isolationPaneMarkerPath, policyOffTombstonePath, policyOffTombstoneContent, policyOffTombstoneValid, + provenancePendingContent, + provenancePendingNonce, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, @@ -11973,6 +11976,19 @@ async function spawnCli( // Presence (above) still drives cleanup; validity drives authorization. const policyOffTombstoneIsValid = paneLive && policyOffTombstonePresent && policyOffTombstoneValid(readManagedOriginAuthorityFile(policyOffTombstoneFilePath)); + // PENDING provenance: a present marker OR tombstone whose secure-read body is a + // `state:'pending'` record. This is a generation whose fresh-attribution never + // completed (crash between pending-write and commit, or an uncommitted + // late-flip/collision). It DOMINATES the state machine (all backends, both + // policy directions) — see evaluatePersistentPaneMigration. Secure-read (not + // lstat) because only a real 0600 file we wrote can be a trusted pending + // record; a planted/garbage leaf reads as null → not pending → falls through + // to the normal presence-but-invalid handling (still conservative). + const pendingProvenancePresent = + (stalePaneMarkerPresent + && provenancePendingNonce(readManagedOriginAuthorityFile(stalePaneMarkerPath)) !== null) + || (policyOffTombstonePresent + && provenancePendingNonce(readManagedOriginAuthorityFile(policyOffTombstoneFilePath)) !== null); const migration = evaluatePersistentPaneMigration({ appliedIsolationCapabilities, isolationCapableBackend, @@ -11981,6 +11997,7 @@ async function spawnCli( policyOffTombstonePresent, policyOffTombstoneValid: policyOffTombstoneIsValid, paneProbe, + pendingProvenancePresent, isolationMarkerReattachSafe, }); // Verified removal of a provenance file: unlink then confirm it is truly gone @@ -13268,22 +13285,53 @@ async function spawnCli( log(`Sandbox ON (${cfg.cliId}, fs-policy ${policy.rules.length} rules): outbox=${sbx.outbox}`); } } - // Fresh spawn on a persistent backend: stamp provenance so a later reattach can - // be judged (see the stale-pane guard above). pty needs no marker (never - // reattached). Policy ON → ISOLATION marker; policy OFF on a no-transport, - // isolation-capable (tmux) session → POLICY-OFF TOMBSTONE, positively proving - // this generation is the new no-sandbox policy (so a later restart does not - // mistake it for a possibly-still-confined legacy pane and kill it). + // Fresh spawn on a persistent backend: write a PENDING generation proof BEFORE + // the pane is created, then COMMIT it after spawn only once the fresh generation + // is attributably established (see the post-spawn commit below). pty needs no + // marker (never reattached). + // + // Why pending-then-commit and not a single pre-spawn write: `backend.spawn()` may + // bind this launch to a LATE-ARRIVING same-named pane (zellij/TmuxBackend + // dynamically flip fresh→reattach; TmuxPipe/herdr/zmx throw on collision). A + // committed proof written before spawn would then "certify" a foreign/unknown- + // confinement pane the worker never actually created fresh — a circular, + // self-written proof. So we write PENDING first (both validators reject it, its + // presence drives the conservative guard), and only rewrite it to committed after + // spawn confirms a genuine fresh generation. + // + // Policy ON → ISOLATION marker; policy OFF on a no-transport isolation-capable + // (tmux) session → POLICY-OFF TOMBSTONE. The tombstone is tmux-only + // (isolationCapableBackend === tmux), so the policy-off circular-proof concern is + // fully closed synchronously. See PersistentPaneCommit below for the ZELLIJ + // exception (option B: isolation-capable zellij stays pending → always cold-spawn). + type PersistentPaneCommit = { + path: string; + nonce: string; + committedContent: string; + /** Also clear this sibling proof (mutual exclusivity) at commit time, verified. */ + clearSiblingPath?: string; + label: string; + }; + let pendingProvenanceCommit: PersistentPaneCommit | null = null; if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) { + // Policy-ON isolation marker. The PENDING write is a spawn-time ADMISSION + // PRECONDITION, NOT best-effort: if we cannot durably record the pending proof + // and then backend.spawn() dynamically reattaches a late-arriving same-named + // pane (zellij/TmuxBackend flip), the whole commit/teardown block below — + // gated on pendingProvenanceCommit — would be SKIPPED, so the late-flip + // teardown never runs and this launch keeps running attached to an + // unattributed generation. "No committed proof → next launch cold-spawns" only + // protects the NEXT launch, not THIS one. So a write failure must FAIL CLOSED + // here (throw before spawn), exactly like the policy-off arm. try { mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true }); - // Mutual exclusivity: a policy-ON generation must not carry a stale - // policy-off tombstone (else a later flip to policy-off could read it as a - // no-sandbox generation). Best-effort like the marker write itself. - try { unlinkSync(policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId)); } catch { /* absent */ } - replaceManagedOriginCapabilityFile( - isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId), - isolationPaneMarkerContent( + const nonce = randomBytes(32).toString('hex'); + const markerPath = isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId); + replaceManagedOriginCapabilityFile(markerPath, provenancePendingContent(nonce)); + pendingProvenanceCommit = { + path: markerPath, + nonce, + committedContent: isolationPaneMarkerContent( cfg.daemonBootId ?? '', appliedIsolationCapabilities, managedOriginChannelPolicyDigest @@ -13295,34 +13343,41 @@ async function spawnCli( } : undefined, ), + // A committed policy-ON generation must not carry a stale policy-off + // tombstone (a later flip to policy-off could read it as a no-sandbox gen). + clearSiblingPath: policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId), + label: 'isolation marker', + }; + } catch (e) { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + `could not record pending isolation-marker generation proof (${(e as Error).message})`, ); - } catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ } + } } else if (appliedIsolationCapabilities.length === 0 && persistentSessionName && !willReattachPersistent && noTransportSession && isolationCapableBackend) { - // Policy-OFF generation proof. Unlike the isolation marker this is NOT - // best-effort: if we cannot durably record that this no-transport pane is a - // known policy-off generation, a later restart would (correctly, fail-closed) - // treat the unproven live pane as possibly-still-confined and kill it. Rather - // than spawn a pane we cannot prove, FAIL CLOSED here. + // Policy-OFF generation proof (tmux-only: isolationCapableBackend === tmux). + // NOT best-effort: if we cannot durably record the PENDING proof we must fail + // closed rather than spawn a pane we can never prove. The committed tombstone + // is written post-spawn only on a confirmed fresh generation. try { mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true }); - // Mutual exclusivity: clear any stale isolation marker BEFORE recording the - // tombstone, so this policy-off generation is never seen as still-confined. - // Verified (fail-closed) — a lingering marker DOMINATES the tombstone in the - // guard, so leaving one would defeat the tombstone entirely. - const staleMarker = isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId); - try { unlinkSync(staleMarker); } catch { /* absent */ } - if (hostEntryExistsNoFollow(staleMarker)) { - throw new Error(`stale isolation marker survived removal at ${staleMarker}`); - } - replaceManagedOriginCapabilityFile( - policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId), - policyOffTombstoneContent(cfg.daemonBootId ?? ''), - ); + const nonce = randomBytes(32).toString('hex'); + const tombstonePath = policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId); + replaceManagedOriginCapabilityFile(tombstonePath, provenancePendingContent(nonce)); + pendingProvenanceCommit = { + path: tombstonePath, + nonce, + committedContent: policyOffTombstoneContent(cfg.daemonBootId ?? ''), + // A committed policy-off generation must not be shadowed by a stale + // isolation marker (which DOMINATES the tombstone in the guard). + clearSiblingPath: isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId), + label: 'policy-off tombstone', + }; } catch (e) { throw new Error( `[read-isolation] refusing to start session ${cfg.sessionId}: ` - + `could not record policy-off generation tombstone (${(e as Error).message})`, + + `could not record pending policy-off generation proof (${(e as Error).message})`, ); } } @@ -13590,6 +13645,118 @@ async function spawnCli( } const actuallyReattachedPersistent = 'isReattach' in backend && backend.isReattach === true; + // ── Generational-race commit/teardown for the read-isolation provenance proof ── + // We wrote a PENDING proof before spawn (pendingProvenanceCommit). Now that spawn + // has returned we know whether a FRESH generation was actually established. + if (pendingProvenanceCommit) { + const commit = pendingProvenanceCommit; + // Verified removal of a proof file: unlink then confirm truly gone (no-follow). + // A leaf we cannot remove must FAIL CLOSED — never leave an ambiguous proof. + const removeProofOrThrow = (path: string, label: string): void => { + try { unlinkSync(path); } catch { /* may already be absent — verified below */ } + if (hostEntryExistsNoFollow(path)) { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + `could not remove ${label} at ${path}`, + ); + } + }; + // Teardown the exact just-launched target, confirm authoritative `missing`, + // and only THEN keep/clear the pending proof per the tri-state result. On a + // non-missing (exists/unknown) post-kill probe we KEEP the pending proof and + // refuse — never erase evidence of a possibly-live pane. + // + // CRITICAL: kill the EXACT backend target, NOT the session name. An isolated / + // MCP herdr task lives as an agent on the SHARED host session `botmux` + // (target = {sessionName:'botmux', agentName:}); a name-only + // killPersistentSession('herdr','botmux') would tear down the whole shared host + // (every bot/topic agent). Mirror the migration effects' killStalePane / + // confirmPaneGone: target helper for herdr's agent scope, frozen-PID path for + // ZMX identity, name only as the last-resort fallback when no target exists. + const teardownTarget = selectedBackend.persistentBackendTarget; + const tearDownAndRefuse = (why: string): never => { + if (persistentSessionName) { + // Pure policy (behaviorally tested): herdr's shared-host agent MUST be + // killed via its target, never by the 'botmux' session name. + const killKind = persistentTeardownKillKind({ + backendType: effectiveBackendType, + hasBackendTarget: !!teardownTarget, + }); + try { + if (killKind === 'zmx') { + ZmxBackend.killManagedSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid); + } else if (killKind === 'target') { + killPersistentBackendTarget(teardownTarget!, cfg.sessionId); + } else { + killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName, cfg.sessionId); + } + } catch (killErr: any) { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ${why}; ` + + `could not kill the exact target (${killErr?.message ?? killErr}) — pending proof retained`, + ); + } + const postKill = killKind === 'zmx' + ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId).probe + : (killKind === 'target' + ? probePersistentBackendTarget(teardownTarget!) + : probePersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName)); + if (postKill !== 'missing') { + throw new Error( + `[read-isolation] refusing to start session ${cfg.sessionId}: ${why}; ` + + `post-kill probe ${postKill} (not missing) — pending proof retained`, + ); + } + } + throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${why}`); + }; + + // Condition #2: a predicted-fresh launch that backend.spawn() dynamically + // flipped into a reattach (a same-named pane arrived before the in-spawn probe) + // must NOT keep running — the live pane is a foreign/unknown generation. Tear + // it down and refuse rather than "don't commit but keep running". + if (actuallyReattachedPersistent) { + tearDownAndRefuse('predicted-fresh persistent launch dynamically reattached a late-arriving pane'); + } else if (effectiveBackendType === 'zellij') { + // Option B: zellij's session is created ASYNCHRONOUSLY inside the pty child, + // so a synchronous `!isReattach` here does NOT prove a fresh generation + // attributable to THIS launch (a late collision can still occur after the + // last in-spawn hasSession probe). We have no synchronous attributable + // signal, so we DELIBERATELY do not commit: the pending proof is left on + // disk, and this isolation-capable zellij pane will always cold-spawn on the + // next daemon restart/suspend-resume (no warm-reattach). The attributable-ack + // protocol that would restore zellij warm-reattach is a separate follow-up. + log(`[read-isolation] zellij persistent pane ${cfg.sessionId}: leaving PENDING proof (isolation-capable zellij does not warm-reattach; cold-spawn on next launch)`); + } else { + // tmux(-pipe) / herdr / zmx: attributable-fresh at the synchronous spawn + // return — TmuxPipe throws on new-session collision, herdr/zmx throw on a + // name collision / verify their own bootstrap+launchPid handshake. So a + // returned spawn + !isReattach here IS a fresh generation created by THIS + // launch. COMMIT with compare-before-replace + generation fence. + try { + // F1 generation fence: a superseded spawn must not let this commit run. + if (spawnGeneration !== cliSpawnGeneration) throw new CliSpawnSupersededError(); + // F2 compare-before-replace: the pending file on disk must STILL be our + // nonce (a same-worker restart / backend replacement between the pending + // write and here could have replaced it with a newer generation's pending). + const current = provenancePendingNonce(readManagedOriginAuthorityFile(commit.path)); + if (current !== commit.nonce) { + throw new Error(`pending proof nonce mismatch (superseded generation) at ${commit.path}`); + } + // Mutual exclusivity: clear the sibling proof (verified) BEFORE committing, + // so the committed generation is never shadowed by a stale sibling. + if (commit.clearSiblingPath) removeProofOrThrow(commit.clearSiblingPath, `stale ${commit.label} sibling`); + // Atomic replace pending → committed. + replaceManagedOriginCapabilityFile(commit.path, commit.committedContent); + } catch (err) { + if (err instanceof CliSpawnSupersededError) throw err; + // Condition #3: a commit-write failure must not leave an ambiguous started + // pane running as if successful — tear down the exact target and refuse. + // (The pending proof, if it survived, keeps the next launch fail-closed.) + tearDownAndRefuse(`could not commit ${commit.label} generation proof (${(err as Error).message})`); + } + } + } try { finalizeCodexAppControlGeneration( cfg, diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index 9268caccc..3ddaf882a 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -496,12 +496,56 @@ describe('API-only bot mode — no-transport fs-policy authority provenance (wor expect(workerSource).toContain('could not verify existing ${effectiveBackendType} pane'); expect(effects).toContain('clearProvenanceVerified:'); expect(effects).toContain('reselectBackend:'); - // Policy-OFF cold-spawn of a no-transport isolation-capable pane MUST record a - // tombstone, clearing any stale isolation marker first, and FAIL CLOSED if it - // cannot (else the next restart mis-kills it). - expect(workerSource).toContain('policyOffTombstoneContent(cfg.daemonBootId ?? \'\')'); - expect(workerSource).toContain('could not record policy-off generation tombstone'); - expect(workerSource).toContain('stale isolation marker survived removal at'); + // Generational-race fix: provenance is written PENDING before spawn (a nonce + // record both validators reject) and only rewritten to committed AFTER spawn + // confirms a fresh, non-reattached generation. + expect(workerSource).toContain('provenancePendingContent(nonce)'); + expect(workerSource).toContain('let pendingProvenanceCommit: PersistentPaneCommit | null = null;'); + // The PENDING presence is fed into the state machine as a dominant input. + expect(workerSource).toContain('pendingProvenancePresent,'); + expect(workerSource).toContain('provenancePendingNonce(readManagedOriginAuthorityFile(stalePaneMarkerPath))'); + // Commit runs AFTER actuallyReattachedPersistent is known, with a generation + // fence + compare-before-replace on the pending nonce. + const commitBlock = region(workerSource, + 'if (pendingProvenanceCommit) {', 'finalizeCodexAppControlGeneration('); + // Condition #2: a predicted-fresh launch that dynamically reattached a late + // pane must tear down + refuse, not silently keep running. + expect(commitBlock).toContain('if (actuallyReattachedPersistent) {'); + expect(commitBlock).toContain('dynamically reattached a late-arriving pane'); + // Option B: isolation-capable zellij never commits (stays pending → cold-spawn). + expect(commitBlock).toContain("effectiveBackendType === 'zellij'"); + expect(commitBlock).toContain('does not warm-reattach'); + // Condition #3: fence + compare-before-replace + commit-fail teardown. + expect(commitBlock).toContain('spawnGeneration !== cliSpawnGeneration'); + expect(commitBlock).toContain('provenancePendingNonce(readManagedOriginAuthorityFile(commit.path))'); + expect(commitBlock).toContain('pending proof nonce mismatch'); + expect(commitBlock).toContain('replaceManagedOriginCapabilityFile(commit.path, commit.committedContent)'); + // Teardown = kill the EXACT backend target → confirm authoritative missing → + // else keep pending + refuse (never erase evidence of a possibly-live pane). + // CRITICAL: must NOT name-only kill — an isolated/MCP herdr agent lives on the + // SHARED host session `botmux`, so a name-only killPersistentSession('herdr', + // 'botmux') would tear down every bot's agent. Mirror the migration effects: + // target helper for herdr's agent scope, frozen-PID path for ZMX identity. + const teardown = region(commitBlock, + 'const teardownTarget = selectedBackend.persistentBackendTarget;', 'Condition #2:'); + // Dispatches on the pure, behaviorally-tested policy (read-isolation.test.ts). + expect(teardown).toContain('persistentTeardownKillKind({'); + expect(teardown).toContain('killPersistentBackendTarget(teardownTarget!, cfg.sessionId)'); + expect(teardown).toContain('probePersistentBackendTarget(teardownTarget!)'); + expect(teardown).toContain('ZmxBackend.killManagedSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid)'); + expect(teardown).toContain('probeOwnedZmxSession(persistentSessionName, cfg.sessionId).probe'); + expect(teardown).toContain("postKill !== 'missing'"); + expect(teardown).toContain('pending proof retained'); + + // Blocker #3: the policy-ON PENDING write is a spawn-time ADMISSION + // PRECONDITION, not best-effort — a write failure must THROW before spawn (else + // a late-flip reattach skips the pendingProvenanceCommit-gated teardown and + // runs unattributed). Assert the policy-ON arm fails closed, same as policy-off. + const pendingWrite = region(workerSource, + "if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {", + "} else if (appliedIsolationCapabilities.length === 0"); + expect(pendingWrite).toContain('could not record pending isolation-marker generation proof'); + expect(pendingWrite).not.toContain('non-fatal'); }); it('daemon freezes the actual loaded bots-config path into the worker init message', () => { diff --git a/test/herdr-backend.test.ts b/test/herdr-backend.test.ts index 5db0430da..451255698 100644 --- a/test/herdr-backend.test.ts +++ b/test/herdr-backend.test.ts @@ -580,7 +580,14 @@ describe('HerdrBackend.spawn', () => { be.kill(); }); - it('reports actual fresh start when a predicted reattach has no reusable agent', () => { + it('REFUSES to silently fresh-start when a predicted reattach has no reusable agent (freeze the decision)', () => { + // Generational-race symmetric case: the worker predicted reattach and SKIPPED + // the cold-path setup (PENDING proof + credential-only wrapper, both gated on + // !willReattachPersistent). If the `botmux` agent vanished between that probe + // and spawn, silently `agent start`ing would launch an UNWRAPPED (no credential + // boundary) CLI on an enrolled host and inherit the stale committed marker. So + // the backend must FREEZE the reattach decision and throw (mirrors ZmxBackend), + // never internally turn it fresh — the worker's next launch re-plans cold. setHerdrResponses([ { match: a => a[0] === 'session' && a[1] === 'list', reply: () => EXISTING_SESSION_REPLY }, { match: a => a.includes('agent') && a.includes('get'), reply: () => JSON.stringify({ result: {} }) }, @@ -588,9 +595,13 @@ describe('HerdrBackend.spawn', () => { { match: a => a.includes('read') && (a.includes('agent') || a.includes('pane')), reply: () => PANE_READ_REPLY('') }, ]); const be = new HerdrBackend(SESSION, { isReattach: true }); - be.spawn('claude', [], { cwd: '/work', cols: 80, rows: 24, env: {} }); - expect(herdrCall('agent', 'start', 'botmux')).toBeDefined(); - expect(be.isReattach).toBe(false); + expect(() => be.spawn('claude', [], { cwd: '/work', cols: 80, rows: 24, env: {} })) + .toThrow(/disappeared before reattach|refusing to silently start/); + // Must NOT have started a fresh agent — the throw precedes any `agent start`, + // so no unwrapped generation was ever launched. (isReattach stays false because + // we never completed a reattach; the point is that we ALSO never fresh-started, + // which the missing `agent start` call proves.) + expect(herdrCall('agent', 'start', 'botmux')).toBeUndefined(); be.kill(); }); diff --git a/test/read-isolation.test.ts b/test/read-isolation.test.ts index daf046673..8b409a910 100644 --- a/test/read-isolation.test.ts +++ b/test/read-isolation.test.ts @@ -11,8 +11,11 @@ import { isolatedPaneReattachSafe, evaluatePersistentPaneMigration, executePersistentPaneMigration, + persistentTeardownKillKind, policyOffTombstoneContent, policyOffTombstoneValid, + provenancePendingContent, + provenancePendingNonce, isolationPaneMarkerContent, ISOLATION_PANE_MARKER_VERSION, isolationPanePolicyDigest, @@ -334,6 +337,7 @@ describe('evaluatePersistentPaneMigration — policy-on/off pane provenance stat policyOffTombstonePresent: false, policyOffTombstoneValid: false, paneProbe: 'exists' as const, + pendingProvenancePresent: false, isolationMarkerReattachSafe: false, }; @@ -507,6 +511,90 @@ describe('evaluatePersistentPaneMigration — policy-on/off pane provenance stat isolationCapableBackend: false, paneProbe: 'unknown', })).toEqual({ action: 'skip' }); }); + + // ── PENDING dominates everything (generational-race fix). A pending provenance + // file = the system explicitly knows a generation's fresh-attribution never + // completed. It is judged FIRST, on ALL backends and BOTH policy directions, + // independent of the tmux migration scope. This is what stops a leftover + // pending on an enrolled non-tmux (zellij) pane from warm-reattaching an + // undetermined generation once its credential policy flips OFF. ── + it('PENDING + exists → kill (dominates, even policy-OFF out of tmux migration scope)', () => { + // Enrolled zellij pane, credential policy now OFF, out of tmux scope — the old + // `!inMigrationScope → skip` path would warm-reattach. Pending overrides it. + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, noTransport: false, + isolationCapableBackend: false, paneProbe: 'exists', + isolationMarkerPresent: true, pendingProvenancePresent: true, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); + + it('PENDING + exists + policy-ON → kill (pending dominates the policy-ON reattach path too)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_ON, paneProbe: 'exists', + isolationMarkerPresent: true, pendingProvenancePresent: true, + // even if a stale committed check would have said "safe", pending wins: + isolationMarkerReattachSafe: true, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); + + it('PENDING + unknown → refuse (never erase evidence of a possibly-live pending pane)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, paneProbe: 'unknown', + policyOffTombstonePresent: true, pendingProvenancePresent: true, + })).toEqual({ action: 'refuse-inconclusive-probe' }); + }); + + it('PENDING + missing → clear-stale (verified) then cold-spawn', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, paneProbe: 'missing', + isolationMarkerPresent: true, pendingProvenancePresent: true, + })).toEqual({ action: 'clear-stale-then-cold-spawn' }); + }); + + it('regression #6: zellij policy-ON fresh left PENDING, restart still policy-ON → kill/cold (never reattach)', () => { + // Option B: isolation-capable zellij never commits, so its proof stays pending. + // On restart the still-live pane + pending → kill, regardless of policy-ON. + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_ON, isolationCapableBackend: false, + paneProbe: 'exists', isolationMarkerPresent: true, pendingProvenancePresent: true, + isolationMarkerReattachSafe: false, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); + + it('regression #7: same PENDING pane, restart now policy-OFF + OUT of tmux migration scope → still kill (never the old skip)', () => { + // This is the exact hole the pending-dominance fix closes: policy-OFF + live + + // !inMigrationScope used to `skip` (warm-reattach). Pending forces kill. + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, noTransport: false, + isolationCapableBackend: false, paneProbe: 'exists', + isolationMarkerPresent: true, pendingProvenancePresent: true, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); +}); + +describe('persistentTeardownKillKind — exact-target teardown policy (herdr shared-host safety)', () => { + // The generational-race teardown must NOT name-only kill: an isolated/MCP herdr + // agent lives on the SHARED host session `botmux`, so killing by session name + // would tear down every bot's agent. This pure policy is what the worker's inline + // teardown dispatches on. + it('herdr WITH a recorded target → target-scoped kill (never the shared host name)', () => { + expect(persistentTeardownKillKind({ backendType: 'herdr', hasBackendTarget: true })).toBe('target'); + }); + it('zmx → identity-verified frozen-PID path regardless of target', () => { + expect(persistentTeardownKillKind({ backendType: 'zmx', hasBackendTarget: true })).toBe('zmx'); + expect(persistentTeardownKillKind({ backendType: 'zmx', hasBackendTarget: false })).toBe('zmx'); + }); + it('tmux/zellij WITH a target → target-scoped; WITHOUT a target → name-only (legacy own-session)', () => { + expect(persistentTeardownKillKind({ backendType: 'tmux', hasBackendTarget: true })).toBe('target'); + expect(persistentTeardownKillKind({ backendType: 'tmux', hasBackendTarget: false })).toBe('name'); + expect(persistentTeardownKillKind({ backendType: 'zellij', hasBackendTarget: false })).toBe('name'); + }); + it('a herdr WITHOUT a recorded target falls back to name — but the worker always captures the target for a live agent', () => { + // Documents the only path to 'name' for herdr: no target recorded at all. The + // worker captures selectedBackend.persistentBackendTarget, which for a herdr + // agent is always populated, so the dangerous host-name kill is unreachable there. + expect(persistentTeardownKillKind({ backendType: 'herdr', hasBackendTarget: false })).toBe('name'); + }); }); describe('executePersistentPaneMigration — ordered, fail-closed IO seam', () => { @@ -608,6 +696,60 @@ describe('policyOffTombstoneValid — secure-read schema/version check', () => { // An isolation marker must NOT validate as a tombstone. expect(policyOffTombstoneValid(isolationPaneMarkerContent('boot', ['credential']))).toBe(false); }); + + it('requires state:committed — rejects PENDING and any no-state/other-state record (v11 strict)', () => { + // PENDING generation proof must never authorize (generational-race fix). + expect(policyOffTombstoneValid(provenancePendingContent('nonce-abc'))).toBe(false); + expect(policyOffTombstoneValid(JSON.stringify({ + version: ISOLATION_PANE_MARKER_VERSION, policyOff: true, bootId: 'x', state: 'pending', + }))).toBe(false); + // committed authorizes. + expect(policyOffTombstoneValid(policyOffTombstoneContent('boot-xyz'))).toBe(true); + // A NO-state record (the pre-v11 pre-spawn-write shape, possibly washed onto a + // late-winner pane) is now REFUSED — state:'committed' is required, forcing a + // cold-spawn once instead of trusting an unearned proof. + expect(policyOffTombstoneValid(JSON.stringify({ + version: ISOLATION_PANE_MARKER_VERSION, policyOff: true, bootId: 'legacy', + }))).toBe(false); + // Any other explicit state is refused. + expect(policyOffTombstoneValid(JSON.stringify({ + version: ISOLATION_PANE_MARKER_VERSION, policyOff: true, bootId: 'x', state: 'weird', + }))).toBe(false); + }); +}); + +describe('provenance PENDING encoding (generational-race two-phase proof)', () => { + it('both validators reject a pending body; presence-nonce round-trips', () => { + const pending = provenancePendingContent('nonce-123'); + // Neither validator authorizes a pending record. + expect(policyOffTombstoneValid(pending)).toBe(false); + expect(isolatedPaneReattachSafe(pending, { requiredCapabilities: ['credential'] })).toBe(false); + expect(isolatedPaneReattachSafe(pending)).toBe(false); + // The nonce round-trips for the commit-time compare-before-replace. + expect(provenancePendingNonce(pending)).toBe('nonce-123'); + }); + + it('provenancePendingNonce returns null for committed / garbage / absent bodies', () => { + expect(provenancePendingNonce(policyOffTombstoneContent('boot'))).toBeNull(); + expect(provenancePendingNonce(isolationPaneMarkerContent('boot', ['credential']))).toBeNull(); + expect(provenancePendingNonce(null)).toBeNull(); + expect(provenancePendingNonce('not json')).toBeNull(); + expect(provenancePendingNonce(JSON.stringify({ state: 'pending' }))).toBeNull(); // no nonce + expect(provenancePendingNonce(JSON.stringify({ state: 'pending', nonce: '' }))).toBeNull(); + }); + + it('a committed isolation marker carries state:committed and still validates', () => { + const committed = isolationPaneMarkerContent('boot-abc', ['credential', 'read', 'write']); + expect(JSON.parse(committed).state).toBe('committed'); + expect(isolatedPaneReattachSafe(committed, { + requiredCapabilities: ['credential', 'read', 'write'], exactCapabilities: true, + })).toBe(true); + // An explicit state:'pending' spliced onto an otherwise-valid marker is refused. + const tampered = JSON.stringify({ ...JSON.parse(committed), state: 'pending' }); + expect(isolatedPaneReattachSafe(tampered, { + requiredCapabilities: ['credential', 'read', 'write'], exactCapabilities: true, + })).toBe(false); + }); }); /** @@ -645,6 +787,29 @@ describe('isolatedPaneReattachSafe — start-time contract bump forces cold resp // silently warm-reattach those broken panes. expect(ISOLATION_PANE_MARKER_VERSION).toBeGreaterThan(7); }); + + it('rejects a pre-v11 NO-state marker (the pre-spawn-write shape) → forces cold-spawn once', () => { + // The generational-race fix (pending→commit) added state:'committed'. A v10 + // marker was written UNCONDITIONALLY before spawn (the vulnerable path) with NO + // state field, so a late-winner pane may wear a "full-capability" v10 marker it + // never earned. Both the version bump AND the strict state check must reject it + // so it cold-spawns once under the new contract — closing the INSTALLED-BASE + // risk, not just new spawns. + const legacyV10NoState = JSON.stringify({ + version: 10, + bootId: 'washed-late-winner', + capabilities: ['credential', 'read', 'write'], + }); + expect(isolatedPaneReattachSafe(legacyV10NoState, ['credential', 'read', 'write'])).toBe(false); + // Even a hypothetical CURRENT-version marker with no state is refused (strict). + const currentVersionNoState = JSON.stringify({ + version: ISOLATION_PANE_MARKER_VERSION, + bootId: 'no-state', + capabilities: ['credential', 'read', 'write'], + }); + expect(isolatedPaneReattachSafe(currentVersionNoState, ['credential', 'read', 'write'])).toBe(false); + expect(ISOLATION_PANE_MARKER_VERSION).toBeGreaterThanOrEqual(11); + }); }); // ─── #714: new spawn-time sandbox mount (traex/coco migration markers) ──────── diff --git a/test/tmux-reattach-backend.test.ts b/test/tmux-reattach-backend.test.ts index 183c45af4..8015d96dc 100644 --- a/test/tmux-reattach-backend.test.ts +++ b/test/tmux-reattach-backend.test.ts @@ -22,6 +22,7 @@ vi.mock('../src/adapters/backend/herdr-backend.js', () => ({ HerdrBackend: class MockHerdrBackend { static sessionName = vi.fn((id: string) => `bmx-${id.slice(0, 8)}`); static managedSessionName = vi.fn(() => 'botmux'); + static defaultAgentName = vi.fn(() => 'botmux'); static hasSession = vi.fn(() => false); static probeSession = vi.fn(() => 'missing'); static hasAgent = vi.fn(() => false); @@ -344,6 +345,64 @@ describe('selectSessionBackend', () => { sessionId: '9cfa0024-197d-4781-845b-c541dceb8980', }); }); + + // ── Owned isolation/MCP Herdr host: agent-precise reattach-vs-fresh (generational + // race symmetric case). host = bmx-. Must use TRI-STATE probes; NEVER + // predict reattach from the session alone (a live host whose botmux agent + // vanished would loop the backend's frozen reattach guard, and the worker + // would have skipped the PENDING + credential wrapper cold-path). ── + const OWNED_SID = 'aabbccdd-197d-4781-845b-c541dceb8980'; + const ownedHost = `bmx-${OWNED_SID.slice(0, 8)}`; + + it('owned host + agent BOTH exist → warm reattach the same owned host', () => { + vi.mocked(HerdrBackend.hasSession).mockImplementation(name => name === ownedHost); + vi.mocked(HerdrBackend.probeSession).mockImplementation(name => name === ownedHost ? 'exists' : 'missing'); + vi.mocked(HerdrBackend.probeAgent).mockReturnValue('exists'); + const selected = selectSessionBackend({ sessionId: OWNED_SID, backendType: 'herdr' }); + expect((selected.backend as any).sessionName).toBe(ownedHost); + expect(selected.isReattach).toBe(true); + expect((selected.backend as any).opts.isReattach).toBe(true); + }); + + it('owned host EXISTS but agent MISSING → cold start IN the same host (isReattach:false), no teardown, no migrate to shared', () => { + vi.mocked(HerdrBackend.hasSession).mockImplementation(name => name === ownedHost); + vi.mocked(HerdrBackend.probeSession).mockImplementation(name => name === ownedHost ? 'exists' : 'missing'); + vi.mocked(HerdrBackend.probeAgent).mockReturnValue('missing'); + const selected = selectSessionBackend({ sessionId: OWNED_SID, backendType: 'herdr' }); + // SAME owned host retained (not migrated to the shared 'botmux' host)… + expect((selected.backend as any).sessionName).toBe(ownedHost); + // …but cold: worker will write PENDING + assemble the credential wrapper first. + expect(selected.isReattach).toBe(false); + expect((selected.backend as any).opts.isReattach).toBe(false); + // Never tore down the still-live host. + expect(vi.mocked(HerdrBackend.killAgent)).not.toHaveBeenCalled(); + }); + + it('owned host exists but agent probe UNKNOWN → refuse (no kill, no spawn)', () => { + vi.mocked(HerdrBackend.hasSession).mockImplementation(name => name === ownedHost); + vi.mocked(HerdrBackend.probeSession).mockImplementation(name => name === ownedHost ? 'exists' : 'missing'); + vi.mocked(HerdrBackend.probeAgent).mockReturnValue('unknown'); + expect(() => selectSessionBackend({ sessionId: OWNED_SID, backendType: 'herdr' })) + .toThrow(/agent .* probe inconclusive|reattach-vs-fresh/); + expect(vi.mocked(HerdrBackend.killAgent)).not.toHaveBeenCalled(); + }); + + it('owned host probe UNKNOWN → refuse (never fail-open by collapsing unknown to a fresh migrate)', () => { + vi.mocked(HerdrBackend.hasSession).mockReturnValue(false); + vi.mocked(HerdrBackend.probeSession).mockImplementation(name => name === ownedHost ? 'unknown' : 'missing'); + expect(() => selectSessionBackend({ sessionId: OWNED_SID, backendType: 'herdr' })) + .toThrow(/owned herdr session .* probe inconclusive|reattach-vs-fresh/); + }); + + it('owned host MISSING → migrate to the shared machine-wide botmux host (fresh)', () => { + vi.mocked(HerdrBackend.hasSession).mockImplementation(name => name === 'botmux'); // shared exists, owned missing + vi.mocked(HerdrBackend.probeSession).mockImplementation(name => name === ownedHost ? 'missing' : 'missing'); + vi.mocked(HerdrBackend.probeAgent).mockReturnValue('missing'); + const selected = selectSessionBackend({ sessionId: OWNED_SID, backendType: 'herdr' }); + // Falls through to the shared 'botmux' host cold path. + expect((selected.backend as any).sessionName).toBe('botmux'); + expect(selected.isReattach).toBe(false); + }); }); describe('superseded Herdr target retirement', () => { diff --git a/test/worker-pipe-initial-screen-order.test.ts b/test/worker-pipe-initial-screen-order.test.ts index b85423ac3..f34db59c2 100644 --- a/test/worker-pipe-initial-screen-order.test.ts +++ b/test/worker-pipe-initial-screen-order.test.ts @@ -448,8 +448,10 @@ describe('worker pipe initial screen ordering', () => { const killCliBody = source.slice(source.indexOf('} = {}): void {', killCliIdx)); expect(killCliBody.slice(0, 300)).toContain('cliSpawnGeneration++;'); // Two additional checks normalize nested spawn failures before the three - // restart/init/message handlers consume them. - expect(source.match(/err instanceof CliSpawnSupersededError/g)).toHaveLength(5); + // restart/init/message handlers consume them; plus the generational-race + // provenance commit re-throws a superseded spawn instead of tearing down + // (the commit-fail path must not swallow CliSpawnSupersededError). + expect(source.match(/err instanceof CliSpawnSupersededError/g)).toHaveLength(6); const restartHandler = source.slice( source.indexOf('async function restartCliProcess('), source.indexOf('// ─── HTTP + WebSocket Server'),