@sema-agent/server 7.85.0 → 7.86.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/USAGE.md +18 -0
  2. package/dist/approval-hmac.d.ts +28 -1
  3. package/dist/approval-hmac.js +2 -1
  4. package/dist/capabilities/execution-lane.d.ts +22 -0
  5. package/dist/capabilities/execution-lane.js +8 -0
  6. package/dist/capabilities/memory-notice.d.ts +4 -1
  7. package/dist/config-catalog.js +2 -1
  8. package/dist/execution-lane-caps.d.ts +13 -0
  9. package/dist/execution-lane-caps.js +1 -0
  10. package/dist/fleet/fleet-reconciler.js +2 -2
  11. package/dist/http/resume-legs.d.ts +2 -1
  12. package/dist/http/resume-legs.js +21 -6
  13. package/dist/http/route-ctx.d.ts +51 -17
  14. package/dist/http/route-ctx.js +0 -3
  15. package/dist/http/routes/approvals-assistant.d.ts +11 -0
  16. package/dist/http/routes/approvals-assistant.js +61 -16
  17. package/dist/http/routes/capabilities.js +2 -0
  18. package/dist/http/routes/images.js +38 -8
  19. package/dist/http/routes/mcp-reconnect.d.ts +1 -1
  20. package/dist/http/routes/mcp-reconnect.js +2 -2
  21. package/dist/http/routes/memory-policy.js +3 -6
  22. package/dist/http/routes/notify-wake.js +11 -3
  23. package/dist/http/routes/run-memory-capture.js +3 -5
  24. package/dist/http/routes/runs.js +19 -21
  25. package/dist/http/routes/sessions.js +10 -2
  26. package/dist/http/routes/shared-memory.js +5 -1
  27. package/dist/memory-scope.d.ts +16 -0
  28. package/dist/plugins/checkpoint-store-sql.d.ts +8 -2
  29. package/dist/plugins/store-contracts.d.ts +23 -0
  30. package/dist/plugins/store-contracts.js +3 -0
  31. package/dist/principal-jwt.d.ts +15 -7
  32. package/dist/principal-jwt.js +1 -1
  33. package/dist/task-settings.d.ts +35 -0
  34. package/dist/task-settings.js +7 -0
  35. package/package.json +1 -1
package/USAGE.md CHANGED
@@ -408,6 +408,12 @@ MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro # 目录里的模型名,cheap
408
408
  车道会尊重它(那条车道的定义就是"运维自己这台机、无容器边界")。所以「服务跑在 A 机、代码在 B 机」不成立
409
409
  —— 没有任何东西把 B 的路径映到 A。要么把服务(或 `run-local`)跑在放着文件的那台机器上,要么走容器车道
410
410
  (`e2b`/`k8s`/`local-docker`),那里的工作区是沙箱自己的,`cwd` 被忽略。
411
+ - **读面(S-426 起)**:`GET /v1/capabilities` 的 `executionLane` 把本部署的车道广告成
412
+ `{provider:<REMOTE_EXEC 闭集词>, toolsOnThisHost:boolean}`(`REMOTE_EXEC` 未设 ⇒ `provider:"host"`)。
413
+ 壳/客户端判「工具跑不跑在本机」**读这一位**,不要再从自己这一侧的 `REMOTE_EXEC` 猜 —— 连远端引擎时
414
+ 那个 env 说的是**壳自己这台**的事。⚠️ 它是**文件系统同一性**位,**不是**权限位:`cwd` 兑不兑现仍由
415
+ `projectContext` / `callerCwd` 回答(那两位的判据多一条单用户约束,多租户 host 部署上两者会分叉)。
416
+ 端点/凭据/镜像/`serial` 一个字都不上这条 wire,那些仍只在 operator 面 `GET /v1/config/catalog`。
411
417
 
412
418
  **workspace 浏览面 —— 已随整树快照纪元退役**
413
419
  - 旧四端点(list/tree/file/archive)恒 `501 capability.workspace_retired`,`capabilities.workspace`
@@ -1524,6 +1530,18 @@ GET /v1/capabilities?permissionMode=default
1524
1530
  **per-run 面**:本批不在 run 记录上加 per-run `autoMode` 座——引擎侧的武装结果 / 未武装原因(含只有引擎知道的那些;core 7.12.0 起熔断族已退役)
1525
1531
  将随 core 的状态座到货,届时 server 只投影不自算;此前 `armed` 是 server 侧三项可知判据的裁决。
1526
1532
 
1533
+ ### 9.4b plan 模式(`permissionMode:"plan"`):批准一份计划时,顺带说「解到哪一档」(7.86.0 起)
1534
+
1535
+ 只读起步的任务把计划交给人之后停在 `plan_review` 门上。`POST /v1/assistant/tasks/:id/plan_review` 的
1536
+ `decision:"approve"` 可携 **`permissionModeAfter`**(闭集 `"default" | "acceptEdits"`,**缺席 ⇒ `"default"`**):
1537
+ `acceptEdits` = 继续跑时对自己工作目录内的写不再逐次征询,`default` = 有手但每次写照常征询;`edit` / `reject`
1538
+ 的语义是重做计划,只读**原样保留**,在它们上送这一位是 `400`(词表外的值、以及本来就不是只读起步的任务送它,
1539
+ 同样是 `400`)。解除会写回这条会话的续跑输入 —— 此后每一次挂起→续跑都沿用解除后的档,不回落只读。
1540
+ 🔴 **这个体是闭集(7.86.0,S-438)**:受理集 = `decision` / `editedPlan` / `reason` / `permissionModeAfter` /
1541
+ `boundCallId` 五键,集外键(含定谳前的旧拼法 `nextPermissionMode`)**响亮拒** `400 request.body_shape` +
1542
+ 机读两桶逐字点名,**不再静默丢** —— 与 `/v1/approvals/:id/decide` 同一只机器、同码同串。
1543
+ 逐字契约见 `docs/ASSISTANT-WIRE-CONTRACT.md` §4c。
1544
+
1527
1545
  ### 9.5 Web search:两车道优先级、per-request `settings.webSearch`、错误形
1528
1546
 
1529
1547
  **两车道,整段整取(不是逐键 merge)**:部署 env(`WEB_SEARCH_*`,§0)与 per-request `body.settings.webSearch`
@@ -3,7 +3,7 @@ import type { ApprovalHmacKey } from "./auth-keys.js";
3
3
  * The canonical message an approval MAC signs — a fixed-order, JSON-escaped tuple so no field value (an id, a
4
4
  * hash, a free-text note) can ambiguate the boundary with the next (same discipline as the approvals-stream diff
5
5
  * key). 🔴 Bound to CLIENT-VISIBLE values only: `sessionId` (in the /decide URL) + `boundCallId`/`boundInputHash`
