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..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,11 +134,25 @@ 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,安全复审收敛)** + +> ⚠️ **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 遮蔽的文件,还能据此直接调 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`)——这只关闭 **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 方向。 +> +> **升级迁移(持久后端)**: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**: - **权威根 = 目录,不是 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/docs/file-sandbox.md b/docs/file-sandbox.md index 048c1af59..7abb44a86 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` 配置决定:没配 → 不隔离(以同一 OS 用户身份对宿主文件有完整**读写**权,能读宿主 `bots.json`、也能改写宿主配置);配了 → 照常隔离。 + +> 早先版本曾把「会话没有飞书 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 能力)——这只关闭 **Botmux 内建的 transport 调用链**(本 bot 的 send 路径),**不构成恶意代码下的凭证隔离**:不开沙盒时 agent 仍能从磁盘 `bots.json` 读出 secret 自行调 Lark;② enrolled 设备上的 **device-credential 强制隔离**独立生效,与本开关无关。 + ## 落盘(改动去向) fs-policy 模型下 agent 在 **readWrite 白名单区(含 workingDir)直接写宿主真实文件**——改动即时落盘,不再是「副本 + 补丁交回」。沙盒的作用是把可写面收敛到白名单:项目目录可写、认证目录可写,白名单之外(别的项目、别的会话、`~/.ssh`/`~/.aws`、`bots.json`、各类密钥)一律读不到写不了。 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 b18f5ce26..a41b55f3a 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, @@ -324,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'; @@ -419,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 ?? {}), }); } @@ -437,6 +451,104 @@ 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-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. + * + * `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, 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; + } +} + +/** + * 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. + * + * 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; state?: unknown; + }; + return parsed.version === ISOLATION_PANE_MARKER_VERSION + && parsed.policyOff === true + && typeof parsed.bootId === 'string' + && parsed.bootId.trim().length > 0 + && parsed.state === 'committed'; + } 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 @@ -470,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' @@ -512,6 +632,315 @@ export function isolatedPaneReattachSafe( } } +/** + * 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". + * + * 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, 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. + * + * 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). `policyOffTombstoneValid` is the secure-read result. + * + * 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 + * 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 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 existence — planted/garbage counts). */ + isolationMarkerPresent: boolean; + /** `.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'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; + /** 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 + * marker + policy digest); passed in to keep this function pure. + */ + isolationMarkerReattachSafe: boolean; +}; + +export type PersistentPaneMigrationDecision = + /** 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. 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. */ + | { 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, +): PersistentPaneMigrationDecision { + const { + appliedIsolationCapabilities, isolationCapableBackend, noTransport, + isolationMarkerPresent, policyOffTombstonePresent, policyOffTombstoneValid: tombstoneValid, + 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 + // 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 + // 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 }; + } + // 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' }; + } + + // 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. + if (paneLive) { + 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 }; + } + + // 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' }; +} + +/** + * 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; + /** 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; +}; + +/** + * 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). + * 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). + */ +export function executePersistentPaneMigration( + decision: PersistentPaneMigrationDecision, + effects: PersistentPaneMigrationEffects, +): PersistentPaneMigrationDecision['action'] { + switch (decision.action) { + 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; + 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; + } +} + +/** + * 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/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/src/worker.ts b/src/worker.ts index 0b4b3a89a..201f324f3 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -28,6 +28,16 @@ import { buildSeatbeltProfile, isolatedPaneOriginChannel, isolatedPaneReattachSafe, + evaluatePersistentPaneMigration, + executePersistentPaneMigration, + type PersistentPaneMigrationEffects, + persistentTeardownKillKind, + isolationPaneMarkerPath, + policyOffTombstonePath, + policyOffTombstoneContent, + policyOffTombstoneValid, + provenancePendingContent, + provenancePendingNonce, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, @@ -11872,17 +11882,65 @@ 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 = 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 (→ triggers cleanup / conservative kill) and can never be used to + // force a silent reattach. + 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 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. + // Enter for: + // · any policy-ON spawn (capability check runs on every persistent backend, + // 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. + const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0 + || (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 // same-named session may belong to the user or to a newer generation. 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 ( @@ -11895,91 +11953,119 @@ 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'; - if (paneLive) { - const markerPath = join( - isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`, - ); - 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`); - // 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; + 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, + } : {}), + }); + // 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)); + // 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, + noTransport: noTransportSession, + isolationMarkerPresent: stalePaneMarkerPresent, + policyOffTombstonePresent, + policyOffTombstoneValid: policyOffTombstoneIsValid, + paneProbe, + pendingProvenancePresent, + 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}`, + ); + } + }; + // 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( - persistentSessionName, - cfg.sessionId, - resolvedZmxSessionPid, - ); + ZmxBackend.killManagedSession(staleSessionName, cfg.sessionId, resolvedZmxSessionPid); + } else if (stalePersistentTarget) { + killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId); } else { - if (stalePersistentTarget) killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId); - else killPersistentSession(effectiveBackendType as PersistentBackendType, persistentSessionName, cfg.sessionId); + 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, - )) { + : probePersistentSession(effectiveBackendType as PersistentBackendType, staleSessionName)); + // 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`, + `[read-isolation] refusing to start session ${cfg.sessionId}: ` + + `could not confirm stale ${effectiveBackendType} pane termination ` + + `(post-kill probe: ${postKillProbe})`, ); } 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. @@ -11990,8 +12076,39 @@ async function spawnCli( backend = selectedBackend.backend; 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) { + persistentPaneOriginChannelId = isolatedPaneOriginChannel(marker); } - } + // Pane matches the current policy (isolated pane stamped under it, or a + // 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') { + 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 + // guarantees are unit-testable with injected mocks. + executePersistentPaneMigration(migration, migrationEffects); } readIsolationOriginChannelId = managedOriginChannelRequired ? (persistentPaneOriginChannelId ?? randomBytes(32).toString('hex')) @@ -13168,16 +13285,53 @@ 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: 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 { - const markerDir = join(isolationRuntimeDataDir, 'read-isolation'); - mkdirSync(markerDir, { recursive: true }); - replaceManagedOriginCapabilityFile( - join(markerDir, `${cfg.sessionId}.boot`), - isolationPaneMarkerContent( + mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true }); + 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 @@ -13189,8 +13343,43 @@ 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 (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 }); + 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 pending policy-off generation proof (${(e as Error).message})`, + ); + } } // 通用启动前缀(wrapperCli):把启动命令重写成 ` `(首 token 当 @@ -13456,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 8eb4a0e37..3ddaf882a 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_')"); @@ -441,6 +447,107 @@ describe('API-only bot mode — no-transport fs-policy authority provenance (wor expect(workerSource).toContain('no-transport suppressed'); }); + 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({'); + expect(workerSource).toContain('executePersistentPaneMigration(migration, migrationEffects)'); + expect(workerSource).not.toContain('persistentPaneReattachGuardEngaged'); + // 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). + expect(workerSource).toContain( + 'const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0\n' + + ' || (noTransportSession && isolationCapableBackend)\n' + + ' || stalePaneMarkerPresent || policyOffTombstonePresent;', + ); + // 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 =', 'const staleSessionName = persistentSessionName;'); + expect(remover).toContain('hostEntryExistsNoFollow(path)'); + expect(remover).toContain('could not remove stale'); + // 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:'); + // 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:'); + // 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', () => { // 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..f93af2099 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] legacy/unmarked persistent pane'), + workerSource.indexOf('const migrationEffects: PersistentPaneMigrationEffects = {'), workerSource.indexOf('if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length'), ]; for (const gate of gates) { @@ -189,36 +191,43 @@ 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 (appliedIsolationCapabilities.length > 0 && persistentSessionName', + "if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneGuardApplies) {", ); const readIsolationEnd = workerSource.indexOf('let willReattachPersistent', readIsolationStart); const mcpStart = workerSource.indexOf( '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', () => { - const start = workerSource.indexOf('[read-isolation] legacy/unmarked persistent pane'); + 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 +251,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('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/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 b7d0540bd..8b409a910 100644 --- a/test/read-isolation.test.ts +++ b/test/read-isolation.test.ts @@ -9,6 +9,13 @@ import { buildCredentialIsolationRules, isolatedPaneOriginChannel, isolatedPaneReattachSafe, + evaluatePersistentPaneMigration, + executePersistentPaneMigration, + persistentTeardownKillKind, + policyOffTombstoneContent, + policyOffTombstoneValid, + provenancePendingContent, + provenancePendingNonce, isolationPaneMarkerContent, ISOLATION_PANE_MARKER_VERSION, isolationPanePolicyDigest, @@ -314,6 +321,437 @@ describe('isolatedPaneReattachSafe', () => { // ─── cold-start migration: START-TIME env contract (bots.json EPERM fix) ────── +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. + // `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; + const CRED_ONLY = ['credential'] as const; + const CAPS_OFF = [] as const; + const base = { + isolationCapableBackend: true, + noTransport: true, + isolationMarkerPresent: false, + policyOffTombstonePresent: false, + policyOffTombstoneValid: false, + paneProbe: 'exists' as const, + pendingProvenancePresent: false, + isolationMarkerReattachSafe: false, + }; + + // ── 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, + 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 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, 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, paneProbe: 'missing', + })).toEqual({ action: 'skip' }); + }); + + // ── 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, 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)', () => { + 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)', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, + isolationMarkerPresent: false, policyOffTombstonePresent: false, + })).toEqual({ action: 'kill-then-cold-spawn', clearAfterKill: true }); + }); + + it('policy OFF + live pane with valid tombstone AND isolation marker (marker dominates) → kill', () => { + expect(evaluatePersistentPaneMigration({ + ...base, appliedIsolationCapabilities: CAPS_OFF, + isolationMarkerPresent: true, policyOffTombstonePresent: true, policyOffTombstoneValid: 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, + 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, + 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, paneProbe: 'missing', + })).toEqual({ action: 'skip' }); + }); + + 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, + 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' }); + }); + + // ── 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', () => { + // Behavioral (not source-lock): inject mock effects, observe call ORDER and the + // "not called" guarantees on each failure path. + const makeEffects = () => { + const calls: string[] = []; + const eff = { + killStalePane: () => { calls.push('kill'); }, + 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 }; + }; + + 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']); + }); + + 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', () => { + 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); + }); + + 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); + }); +}); + /** * 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 @@ -349,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/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({ 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'),