6
- * (surfaced by listPending) + `reason` (the caller's own body). It must NOT include the checkpointToken — that is
6
+ * (surfaced by listPending) + `reason` and `permissionModeAfter` (the caller's own body). It must NOT include the checkpointToken — that is
7
7
  * the unexposed resume credential the client can never see (it resumes by sessionId), so a checkpointToken-bound
8
8
  * message would be uncomputable by the signer.
9
9
  *
@@ -12,11 +12,24 @@ import type { ApprovalHmacKey } from "./auth-keys.js";
12
12
  * 被接管的客户端 —— 正是 HMAC 要防的风险模型)可以在操作员批准同一个动作的那次决定里,把落账理由换成
13
13
  * 别的:动作没变,**审计记录变了**。三方裁定纳入签名载荷,不留 `reasonUnsigned` 宽限代际(未上生产)。
14
14
  *
15
+ * ── 🔴 **两把绑定的分工**(S-433 F1 立此,别再把它们当成一回事)─────────────────────────────────
16
+ * `principal-jwt.ts` 的 `approvalBnd`(JWT `cnf.bnd`)只哈希**前三位** `[sessionId, boundCallId, boundInputHash]`
17
+ * —— 它绑的是**「哪一张卡」**(这份身份凭据被签发去决哪一次待决动作),所以决定内容改了它**不该**变。
18
+ * 本文件的 HMAC 信封绑的是**「决了什么」**—— 决定本身的每一个会改变后果的字段都在里面(`decision`、
19
+ * `reason`,以及 S-433 起的 `permissionModeAfter`)。⇒ 「同一张卡上把 approve 的档偷偷调宽」这件事,
20
+ * 结构上只有 HMAC 这一把拦得住;往 `cnf.bnd` 里加位既不解决它,还会把一份合法 JWT 绑死在一个它签发时
21
+ * 根本不知道的值上。**本次(S-433 F1)只动 HMAC 这一把,`approvalBnd` 一个字节不动。**
22
+ *
15
23
  * ── 🔴 字节构造规则(权威定义;跨仓签名器照此实现,docs/ASSISTANT-WIRE-CONTRACT.md §6.2 同源)──────────
16
24
  * 被 HMAC 的是下面这个字符串的 **UTF-8 字节**:
17
25
  * 1. **恒 5 元素、定序**的紧凑 JSON 数组(元素间**无空格**):
18
26
  * `[sessionId, boundCallId|null, boundInputHash|null, decision, reason|null]`
19
27
  * —— 恒定长度是刻意的:签名器里少一个"有 reason 才追加"的条件分支,就少一类跨实现漂移。
28
+ * 🔴 **唯一的例外是尾位**(S-433,server 7.86.0):`permissionModeAfter` **在场时**追加为第 6 位
29
+ * `[…, permissionModeAfter]`,**缺席时一个字节都不追加**。之所以在这一位上接受那个条件分支:
30
+ * 它换来的是「不签这一位的既有签名器对不带本键的体照常验过」—— `reason` 入签那次是**全体签名器
31
+ * 同拍 BREAKING**(1.284.0),本次刻意不再来一次。带了本键而签名方没签 ⇒ MAC 不匹配 ⇒ 既有
32
+ * `401 approval_mac_invalid`,不铸第二种拒绝形。
20
33
  * 2. 缺席 ⇒ `null`;**`""`(空串)是与 `null` 不同的被签值**(否则"把理由抹成空"不改变 MAC)。
21
34
  * 3. 字符串转义 = RFC 8259 最小集:`"`→`\"`、`\`→`\\`、U+0000–U+001F → `\n`/`\t`/`\uXXXX`。
22
35
  * 4. **非 ASCII 原样输出 UTF-8,不转 `\uXXXX`**。⚠️ Python 的 `json.dumps` 缺省 `ensure_ascii=True` 会踩
@@ -41,6 +54,20 @@ export interface ApprovalEnvelope {
41
54
  boundInputHash?: string | null;
42
55
  decision: string;
43
56
  reason?: string | null;
57
+ /**
58
+ * 🔴 **S-433 合并复审 F1 [high] —— 「凡改变续跑能做什么的决裁字段都在签名信封里」**(7.86.0,验真后修)。
59
+ *
60
+ * `plan_review` 的 `permissionModeAfter` 选的是**批准之后这条 run 有没有手**(`acceptEdits` = 工作目录内
61
+ * 的写不再逐次征询)。它铸下来时**没有**进签名载荷 ⇒ 一个能改写线上字节的中间人(反代 / 被接管的
62
+ * 客户端 —— 正是 HMAC 的风险模型)可以把操作员签的那次 `default` 批准原地改成 `acceptEdits`:动作没变、
63
+ * MAC 照过,**权限变了**。这与 `reason` 入签是同一条判据的更贵一格(那条改的是审计记录,这条改的是手)。
64
+ *
65
+ * 🔴 **尾位、按在场序列化**(编码规则见 {@link approvalHmacMessage}):键缺席 ⇒ 消息字节与 7.85.0 **逐字
66
+ * 节相同**,所以不签这一位的既有签名器(cli / BFF / deploy 夹具)对**不带本键的体**照常验过 —— 这一位
67
+ * 不是第二次 `reason` 式的 BREAKING。带了键而签名方没签 ⇒ MAC 不匹配 ⇒ 落既有的
68
+ * `401 approval_mac_invalid`,**不铸第二种拒绝形**(不加 400/401 串,不加「直连门直接拒收本键」的特判)。
69
+ */
70
+ permissionModeAfter?: string | null;
44
71
  }
45
72
  export declare function approvalHmacMessage(env: ApprovalEnvelope): string;
46
73
  /** 签名载荷里 `reason` 的字符上限。**拒**而不是截 —— 截断在这条路径上**结构上不可行**:服务端截过的字节
@@ -1,6 +1,7 @@
1
1
  import { createHmac, timingSafeEqual } from "node:crypto";
2
2
  export function approvalHmacMessage(env) {
3
- return JSON.stringify([env.sessionId, env.boundCallId ?? null, env.boundInputHash ?? null, env.decision, env.reason ?? null]);
3
+ const head = [env.sessionId, env.boundCallId ?? null, env.boundInputHash ?? null, env.decision, env.reason ?? null];
4
+ return JSON.stringify(env.permissionModeAfter === undefined ? head : [...head, env.permissionModeAfter]);
4
5
  }
5
6
  export const MAX_APPROVAL_REASON_CHARS = 4_096;
6
7
  export function verifyApprovalHmac(env, mac, kid, keys) {
@@ -0,0 +1,22 @@
1
+ import type { RemoteExecProvider } from "../execution-lane-caps.js";
2
+ export interface ExecutionLaneCapability {
3
+ /** 本部署的执行车道词(闭集 = {@link RemoteExecProvider};`REMOTE_EXEC` 未设 ⇒ `"host"`,见头注)。 */
4
+ readonly provider: RemoteExecProvider;
5
+ /** 工具跑的那台机器就是本进程这台吗(= server 盘上的绝对路径对模型的工具有没有意义)。
6
+ * 属主 = `task-cwd.ts` 的 {@link toolsRunOnThisHost},与 `skills[].baseDir` 的铸点同一只函数。 */
7
+ readonly toolsOnThisHost: boolean;
8
+ }
9
+ /**
10
+ * 部署席 → 消费端窄投影。形照 `projectWebSearchCapability` / `projectSqlEngineCapability`:
11
+ * **只 import 型**,不碰 config.ts,于是本模块是叶。
12
+ *
13
+ * 🔴 **唯一**写缺席折词的地方。`"host"` 这个字面量不入 `api-notice-text-freeze`:它是
14
+ * {@link RemoteExecProvider} 的成员,配置联合里那条臂改名 ⇒ 本行当场 tsc 红(S-382 对三个 provider 词
15
+ * 免入表的同一条理由 —— 会静默漂的只有**本仓自铸**的词,这里一个都没有)。
16
+ */
17
+ export declare function projectExecutionLaneCapability(config: {
18
+ readonly remoteExec?: {
19
+ readonly provider: RemoteExecProvider;
20
+ } | undefined;
21
+ }): ExecutionLaneCapability;
22
+ //# sourceMappingURL=execution-lane.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { toolsRunOnThisHost } from "../task-cwd.js";
2
+ export function projectExecutionLaneCapability(config) {
3
+ return {
4
+ provider: config.remoteExec?.provider ?? "host",
5
+ toolsOnThisHost: toolsRunOnThisHost(config),
6
+ };
7
+ }
8
+ //# sourceMappingURL=execution-lane.js.map
@@ -38,7 +38,10 @@ export interface MemoryFaceFacts {
38
38
  /** 这一腿挂不挂记忆面 —— 由唯一属主 `memory-scope.ts` `memoryFaceMounted(spec.memory)` 算出(与 core `prepareMemory`
39
39
  * 同判:`normalizeMemorySpec(...)?.enabled === true`)。`false` = 引擎未接 / 多租户 dark / remote lane fail-closed /
40
40
  * S-405 `memory:"off"`(键在场但 `enabled:false`)—— 殊途同归:core 不会 prepare 记忆,也就不会挂任何披露。
41
- * 🔴 不再收 `TaskSpec.memory` 本体:按「键在不在场」判是 7.84.0 前的病(合并复审 车HF F1)。 */
41
+ * 🔴 不再收 `TaskSpec.memory` 本体:按「键在不在场」判是 7.84.0 前的病(合并复审 车HF F1)。
42
+ * 🔴 属主会**抛**(值闭包门没过的手搓 spec —— 单数 `scope` / 非 `"off"` 的 `capture`),理由与两个属主
43
+ * 的坐标逐字写在 `memory-scope.ts` 的 `memoryFaceMounted` 头注里;本判别式不接这条臂(接了就是在
44
+ * 披露轴上静默 fail-open)。 */
42
45
  memoryFaceMounted: boolean;
43
46
  /** 部署声明 `MEMORY_PERSISTENCE_CAPABLE`(三态)。`true` = operator 自证有推断看不见的持久通道
44
47
  * (自定义 writer / MCP 记忆服务)⇒ 披露会变成假话,撤回;`false` = 强制披露;缺席 ⇒ 走推断。 */
@@ -2,6 +2,7 @@ import { CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES } fr
2
2
  import { THINKING_FORMATS } from "./model-compat.js";
3
3
  import { RESILIENCE_DEFAULTS } from "./config-types.js";
4
4
  import { webSearchConfigFromEnv, WEB_SEARCH_PROVIDERS } from "./plugins/web-search.js";
5
+ import { REMOTE_EXEC_PROVIDER_WORDS } from "./execution-lane-caps.js";
5
6
  import { CENTER_READ_FACE_LIVE_KEYS } from "./config-center/read-face.js";
6
7
  export const CATALOG_VALUE_TYPES = ["boolean", "number", "string", "enum", "csv", "json", "path", "url", "secret", "opaque"];
7
8
  export const CATALOG_TIMINGS = ["hot", "restart", "deploy", "inert"];
@@ -347,7 +348,7 @@ export const CONFIG_CATALOG = [
347
348
  r("READ_FACE", "orchestration", "enum", "READ 容纳面档位(env 逐键恒赢 center —— 部署主权表达)", { enumValues: ["open", "roots"], danger: SEC, derivedDefaultNote: "缺席 ⇒ 按 posture 派生(S-167):单用户 turnkey(REQUIRE_PRINCIPAL 未设)铸 open;多租户不铸键、core 默认 roots。生效来源见 GET /v1/diagnostics/wiring 的 readFace.source 四词", configKey: "readFace", center: cRead("face", "readFace") }),
348
349
  r("REAP_INTERVAL_SEC", "limitsHttp", "number", "reaper 周期(秒)", { staticDefault: "60", configKey: "reapIntervalSec" }),
349
350
  r("REAP_RUN_STALE_SEC", "limitsHttp", "number", "run 活性判陈旧阈(秒)", { staticDefault: "120", configKey: "runStaleSec", center: cLimits("runStaleSec") }),
350
- r("REMOTE_EXEC", "orchestration", "enum", "执行车道选择(点名而配不齐/认不出 ⇒ 拒启,绝不静默降级到 host/stub)", { enumValues: ["e2b", "k8s", "ssh", "adb", "host", "local-docker", "device"], danger: SEC, derivedDefaultNote: "缺席:CONFIG_PROVIDER=local ⇒ host;其余 ⇒ 进程内 stub", configKey: "remoteExec", resolve: (c) => c.remoteExec?.provider ?? null }),
351
+ r("REMOTE_EXEC", "orchestration", "enum", "执行车道选择(点名而配不齐/认不出 ⇒ 拒启,绝不静默降级到 host/stub)", { enumValues: REMOTE_EXEC_PROVIDER_WORDS, danger: SEC, derivedDefaultNote: "缺席:CONFIG_PROVIDER=local ⇒ host;其余 ⇒ 进程内 stub", configKey: "remoteExec", resolve: (c) => c.remoteExec?.provider ?? null }),
351
352
  r("REMOTE_EXEC_HOME_DIR", "orchestration", "path", "执行环境的 home 目录(`~/` 形权限规则的基;core ExecutionEnv.homeDir 座)", { danger: SEC, derivedDefaultNote: "缺席:host/in-process ⇒ os.homedir();e2b ⇒ /home/user;k8s/local-docker/ssh/adb/device ⇒ 无(`~/` 规则 422 响亮拒)", configKey: "remoteExecHomeDir", resolve: (c) => c.remoteExecHomeDir ?? null }),
352
353
  r("REQUIRE_PRINCIPAL", "auth", "boolean", "多租户开关(posture 判别位:true ⇒ CC 能力族默认收紧)", { staticDefault: "false", danger: SEC, configKey: "requirePrincipal" }),
353
354
  r("RESOURCE_SUSPEND", "approval", "boolean", "durable 路径 opt-in core resourceSuspend(预算/preempt ⇒ 可恢复挂起)", { staticDefault: "false", configKey: "resourceSuspend" }),
@@ -89,6 +89,19 @@ export declare function executionLaneCaps(lane: ExecutionLane): ExecutionLaneCap
89
89
  export declare function isExecutionLane(word: string): word is ExecutionLane;
90
90
  /** 车道普查全集(顺序 = 词表声明序;对账测试按它逐 lane 跑真判别式)。 */
91
91
  export declare const ALL_EXECUTION_LANES: readonly ExecutionLane[];
92
+ /**
93
+ * `REMOTE_EXEC` **真能写下去的**那几个词(= {@link ALL_EXECUTION_LANES} 去掉 `in-process`:后者是
94
+ * 「没有 remoteExec」这个状态的名字,env 里写它没有意义)。
95
+ *
96
+ * 🔴 S-426 以点改面:此前 `config-catalog.ts` 的 `REMOTE_EXEC` 行把这张表**手抄**成一个
97
+ * `enumValues: ["e2b","k8s",…]` 字面量(字段静态型是 `readonly string[]`,与 {@link RemoteExecProvider}
98
+ * 零类型联系)。病形与 S-382 逮到的 `WEB_SEARCH_PROVIDER` 逐字同族:给
99
+ * `ServiceConfigFlat["remoteExec"]` 加一条 provider 臂(config-types.ts 里写着的下一条就是
100
+ * `remote-docker`),本文件的穷举 switch 与词表**会**编译红,而目录行不会 —— 于是运维在
101
+ * `GET /v1/config/catalog` 上读到的可写词集静默少一个,新车道成了「配了但目录说不认识」。
102
+ * 收成一只派生数组之后,目录行**就用它本人**(身份相等有钉),全仓只剩这一张表。
103
+ */
104
+ export declare const REMOTE_EXEC_PROVIDER_WORDS: readonly RemoteExecProvider[];
92
105
  /**
93
106
  * `config.remoteExec?.provider` → 车道词。
94
107
  *
@@ -156,6 +156,7 @@ export function isExecutionLane(word) {
156
156
  return Object.hasOwn(EXECUTION_LANE_WORDS, word);
157
157
  }
158
158
  export const ALL_EXECUTION_LANES = Object.freeze(Object.keys(EXECUTION_LANE_WORDS).filter(isExecutionLane));
159
+ export const REMOTE_EXEC_PROVIDER_WORDS = Object.freeze(ALL_EXECUTION_LANES.filter((lane) => lane !== "in-process"));
159
160
  export function executionLaneOf(provider) {
160
161
  if (provider === undefined)
161
162
  return "in-process";
@@ -1,6 +1,6 @@
1
1
  import { isTerminalWorkflowStatus } from "@sema-agent/core";
2
2
  import { recordFailOpen } from "../observability/fail-open.js";
3
- import { isParkedRunStatus, isTerminalRunStatus } from "../plugins/store-contracts.js";
3
+ import { isParkedRunStatus, isTerminalRunStatus, runProvenElsewhere } from "../plugins/store-contracts.js";
4
4
  import { buildFleetWorkflowRow, fleetRunResiduals, runStatusToFleet } from "./fleet-bus.js";
5
5
  import { isTerminalWorkflowRowStatus } from "./fleet-terminal-window.js";
6
6
  export const FLEET_RECONCILE_INTERVAL_MS = 60_000;
@@ -169,7 +169,7 @@ export function createFleetReconciler(deps) {
169
169
  tally.skippedParked++;
170
170
  continue;
171
171
  }
172
- orphanExit(row, rec.instanceId !== null && rec.instanceId !== (deps.instanceId ?? null) ? "durable_running_other_replica" : "durable_running_no_local_leg");
172
+ orphanExit(row, runProvenElsewhere(rec, deps.instanceId) ? "durable_running_other_replica" : "durable_running_no_local_leg");
173
173
  }
174
174
  for (const row of snap.tasks) {
175
175
  if (row.parentId === undefined || present.has(row.parentId))
@@ -16,6 +16,7 @@ import { type QuestionAnswer, type CheckpointToken } from "@sema-agent/core";
16
16
  import type { DecideBinding } from "./wire-types.js";
17
17
  import type { CheckpointStoreFull } from "../plugins/store-backend.js";
18
18
  import { type HostDecision } from "../host-decision.js";
19
+ import { type PlanApprovePermissionModeAfter } from "../task-settings.js";
19
20
  import type { RouteCtxBase, DriveResumeArgs } from "./route-ctx.js";
20
21
  import { type TrustedRunInternals } from "./durable-run-leg.js";
21
22
  import type { RunRegistry, RouteHelpers, RouteLegs } from "./route-ctx.js";
@@ -72,7 +73,7 @@ export declare function createResumeLegs(ctx: {
72
73
  }, acceptEarly?: boolean, decider?: {
73
74
  principal?: string;
74
75
  explicitOperator: boolean;
75
- }) => Promise<{
76
+ }, permissionModeAfter?: PlanApprovePermissionModeAfter) => Promise<{
76
77
  status: number;
77
78
  body: object;
78
79
  }>;
@@ -24,6 +24,7 @@ import { reasoningDeltaBytes, reasoningSegmentEventId } from "../trace/model-seg
24
24
  import { errorText } from "../observability/err-text.js";
25
25
  import { recordFailOpen } from "../observability/fail-open.js";
26
26
  import { verifyRoundsFromBody } from "./verify-rounds.js";
27
+ import { storedBodyPermissionMode } from "../task-settings.js";
27
28
  import { acceptedResumingReceipt } from "./route-ctx.js";
28
29
  import { mintAndDriveDurableRun } from "./durable-run-leg.js";
29
30
  import { createSessionsLocal } from "./routes/sessions.js";
@@ -320,7 +321,7 @@ export function createResumeLegs(ctx) {
320
321
  hostDecision,
321
322
  };
322
323
  const verifyRounds = verifyRoundsFromBody(ctx.body);
323
- const driven = await driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(boundGrant ? { onResumeCommitted: boundGrant } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}), ...(ownerLeaseAdmittedHere ? { ownerLeaseAdmitted: true } : {}) });
324
+ const driven = await driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, resumeCtx: { sessionId, ctx }, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(boundGrant ? { onResumeCommitted: boundGrant } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}), ...(ownerLeaseAdmittedHere ? { ownerLeaseAdmitted: true } : {}) });
324
325
  noteLateSettleIfAccepted(driven);
325
326
  return driven;
326
327
  }
@@ -353,7 +354,11 @@ export function createResumeLegs(ctx) {
353
354
  }
354
355
  async function driveResumeLeg(args, onAccepted) {
355
356
  const resumeLegStartedAt = Date.now();
356
- const { runner: legRunner, token, sessionId, principal, fleetScope, taskConfig, resumeObjective, outcome, verifyRounds } = args;
357
+ const { runner: legRunner, token, sessionId, principal, fleetScope, taskConfig, resumeObjective, outcome, verifyRounds, resumeCtx } = args;
358
+ const commitResumeCtx = () => deps.checkpointStore.putCtx(resumeCtx.sessionId, resumeCtx.ctx).catch((e) => {
359
+ deps.metrics?.inc("resume_ctx_not_persisted_total");
360
+ deps.logger?.warn?.("resume_ctx_not_persisted", { sessionId: resumeCtx.sessionId, gate: outcome.gate, error: errorText(e), note: "this continuation runs with the body it was given; the durable rebuild input still holds the PREVIOUS body, so the NEXT continuation of this session rebuilds from that one (never wider than this leg's)" });
361
+ });
357
362
  const legKey = createHash("sha256").update(String(token)).digest("hex");
358
363
  const streamApprovalOn = resolveStreamApprovalGate({
359
364
  toolApprovalEnabled: Boolean(deps.toolApproval),
@@ -539,6 +544,7 @@ export function createResumeLegs(ctx) {
539
544
  };
540
545
  resumeLegLive = true;
541
546
  const vr = await resumeWithVerification(legRunner, token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds, verifyInternals);
547
+ await commitResumeCtx();
542
548
  let safe = stripResumeToken(vr);
543
549
  const vrNested = nestedPauseOf(vr.terminal);
544
550
  const reParkStatus = nestedParkStatusOf(vr);
@@ -663,6 +669,7 @@ export function createResumeLegs(ctx) {
663
669
  }
664
670
  : {}),
665
671
  });
672
+ await commitResumeCtx();
666
673
  resumeStreamRef = stream;
667
674
  if (taskId)
668
675
  steerableRuns.set(taskId, stream);
@@ -1026,7 +1033,7 @@ export function createResumeLegs(ctx) {
1026
1033
  const { spec, engineNotices } = await resolveResumeSpec(ctx.body, req, auth);
1027
1034
  const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
1028
1035
  const verifyRounds = verifyRoundsFromBody(ctx.body);
1029
- return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}) });
1036
+ return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome: { gate: "resource_limit", decision: "continue" }, verifyRounds, resumeCtx: { sessionId, ctx }, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}) });
1030
1037
  }
1031
1038
  async function startFreshHostTurn(sessionId, objective, principal, req, trustedInternals, runId) {
1032
1039
  const cs = deps.checkpointStore;
@@ -1139,6 +1146,7 @@ export function createResumeLegs(ctx) {
1139
1146
  resumeObjective,
1140
1147
  outcome: { gate: "wake", ...(message !== undefined ? { message: { text: message, trusted } } : {}) },
1141
1148
  verifyRounds: undefined,
1149
+ resumeCtx: { sessionId, ctx },
1142
1150
  mintFreshRun: { source: "wake" },
1143
1151
  ...(engineNotices.length > 0 ? { engineNotices } : {}),
1144
1152
  ...(acceptEarly === true ? { acceptEarly: true } : {}),
@@ -1147,7 +1155,7 @@ export function createResumeLegs(ctx) {
1147
1155
  ...(ownerLeaseAdmittedFor !== undefined && auth.principal === ownerLeaseAdmittedFor ? { ownerLeaseAdmitted: true } : {}),
1148
1156
  });
1149
1157
  }
1150
- async function resumePlanReview(sessionId, decision, editedPlan, reason, req, log, acceptEarly, decider) {
1158
+ async function resumePlanReview(sessionId, decision, editedPlan, reason, req, log, acceptEarly, decider, permissionModeAfter) {
1151
1159
  const cs = deps.checkpointStore;
1152
1160
  const log404 = (r) => deps.logger?.info?.("decide_404", {
1153
1161
  reason: r,
@@ -1208,7 +1216,13 @@ export function createResumeLegs(ctx) {
1208
1216
  if (!ctx)
1209
1217
  return { status: 409, body: { error: "resume context missing — cannot rebuild task config", errorCode: "conflict.resume_context_unavailable" } };
1210
1218
  const auth = { sessionId: cp.sessionId, principal: decodeCheckpointScope(cp.scope), memoryScope: ctx.memoryScope };
1211
- const { spec, engineNotices } = await resolveResumeSpec(ctx.body, req, auth);
1219
+ const effMode = storedBodyPermissionMode(ctx.body);
1220
+ if (permissionModeAfter !== undefined && effMode !== "plan") {
1221
+ throw new Error("resumePlanReview: permissionModeAfter was passed for a task that was not submitted with permissionMode \"plan\" — the caller must refuse this before any state changes (see the plan_review route's pre-nonce gate)");
1222
+ }
1223
+ const approvedMode = decision === "approve" && effMode === "plan" ? (permissionModeAfter ?? "default") : undefined;
1224
+ const resumeBody = approvedMode === undefined ? ctx.body : { ...ctx.body, permissionMode: approvedMode };
1225
+ const { spec, engineNotices } = await resolveResumeSpec(resumeBody, req, auth);
1212
1226
  const { objective: resumeObjective, sessionId: _sessionId, ...taskConfig } = spec;
1213
1227
  const verifyRounds = verifyRoundsFromBody(ctx.body);
1214
1228
  const outcome = {
@@ -1217,7 +1231,8 @@ export function createResumeLegs(ctx) {
1217
1231
  ...(editedPlan !== undefined ? { editedPlan } : {}),
1218
1232
  ...(reason ? { reason } : {}),
1219
1233
  };
1220
- return driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}) });
1234
+ const out = await driveResumeIntoRunLog({ runner: runnerFor(spec), token, sessionId: cp.sessionId, principal: auth.principal, fleetScope: resumeFleetScope(req, auth), taskConfig, resumeObjective, outcome, verifyRounds, resumeCtx: { sessionId, ctx: { ...ctx, body: resumeBody } }, ...(engineNotices.length > 0 ? { engineNotices } : {}), ...(acceptEarly === true ? { acceptEarly: true } : {}), ...(req !== undefined ? { callerInitiated: true } : {}) });
1235
+ return out;
1221
1236
  }
1222
1237
  const routeCtxBase = {
1223
1238
  deps,
@@ -23,6 +23,7 @@ import type { IdempotencyCache } from "./idempotency.js";
23
23
  import type { ImagesLocal } from "./routes/images.js";
24
24
  import type { SessionsLocal } from "./routes/sessions.js";
25
25
  import type { SessionSyncLocal } from "./routes/session-sync.js";
26
+ import type { PlanApprovePermissionModeAfter } from "../task-settings.js";
26
27
  import type { VerifyRoundsSpec } from "./verify-rounds.js";
27
28
  /** 跨域可变状态 = lens1 §C2 实测的那 8 条「运行期登记簿」,一个对象。 */
28
29
  export interface RunRegistry {
@@ -127,9 +128,55 @@ export interface PreparedTaskSubmission {
127
128
  * 经 `pending` 一起投(投递单点仍是那一只注册助手,不新开第二条机制)。缺席 = 本次装配没铸下任何通告。 */
128
129
  engineNotices?: readonly EngineNotice[];
129
130
  }
131
+ /**
132
+ * 一条会话的**续跑重建输入**(`checkpoint_ctx` 的行体)—— 提交腿写一次,续跑腿读它重建 spec。
133
+ *
134
+ * S-433 F2 起命名化(此前是五处结构等价的内联字面 `{ body: TaskRequestBody; memoryScope?: string }`):
135
+ * 它同时是 {@link DriveResumeArgs.resumeCtx} 的形,而那一位是**写回**口 —— 读形与写形是同一件东西这句话
136
+ * 得由类型说,不能靠五处手抄碰巧一致。
137
+ */
138
+ export interface CheckpointCtx {
139
+ body: TaskRequestBody;
140
+ /** 提交时这条会话绑定的记忆域(重建 `RequestAuth` 用);缺席 = 没有记忆面。 */
141
+ memoryScope?: string;
142
+ }
130
143
  /** B4①(命名化):`driveResumeIntoRunLog` 的入参形(resume 家族共用)——原为 9 成员内联匿名对象。 */
131
144
  export interface DriveResumeArgs {
132
145
  token: CheckpointToken;
146
+ /**
147
+ * 🔴 **S-433 合并复审 F2 [high] —— 「本次续跑真正用的那个体」,由本腿在赢下 CAS 之后写回耐久层**
148
+ * (验真后修;必填 —— `driveResumeIntoRunLog` 今天恰有**四个**调用点:legacy `/decide`(`resumeCheckpoint`)、
149
+ * `resumePreempted`、`resumeWake`、`resumePlanReview`,四条一视同仁)。
150
+ *
151
+ * 病(修前):`resumePlanReview` 在 `driveResumeIntoRunLog` **受理(2xx)之后**才 `putCtx(sessionId,
152
+ * {...ctx, body})`,而 `ctx` 是请求**开头** `getCtx` 读到的那一份,两个店的 `putCtx` 又都是按
153
+ * `session_id` **无条件 upsert**(`checkpoint-store-sql.ts` / `local-checkpoint-store.ts`)。受理点之后
154
+ * 本腿已经可以跑完并 `setTerminal` 释放 `task_active` —— 别的副本随即 `createRun` + 写一份**新的** ctx,
155
+ * 然后这条迟到的写把它整只盖掉。那是一条**跨副本的丢写**,而被盖掉的恰恰是权限模式。
156
+ *
157
+ * 修法(单一写者,栅栏 = CAS 赢点,不引版本号/不加特判):写点移进 `driveResumeLeg`,落在
158
+ * `resumeStream` / `resumeWithVerification` **解析之后**(core 的 pre-CAS 守卫 + 原子 CAS 已经赢了)、
159
+ * 本腿驱终局之前。此时 park 行仍攥着 `task_active`,在本腿 `setTerminal` 之前**谁也建不了新 run** ⇒
160
+ * 写与释放天然串行;输掉 CAS 的决裁在 `resumeStream` 处就 reject,**永远到不了写点**(「先写回则输家
161
+ * 放宽」这个顾虑同时成立)。
162
+ *
163
+ * 不变量(规则变小,四条驱动腿一视同仁):**`ctx.body` 恒等于最近一次赢得 CAS 的续跑实际用的体**。
164
+ * 体没被改写的腿传**原 ctx** —— 同内容 upsert 幂等,不需要「只有 plan_review 才写」这条特判。
165
+ *
166
+ * 🔴 `sessionId` 自带一位:写回的键**恒是读进来的那一个**(各腿的 `sessionId` 形参,**不是**
167
+ * `cp.sessionId`)—— 读写不同键会写出一条谁也读不回来的行。
168
+ *
169
+ * 🔴 写失败**响亮但不致命**(codex r1 两条 [high] 验真后定):core 的 `resumeStream` 是 **EAGER** 的 ——
170
+ * 写点这一拍 run 已经在执行,裸抛会把一条活腿判成 failed + 释放 claim 而执行还在继续。⇒ 一个臂、
171
+ * 四条腿同守:计数 `resume_ctx_not_persisted_total` + 一行 warn,本次续跑照常。方向论证(不是 [ref] 的
172
+ * 静默放宽):写失败 ⇒ 耐久体保持**上一次**的值,而这四条腿里只有 `resumePlanReview` 改写这个体、
173
+ * 且只会**放宽** ⇒ 落盘的恒不比本次续跑更宽。旧的 `plan_approve_mode_not_persisted`(plan 专用)随本批删除,
174
+ * 由上面这只家族级的取代。全文见 `resume-legs.ts` 的 `commitResumeCtx` 顶注。
175
+ */
176
+ resumeCtx: {
177
+ sessionId: string;
178
+ ctx: CheckpointCtx;
179
+ };
133
180
  /** [ref]:续跑腿的执行 Runner。resume 会用同一条 resolveSpec 重解析场景,故与首跑选同一只 —— 否则
134
181
  * 一次续跑就把手带 band 重新 mount 回来(收窄只在首跑成立 = 没成立)。 */
135
182
  runner: Runner;
@@ -354,7 +401,10 @@ export interface RouteLegs {
354
401
  decider?: {
355
402
  principal?: string;
356
403
  explicitOperator: boolean;
357
- }): Promise<{
404
+ },
405
+ /** S-433:批准之后这条会话继续用的 permission mode(闭集 `PLAN_APPROVE_PERMISSION_MODES_AFTER`;只在 `approve`
406
+ * 上有意义,缺席 ⇒ `"default"`)。语义与判据的唯一属主 = `resume-legs.ts` 的同名形参顶注。 */
407
+ permissionModeAfter?: PlanApprovePermissionModeAfter): Promise<{
358
408
  status: number;
359
409
  body: object;
360
410
  }>;
@@ -549,22 +599,6 @@ export declare function acceptedResumingReceipt(taskId: string, sessionId: strin
549
599
  status: number;
550
600
  body: object;
551
601
  };
552
- /**
553
- * 「**证得出**这条 run 活在别的副本上」——「他副本」那一句话的**唯一判据**(S-381 收编)。
554
- *
555
- * 成案:这个合取式此前在四处**逐字手抄**(`routes/runs.ts` 的 steer 臂与 interrupt 臂、同文件
556
- * `sendHandleMiss`、`routes/run-memory-capture.ts`),第五处就要随 `POST /v1/sessions/:id/mcp/reconnect`
557
- * 出现 —— 而它的每一个合取项都是一条**被复审逐条挣出来的**纪律,不是随手的判空:
558
- * · `run.instanceId` 缺席(`null` / 建列之前的行 / 替身行的 `undefined`)= **不知道它在哪**,不是「在别处」;
559
- * · `thisInstanceId` 缺席 = 本副本连自己是谁都说不上,更不能断言别人;
560
- * · 两者都在场且**不等**才够格说「他副本」(`markResuming` 认领后该列语义 = **当前持有副本**)。
561
- * 抄第五份 = 第五个写者;收成一只谓词之后,纪律改一次就四处同改。**`status` 不进本谓词**:
562
- * `sendHandleMiss` 只对 `running` 行说这句话,而 steer 族在 park/终态两臂之后才走到这里 —— 各调用点
563
- * 的前置臂不同族,合进来会让某一处悄悄改判。
564
- */
565
- export declare function runProvenElsewhere(run: {
566
- instanceId?: string | null;
567
- }, thisInstanceId: string | undefined): boolean;
568
602
  export declare const RUN_NOT_FOUND_MESSAGE = "run not found \u2014 the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)";
569
603
  /** [ref] 前缀=域判别子既成契约:a\* 与 wa\* 形 id 给 TaskOutput/journal 指路;其余给 {@link RUN_NOT_FOUND_MESSAGE}。 */
570
604
  export declare function runNotFoundMessage(id: string): string;
@@ -24,9 +24,6 @@ export const ROUTE_METHOD_ORDER = ["GET", "POST", "PUT", "DELETE"];
24
24
  export function acceptedResumingReceipt(taskId, sessionId) {
25
25
  return { status: 200, body: { taskId, sessionId, status: "resuming", bindingEnforced: true } };
26
26
  }
27
- export function runProvenElsewhere(run, thisInstanceId) {
28
- return typeof run.instanceId === "string" && run.instanceId.length > 0 && thisInstanceId !== undefined && run.instanceId !== thisInstanceId;
29
- }
30
27
  export const RUN_NOT_FOUND_MESSAGE = "run not found — the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)";
31
28
  export function runNotFoundMessage(id) {
32
29
  const aStar = /^(?:a|wa)[0-9a-f]{4,}$/i.test(id);
@@ -16,6 +16,17 @@ import { type KeyClosureIssue } from "../../request-key-closure.js";
16
16
  * 的两种兑现方式:**门只站在人**(或客户端)**现写的那份字节**上。
17
17
  */
18
18
  export declare function decideBodyKeyIssue(raw: unknown): KeyClosureIssue | null;
19
+ /**
20
+ * FRESH 决裁面的**顶层**键闭集裁决:受理集之外的键 ⇒ 返回拒体(调用方 400 `request.body_shape`);
21
+ * 全部受理 ⇒ `null`。判据边界与三个孪生门(`taskBodyKeyIssue` / `taskSettingsKeyIssue` /
22
+ * {@link decideBodyKeyIssue})逐字同一条:**只判键名**(值形校验归各自既有的门,本门零抢话);体本身
23
+ * 非对象 ⇒ 本门不判(留给「body must be { decision … }」那道根形门,免得同一个体被两句话抢答)。
24
+ *
25
+ * 🔴 **重放不额外过门**,也不需要 —— 逐字同 {@link decideBodyKeyIssue} 的同名段:本腿的反重放是
26
+ * 一次性 nonce(S-280),它守的是**同一份现写的字节**;而续跑重建读的是盘上的体、走 `resolveSpec` 的
27
+ * 宽容层 ⇒ 4xx 砖不到任何存量挂起的任务。**门只站在人(或客户端)现写的那份字节上**。
28
+ */
29
+ export declare function planReviewBodyKeyIssue(raw: unknown): KeyClosureIssue | null;
19
30
  /**
20
31
  * S-342 —— 「空串的理由 = 没有理由 = 这是一次**裸拒**」。这条语义只适用于**本仓自铸**的那一个域。
21
32
  *
@@ -7,6 +7,7 @@ import { isParkedRunStatus } from "../../plugins/store-contracts.js";
7
7
  import { principalFrom, decodeCheckpointScope, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../../security.js";
8
8
  import { editedPlanBinding, isWellFormedUnicode, verifyDirectDoorProof } from "../../principal-jwt.js";
9
9
  import { MAX_APPROVAL_REASON_CHARS } from "../../approval-hmac.js";
10
+ import { PLAN_APPROVE_PERMISSION_MODES_AFTER, isPlanApprovePermissionModeAfter, storedBodyPermissionMode } from "../../task-settings.js";
10
11
  import { MAX_APPROVAL_NONCE_CHARS } from "../../plugins/approval-nonce-store.js";
11
12
  import { redactedPreview, redactSecrets } from "../../trace/redact.js";
12
13
  import { redactRiskDescriptor } from "../../observability/run-terminal-log.js";
@@ -46,6 +47,35 @@ export function decideBodyKeyIssue(raw) {
46
47
  unsupportedKeys: buckets.unsupported.slice(0, MAX_KEY_ECHO),
47
48
  };
48
49
  }
50
+ const PLAN_REVIEW_BODY_DECLARED_KEYS = [
51
+ "boundCallId", "decision", "editedPlan", "permissionModeAfter", "reason",
52
+ ];
53
+ const ACCEPTED_PLAN_REVIEW_BODY = new Set(PLAN_REVIEW_BODY_DECLARED_KEYS);
54
+ const UNSUPPORTED_PLAN_REVIEW_BODY = new Set([
55
+ "answer", "boundInputHash", "checkpointToken", "permissionMode", "remember", "sessionId", "taskId", "updatedInput",
56
+ ]);
57
+ export function planReviewBodyKeyIssue(raw) {
58
+ if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw))
59
+ return null;
60
+ const buckets = { unknown: [], unsupported: [] };
61
+ scanKeyClosure(raw, "", ACCEPTED_PLAN_REVIEW_BODY, UNSUPPORTED_PLAN_REVIEW_BODY, buckets);
62
+ if (buckets.unknown.length === 0 && buckets.unsupported.length === 0)
63
+ return null;
64
+ buckets.unknown.sort();
65
+ buckets.unsupported.sort();
66
+ const parts = [
67
+ ...(buckets.unknown.length > 0 ? [keyClosureClause(buckets.unknown, "unknown (not a plan-review-request key — check the spelling)")] : []),
68
+ ...(buckets.unsupported.length > 0 ? [keyClosureClause(buckets.unsupported, "unsupported (a sibling approval door's field, a submit-body key, or a path parameter — never read from this body)")] : []),
69
+ ];
70
+ return {
71
+ message: `body: unrecognized top-level key(s) — refused rather than dropped, because a dropped key reads as "in effect" to the caller ` +
72
+ `(a misspelled \`permissionModeAfter\` silently leaves this task read-only after you approved its plan). ` +
73
+ `${parts.join("; ")}. ` +
74
+ `Accepted: ${[...ACCEPTED_PLAN_REVIEW_BODY].sort().join(",")}`,
75
+ unknownKeys: buckets.unknown.slice(0, MAX_KEY_ECHO),
76
+ unsupportedKeys: buckets.unsupported.slice(0, MAX_KEY_ECHO),
77
+ };
78
+ }
49
79
  export function decisionReasonOf(body) {
50
80
  return typeof body.reason === "string" && body.reason !== "" ? body.reason : undefined;
51
81
  }
@@ -133,7 +163,7 @@ async function decideIdempotentReplay(cs, args) {
133
163
  export async function handleApprovalsAssistant(req, res, match, ctx) {
134
164
  const { deps } = ctx;
135
165
  const { preemptableRuns } = ctx.registry;
136
- const { readJson, rateLimited, quotaExceeded } = ctx.helpers;
166
+ const { readJson, rateLimited, quotaExceeded, safeDecode } = ctx.helpers;
137
167
  const { resumeCheckpoint, resumePreempted, resumePlanReview } = ctx.legs;
138
168
  {
139
169
  const cs = deps.checkpointStore;
@@ -164,13 +194,9 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
164
194
  sendError(res, 501, "capability.store_required", "approval exemptions need a store backend");
165
195
  return;
166
196
  }
167
- let exSessionId;
168
- let exToolName;
169
- try {
170
- exSessionId = decodeURIComponent(match.params.sessionId);
171
- exToolName = match.params.tool !== undefined ? decodeURIComponent(match.params.tool) : undefined;
172
- }
173
- catch {
197
+ const exSessionId = safeDecode(match.params.sessionId);
198
+ const exToolName = match.params.tool !== undefined ? safeDecode(match.params.tool) : undefined;
199
+ if (exSessionId === null || exToolName === null) {
174
200
  sendError(res, 400, "request.path_malformed", "malformed id (invalid percent-encoding)");
175
201
  return;
176
202
  }
@@ -365,9 +391,16 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
365
391
  const body = (await readJson(req));
366
392
  const decision = body?.decision === "approve" ? "approve" : body?.decision === "edit" ? "edit" : body?.decision === "reject" ? "reject" : undefined;
367
393
  if (!decision) {
368
- sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'edit' | 'reject', editedPlan?, reason? }");
394
+ sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'edit' | 'reject', editedPlan?, reason?, permissionModeAfter? }");
369
395
  return;
370
396
  }
397
+ {
398
+ const keyIssue = planReviewBodyKeyIssue(body);
399
+ if (keyIssue) {
400
+ sendError(res, 400, "request.body_shape", keyIssue.message, { unknownKeys: keyIssue.unknownKeys, unsupportedKeys: keyIssue.unsupportedKeys });
401
+ return;
402
+ }
403
+ }
371
404
  if (decision === "edit" && (typeof body.editedPlan !== "string" || body.editedPlan.length === 0)) {
372
405
  sendError(res, 400, "request.field_conflict", "decision 'edit' requires a non-empty editedPlan (the operator's revised plan)");
373
406
  return;
@@ -380,6 +413,14 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
380
413
  sendError(res, 400, "request.field_conflict", `editedPlan is only valid with decision 'edit' (got '${decision}')`);
381
414
  return;
382
415
  }
416
+ if (body.permissionModeAfter !== undefined && !isPlanApprovePermissionModeAfter(body.permissionModeAfter)) {
417
+ sendError(res, 400, "request.field_invalid", `permissionModeAfter must be one of ${PLAN_APPROVE_PERMISSION_MODES_AFTER.map((m) => JSON.stringify(m)).join(" | ")} when present (the permission mode this task continues in once the plan is approved; absent = "default", which keeps asking before each write)`);
418
+ return;
419
+ }
420
+ if (decision !== "approve" && body.permissionModeAfter !== undefined) {
421
+ sendError(res, 400, "request.field_conflict", `permissionModeAfter is only valid with decision 'approve' (got '${decision}')`);
422
+ return;
423
+ }
383
424
  if (body.reason !== undefined && typeof body.reason !== "string") {
384
425
  sendError(res, 400, "request.field_invalid", "reason must be a string");
385
426
  return;
@@ -423,7 +464,7 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
423
464
  }
424
465
  const storeNowMs = await store.nowMs();
425
466
  const hdr = (n) => { const h = req.headers[n]; return Array.isArray(h) ? h[0] : h; };
426
- const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, { sessionId: run.sessionId, boundCallId: planNonce ?? null, boundInputHash: decision === "edit" ? editedPlanBinding(body.editedPlan) : null, decision, reason: typeof body.reason === "string" ? body.reason : null }, deps.config, { actionBinding: false, nowSec: Math.floor(storeNowMs / 1000) });
467
+ const proof = verifyDirectDoorProof({ jwt: hdr(PRINCIPAL_TOKEN_HEADER), mac: hdr(APPROVAL_MAC_HEADER), kid: hdr(APPROVAL_MAC_KID_HEADER) }, { sessionId: run.sessionId, boundCallId: planNonce ?? null, boundInputHash: decision === "edit" ? editedPlanBinding(body.editedPlan) : null, decision, reason: typeof body.reason === "string" ? body.reason : null, ...(isPlanApprovePermissionModeAfter(body.permissionModeAfter) ? { permissionModeAfter: body.permissionModeAfter } : {}) }, deps.config, { actionBinding: false, nowSec: Math.floor(storeNowMs / 1000) });
427
468
  if (!proof.ok) {
428
469
  sendError(res, proof.status, proof.errorCode, proof.error);
429
470
  return;
@@ -438,6 +479,13 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
438
479
  sendError(res, 404, "not_found.run", "task not found");
439
480
  return;
440
481
  }
482
+ if (body.permissionModeAfter !== undefined) {
483
+ const storedCtx = await deps.checkpointStore?.getCtx(run.sessionId).catch(() => null);
484
+ if (storedCtx?.body !== undefined && storedBodyPermissionMode(storedCtx.body) !== "plan") {
485
+ sendError(res, 400, "request.field_conflict", "permissionModeAfter does not apply to this task: it was not submitted with permissionMode \"plan\", so approving the plan lifts no read-only restriction");
486
+ return;
487
+ }
488
+ }
441
489
  if (singleUse !== undefined) {
442
490
  const { proof: onceOnly, store } = singleUse;
443
491
  const fresh = await store.consume(run.sessionId, onceOnly.nonce, onceOnly.acceptedBeforeMs);
@@ -450,18 +498,15 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
450
498
  return;
451
499
  }
452
500
  }
453
- const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined }, true, { principal: deciderPrincipal, explicitOperator });
501
+ const out = await resumePlanReview(run.sessionId, decision, decision === "edit" ? body.editedPlan : undefined, typeof body.reason === "string" ? body.reason : undefined, req, { taskId, principalPresent: principal !== undefined }, true, { principal: deciderPrincipal, explicitOperator }, isPlanApprovePermissionModeAfter(body.permissionModeAfter) ? body.permissionModeAfter : undefined);
454
502
  sendResumeOutcome(res, out, deps.logger);
455
503
  return;
456
504
  }
457
505
  if (match.decl.id === "approval-decide") {
458
506
  if (rateLimited(req, res) || quotaExceeded(req, res))
459
507
  return;
460
- let sessionId;
461
- try {
462
- sessionId = decodeURIComponent(match.params.sessionId);
463
- }
464
- catch {
508
+ const sessionId = safeDecode(match.params.sessionId);
509
+ if (sessionId === null) {
465
510
  sendError(res, 400, "request.path_malformed", "malformed approval id (invalid percent-encoding)");
466
511
  return;
467
512
  }