@sema-agent/server 7.35.1 → 7.36.0-rc.1

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 (50) hide show
  1. package/README.zh-CN.md +1 -1
  2. package/USAGE.md +5 -1
  3. package/dist/boot/config-center.js +14 -4
  4. package/dist/boot/org-memory.d.ts +21 -0
  5. package/dist/boot/org-memory.js +1 -1
  6. package/dist/boot/parked-revive-gate.d.ts +16 -1
  7. package/dist/boot/parked-revive-gate.js +33 -68
  8. package/dist/boot/runner-deps.d.ts +1 -1
  9. package/dist/boot/runner-deps.js +18 -3
  10. package/dist/boot/side-query-lane.d.ts +124 -0
  11. package/dist/boot/side-query-lane.js +158 -0
  12. package/dist/boot/webfetch-summarize-lane.d.ts +60 -0
  13. package/dist/boot/webfetch-summarize-lane.js +67 -0
  14. package/dist/brain.js +15 -1
  15. package/dist/config-types.d.ts +27 -2
  16. package/dist/config.js +13 -3
  17. package/dist/degenerate-instrument.d.ts +13 -1
  18. package/dist/degenerate-instrument.js +13 -1
  19. package/dist/http/active-run-conflict.js +16 -0
  20. package/dist/http/routes/a2a-serve.js +12 -2
  21. package/dist/http/routes/admin-drain.d.ts +15 -0
  22. package/dist/http/routes/admin-drain.js +7 -1
  23. package/dist/http/routes/approvals-assistant.js +3 -3
  24. package/dist/http/routes/capabilities.js +11 -2
  25. package/dist/http/routes/rules.js +7 -4
  26. package/dist/http/routes/runs.js +55 -8
  27. package/dist/http/routes/sessions-list.js +1 -1
  28. package/dist/http/routes/sessions.js +4 -4
  29. package/dist/http/routes/side-query.js +6 -1
  30. package/dist/http/server.d.ts +15 -4
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +14 -0
  33. package/dist/main.js +17 -28
  34. package/dist/memory-posture.d.ts +14 -1
  35. package/dist/memory-posture.js +2 -0
  36. package/dist/observability/fail-open.d.ts +12 -0
  37. package/dist/observability/fail-open.js +12 -0
  38. package/dist/parked-decide.d.ts +3 -1
  39. package/dist/parked-decide.js +34 -11
  40. package/dist/rules-consent.d.ts +20 -0
  41. package/dist/rules-consent.js +21 -0
  42. package/dist/security.d.ts +22 -0
  43. package/dist/security.js +24 -0
  44. package/dist/shared-memory-scope-authorizer.d.ts +36 -3
  45. package/dist/shared-memory-scope-authorizer.js +19 -4
  46. package/dist/task-cwd.d.ts +16 -3
  47. package/dist/task-cwd.js +16 -3
  48. package/dist/tool-approval.d.ts +61 -2
  49. package/dist/tool-approval.js +159 -17
  50. package/package.json +2 -2
@@ -35,6 +35,7 @@
35
35
  */
36
36
  import { randomUUID } from "node:crypto";
37
37
  import { recordFailOpen } from "./observability/fail-open.js";
38
+ import { MAX_CWD_CHARS } from "./task-cwd.js";
38
39
  import { confirmRuleApproval, prepareCardApproval, prepareCcImport, redeemRuleBatch, redeemRuleTicket, removePersistedRule, } from "@sema-agent/core";
39
40
  import { buildRulePayloadHash } from "./plugins/permission-rule-store-sql.js";
40
41
  /** 导入票的存活窗。人从「看到预览」到「按下确认」是一次交互,不是一段会话——十分钟宽到不会误伤,
@@ -75,6 +76,26 @@ export const MAX_IMPORT_CANDIDATES = 200;
75
76
  export function serializeRuleScope(scope) {
76
77
  return scope.kind === "global" ? "global" : `project:${scope.root}`;
77
78
  }
79
+ /**
80
+ * 🔴 判别式串的**上限,由铸侧的尺派生**(A-054.24,2026-08-19 合并重扫 confirmed —— 修的是「两个数字
81
+ * 各挑各的」这件事本身,不是把某个数字调大一点)。
82
+ *
83
+ * 病:铸侧 `cardRuleScopeRoot` 对 root 长度零把关(入口尺 = `isValidCwd` 的 {@link MAX_CWD_CHARS}),
84
+ * core 与两个店都只校验 `root.min(1)` ⇒ 写面全程无阻;而 `DELETE /v1/rules` 的体 schema 此前独立写死
85
+ * `scope: max(1088)`,且 safeParse 排在 `parseRuleScope`/权限判据**之前** ⇒ 超限 root 的规则
86
+ * `GET /v1/rules` **列得出**、按同一串 DELETE **恒 400**。全仓只有一个 `lane.removeRule` 调用点(直删
87
+ * 店行是无墓碑硬删、会被 sync 的 join 复活),所以那是**一条已列出的常驻放行规则在受支持接口上不可撤**;
88
+ * 唯一的钝器补偿是 `PERMISSION_RULES_ENABLED=false`(关掉全部规则,两口自身随之 501)。
89
+ *
90
+ * ⚠️ 可达性在 A-054.F-A 之后**变严重**:那批把铸侧出口从 `realpath` 改成 `path.resolve`,而 realpath
91
+ * 要求目录真实存在(macOS PATH_MAX=1024 ⇒ 宿主上 root 恒 ≤1023,结构性免疫);`path.resolve` 纯词法、
92
+ * 不碰盘 ⇒ **任何宿主上** 1081..4096 字符的注册 cwd 都能铸出这样一条规则。
93
+ *
94
+ * 取值 = `"project:"` 前缀 + 铸侧 root 的入口尺。`path.resolve` 对「已绝对、无 `..` 段」的路径不加长
95
+ * (`isValidCwd` 两条都要求),故 {@link MAX_CWD_CHARS} 是 root 的真上界。**两侧从此同源**:谁改铸侧
96
+ * 的尺,撤侧自动跟。
97
+ */
98
+ export const MAX_RULE_SCOPE_CHARS = "project:".length + MAX_CWD_CHARS;
78
99
  /** {@link serializeRuleScope} 的逆。读不出形 ⇒ `undefined`(调用方 400,绝不猜一个 global 出来 ——
79
100
  * 猜 global 会把一次「删项目内规则」的请求变成一次删不掉任何东西的 no-op,或者更糟)。 */
80
101
  export function parseRuleScope(text) {
@@ -251,6 +251,28 @@ export declare function assertPrincipalShape(principal: string): void;
251
251
  * ./plugins/send-file-ledger.js) so every checkpoint scope read/write goes through one place instead of
252
252
  * each call site re-deriving the "_" ⇄ no-principal mapping inline. */
253
253
  export declare const CHECKPOINT_PUBLIC_SCOPE: "_";
254
+ /**
255
+ * 🔴 A-057.44(2026-08-19)—— **后台子代注册簿的匿名租户键**,与上面那只 checkpoint 哨兵是
256
+ * 「同一件事(没有 principal)的两套写法」。
257
+ *
258
+ * 写侧不是本仓能单方面改的:core 的 `treeScope = reviveClaim?.row.scope ?? ctx.principal ??
259
+ * opts.background?.scope` 同时喂**进程内** `defaultTaskRegistry` 与 **durable** 行,而本仓/`core` 全域的
260
+ * 读侧是一整族 `principal ?? "default"`(`runs.ts` markStopSourceForOwner、`boot/session-faces.ts`
261
+ * reapSessionBackground、`http/routes/runs.ts` 的 subagent output/stream 三口、`agents-roster.ts`、
262
+ * `fleet.ts`)。⇒ **把铸点改成 `"_"` 会把这一整族读者一起打瞎**(本车实测:`subagent-tail-content-supply`
263
+ * 的 tail 建连当场 404)。所以归一只能发生在**读侧**:checkpoint 侧拿着 `"_"` 去查 bg 分区时,
264
+ * 显式把这两个键当同一格。
265
+ */
266
+ export declare const BACKGROUND_REGISTRY_ANON_SCOPE: "default";
267
+ /**
268
+ * checkpoint 的 scope → 该在哪些 **bg 注册簿分区**里找它的 parked 子代(A-057.44)。
269
+ *
270
+ * 匿名(`"_"`)⇒ 两个都查:`"_"`(将来若统一了写侧)与 `"default"`(今天 core 真写下去的那个)。
271
+ * 其余 ⇒ 原样单查:有 principal 时两套约定折出**同一个**串(`encodeCheckpointScope` 只在 null/undefined
272
+ * 时才替换,core 那侧也只在 `ctx.principal` 缺席时才落兜底),不存在第二个候选;而多租户部署里一个
273
+ * **真名叫 `default`** 的租户必须只匹配自己那格 —— 所以别把别名做成双向。
274
+ */
275
+ export declare function backgroundScopesForCheckpointScope(scope: string): readonly string[];
254
276
  /** Encode a principal (or none) as the checkpoint `scope` column value. */
255
277
  export declare const encodeCheckpointScope: (principal: string | null | undefined) => string;
256
278
  /** Decode a checkpoint `scope` column value back to a principal — {@link CHECKPOINT_PUBLIC_SCOPE} maps to
package/dist/security.js CHANGED
@@ -330,6 +330,30 @@ export function assertPrincipalShape(principal) {
330
330
  * ./plugins/send-file-ledger.js) so every checkpoint scope read/write goes through one place instead of
331
331
  * each call site re-deriving the "_" ⇄ no-principal mapping inline. */
332
332
  export const CHECKPOINT_PUBLIC_SCOPE = "_";
333
+ /**
334
+ * 🔴 A-057.44(2026-08-19)—— **后台子代注册簿的匿名租户键**,与上面那只 checkpoint 哨兵是
335
+ * 「同一件事(没有 principal)的两套写法」。
336
+ *
337
+ * 写侧不是本仓能单方面改的:core 的 `treeScope = reviveClaim?.row.scope ?? ctx.principal ??
338
+ * opts.background?.scope` 同时喂**进程内** `defaultTaskRegistry` 与 **durable** 行,而本仓/`core` 全域的
339
+ * 读侧是一整族 `principal ?? "default"`(`runs.ts` markStopSourceForOwner、`boot/session-faces.ts`
340
+ * reapSessionBackground、`http/routes/runs.ts` 的 subagent output/stream 三口、`agents-roster.ts`、
341
+ * `fleet.ts`)。⇒ **把铸点改成 `"_"` 会把这一整族读者一起打瞎**(本车实测:`subagent-tail-content-supply`
342
+ * 的 tail 建连当场 404)。所以归一只能发生在**读侧**:checkpoint 侧拿着 `"_"` 去查 bg 分区时,
343
+ * 显式把这两个键当同一格。
344
+ */
345
+ export const BACKGROUND_REGISTRY_ANON_SCOPE = "default";
346
+ /**
347
+ * checkpoint 的 scope → 该在哪些 **bg 注册簿分区**里找它的 parked 子代(A-057.44)。
348
+ *
349
+ * 匿名(`"_"`)⇒ 两个都查:`"_"`(将来若统一了写侧)与 `"default"`(今天 core 真写下去的那个)。
350
+ * 其余 ⇒ 原样单查:有 principal 时两套约定折出**同一个**串(`encodeCheckpointScope` 只在 null/undefined
351
+ * 时才替换,core 那侧也只在 `ctx.principal` 缺席时才落兜底),不存在第二个候选;而多租户部署里一个
352
+ * **真名叫 `default`** 的租户必须只匹配自己那格 —— 所以别把别名做成双向。
353
+ */
354
+ export function backgroundScopesForCheckpointScope(scope) {
355
+ return scope === CHECKPOINT_PUBLIC_SCOPE ? [CHECKPOINT_PUBLIC_SCOPE, BACKGROUND_REGISTRY_ANON_SCOPE] : [scope];
356
+ }
333
357
  /** Encode a principal (or none) as the checkpoint `scope` column value. */
334
358
  export const encodeCheckpointScope = (principal) => principal ?? CHECKPOINT_PUBLIC_SCOPE;
335
359
  /** Decode a checkpoint `scope` column value back to a principal — {@link CHECKPOINT_PUBLIC_SCOPE} maps to
@@ -21,9 +21,42 @@ import type { OrgMemoryDirectory } from "./org-memory-admission.js";
21
21
  export interface SharedMemoryScopeAuthorizerOptions {
22
22
  /** 目录实例。**必须**与 core 准入 seam / memory-policy 面同一只(见头注)。 */
23
23
  directory: OrgMemoryDirectory;
24
- /** 部署自证的 org scope(装配点已按部署形态择净;多租户下应为空)。 */
25
- deploymentScopes?: readonly string[];
24
+ /**
25
+ * 部署自证的 org scope —— **取值口而非值**(A-057.40)。
26
+ *
27
+ * 🔴 类型上是函数是刻意的,不是风格:这组 scope 的真源是 `config.projects` 的 `defaultScopes`,
28
+ * 而 `config.projects` 被 config-center **就地热应用**(`mutateInPlace`,整表替换),env 腿恒空
29
+ * ⇒ 这一域的**常态**就是运行期变更。此前这里收的是一个数组(装配点在 boot 期算一次),而且本模块
30
+ * 还 `[...new Set(...)]` 又复制一层,连「换引用」这条后路都封死 ⇒ 两个方向都错到进程重启为止:
31
+ * · **新登记**一个带 `org:` 的项目 ⇒ 该 org 的共享库在快照里没有 ⇒ 折成 `{state:"connected",
32
+ * stores:[]}`,对模型面是「本会话没有连接任何记忆库」这种**终局式**空集(不是可重试的
33
+ * unavailable),运维以为配好了;
34
+ * · **撤销**方向更糟:项目/scope 下架后,principal-less 的单用户请求仍按旧快照继续被放行 ——
35
+ * 一次运维撤销动作**静默不生效**(#157 点名的静默类型),方向是 fail-OPEN。
36
+ * 而且它没被登进 `config-center/restart-signal.ts` 的 `RESTART_SLICES` ⇒ `/health` 连一句
37
+ * 「该重启了」都不会说。⇒ 收口是把读取时机对齐消费时机:每次 `resolve` 现取。
38
+ * (缺席 = 部署没有任何自证 scope,与传 `() => []` 等价。)
39
+ *
40
+ * 📌 **取值口契约**:交回来的数组必须**已去重**(生产实现 `collectDeploymentOrgScopes` 用 Set 建,
41
+ * 天然满足)。本层不再替它去重 —— principal 缺席那支直接把它当结果交出去。
42
+ */
43
+ deploymentScopes?: () => readonly string[];
26
44
  }
27
- /** 活对象(闭包持有目录与集合)⇒ `create*`(CLAUDE.md 工厂命名律)。 */
45
+ /**
46
+ * 活对象(闭包持有目录与取值口)⇒ `create*`(CLAUDE.md 工厂命名律)。
47
+ *
48
+ * **代价与它的边界**(codex 对抗复审 R1-[medium] 的一半采纳,一半具名保留):取值口按调用现算,
49
+ * 意味着每次 `resolve` 都要扫一遍 `config.projects`(O(项目数 × 每项 scope 数)的纯 CPU)。
50
+ * · **已采纳**:principal 缺席那支不再二次建 Set —— 生产取值口(`collectDeploymentOrgScopes`)本来就用
51
+ * Set 建、交回来已去重,这里再包一层是纯浪费。**去重责任因此上移到取值口**(契约见下面那个字段的注),
52
+ * principal 在场那支的合并 Set 保留(它要与目录 scope 求并,那一次是真需要的)。
53
+ * · **具名保留**(不做 generation 缓存):`config.projects` 由 `mutateInPlace` **就地**改,对象身份恒定,
54
+ * 没有可用的失效信号;而 config-center 的 `appliedGeneration` 是那个模块的私有 WeakMap,不在公面上。
55
+ * 要缓存就得先给 config-center 开一只「已提交世代」读口 —— 那是别人模块的接缝件,且**缓存失效写错
56
+ * 一次,就恰好把本条 finding 刚关掉的那个陈旧窗原样放回来**(还多一层代码)。规模面亦不支持先做:
57
+ * projects 是运维手写的登记簿(量级十几~百),而这条腿的**同一次**调用里,principal 在场那支还要
58
+ * `await directory.lookup()`(网络/缓存),扫表在它旁边不构成瓶颈。⇒ 若哪天登记簿真长到会阻塞事件循环,
59
+ * 正解是**先给 projects/defaultScopes 定尺**(发布期拒收超尺,与本仓其它 wire 上限同族),再谈缓存。
60
+ */
28
61
  export declare function createSharedMemoryScopeAuthorizer(opts: SharedMemoryScopeAuthorizerOptions): SharedMemoryScopeAuthorizer;
29
62
  //# sourceMappingURL=shared-memory-scope-authorizer.d.ts.map
@@ -1,16 +1,31 @@
1
- /** 活对象(闭包持有目录与集合)⇒ `create*`(CLAUDE.md 工厂命名律)。 */
1
+ /**
2
+ * 活对象(闭包持有目录与取值口)⇒ `create*`(CLAUDE.md 工厂命名律)。
3
+ *
4
+ * **代价与它的边界**(codex 对抗复审 R1-[medium] 的一半采纳,一半具名保留):取值口按调用现算,
5
+ * 意味着每次 `resolve` 都要扫一遍 `config.projects`(O(项目数 × 每项 scope 数)的纯 CPU)。
6
+ * · **已采纳**:principal 缺席那支不再二次建 Set —— 生产取值口(`collectDeploymentOrgScopes`)本来就用
7
+ * Set 建、交回来已去重,这里再包一层是纯浪费。**去重责任因此上移到取值口**(契约见下面那个字段的注),
8
+ * principal 在场那支的合并 Set 保留(它要与目录 scope 求并,那一次是真需要的)。
9
+ * · **具名保留**(不做 generation 缓存):`config.projects` 由 `mutateInPlace` **就地**改,对象身份恒定,
10
+ * 没有可用的失效信号;而 config-center 的 `appliedGeneration` 是那个模块的私有 WeakMap,不在公面上。
11
+ * 要缓存就得先给 config-center 开一只「已提交世代」读口 —— 那是别人模块的接缝件,且**缓存失效写错
12
+ * 一次,就恰好把本条 finding 刚关掉的那个陈旧窗原样放回来**(还多一层代码)。规模面亦不支持先做:
13
+ * projects 是运维手写的登记簿(量级十几~百),而这条腿的**同一次**调用里,principal 在场那支还要
14
+ * `await directory.lookup()`(网络/缓存),扫表在它旁边不构成瓶颈。⇒ 若哪天登记簿真长到会阻塞事件循环,
15
+ * 正解是**先给 projects/defaultScopes 定尺**(发布期拒收超尺,与本仓其它 wire 上限同族),再谈缓存。
16
+ */
2
17
  export function createSharedMemoryScopeAuthorizer(opts) {
3
- const deploymentScopes = [...new Set(opts.deploymentScopes ?? [])];
18
+ const deploymentScopesNow = () => opts.deploymentScopes?.() ?? [];
4
19
  return {
5
20
  async resolve(principal) {
6
21
  // principal 缺席 = 单用户部署(多租户下 core 与 HTTP 门都先要求身份)。此时唯一的授权事实就是
7
22
  // operator 自证的那一组;目录无从查起,但这不是「不可用」——它是一个**已知的**答案。
8
23
  if (principal === undefined)
9
- return { kind: "granted", scopes: deploymentScopes };
24
+ return { kind: "granted", scopes: deploymentScopesNow() };
10
25
  const lookup = await opts.directory.lookup(principal);
11
26
  if (lookup.kind === "unavailable")
12
27
  return { kind: "unavailable", reason: lookup.reason };
13
- return { kind: "granted", scopes: [...new Set([...deploymentScopes, ...Object.keys(lookup.scopes)])] };
28
+ return { kind: "granted", scopes: [...new Set([...deploymentScopesNow(), ...Object.keys(lookup.scopes)])] };
14
29
  },
15
30
  };
16
31
  }
@@ -35,9 +35,22 @@ export declare function inProcessSingleUserLane(config: {
35
35
  /**
36
36
  * #295(F-1,[4512] 修向 (b')):卡批「不再询问」落盘规则的 **project root** —— 「这次授权是在哪个
37
37
  * 工作目录里点的」。取值与既有 cwd 语义同源,不新造坐标系:
38
- * 1. session 显式注册的 cwd(cwd seam,host lane;与 {@link effectiveHostWorkspace} 第 1 优先级同源);
39
- * 2. in-process 单用户 lane({@link inProcessSingleUserLane}):引擎自身 `process.cwd()` ——
40
- * 壳把引擎 spawn 在用户目录的本地形([851]P3a 的 PROCESS 级消费者口径,hooks/C4 同判);
38
+ * 1. session 显式注册的 cwd(cwd seam,host lane;与 {@link effectiveHostWorkspace} 第 1 优先级同源)
39
+ * —— **今天唯一真的会命中的那一臂**;⚠️ 它**不含 lane 判据**,lane 语义完全依赖「`perSessionCwd`
40
+ * 只在 `cwdHonored` 为真时被写」这条跨文件外部不变量(写侧唯一入口 boot/resolve-spec.ts)。这是
41
+ * 刻意的单写者惯用法,不是疏漏:在消费端补第二道闸 = 把一条不变量拆给两个属主(同族消费者
42
+ * {@link effectiveHostWorkspace} 形状完全相同)。机器背书 = test/task-cwd.test.ts 的 A-054.2 两格。
43
+ * 2. 🔴 **前瞻/防御臂,本仓当前装配下零命中**(A-054.21,2026-08-19 合并重扫 partial,存活断言):
44
+ * in-process 单用户 lane({@link inProcessSingleUserLane})取引擎自身 `process.cwd()`。它读起来像在
45
+ * 服务一个真部署形,其实到不了 —— 该 lane 的 `remoteExec` 未设 ⇒ boot/execution-env.ts 六条工厂臂全
46
+ * 不命中 ⇒ core 落 `StubExecutionEnv` ⇒ `handsEnabled=false` ⇒ 不挂 Bash ⇒ 未知工具在 agent-loop 走
47
+ * `tool.not_found`(**不进审批座**)⇒ 无 `ruleSuggestions` ⇒ 本函数在规则车道上根本不被调用;同 lane
48
+ * 第 1 臂也因写侧闸只在 `cwdHonored` 下开而恒空。危害口径按 refuter **降级**:死枝 ≡ 落第 3 臂
49
+ * `undefined` ≡ core global 缺省 ≡ #295 修前行为,**今天零用户可见伤害**,不是「恒不命中」类缺陷。
50
+ * 🚧 **给将来接手的人的红线**:这条 lane 若哪天真接上 hands,root 必须取 `executionEnv.cwd`(core 的
51
+ * `taskRootFinal` 就是它),**不是** `process.cwd()` —— 两者在静态 env 装配形下可以完全不同,照现注
52
+ * 直接接线会铸出一个真的歪 root。真正的本地开箱形(`CONFIG_PROVIDER=local`)被 config 默认成
53
+ * **host** lane,受益的是第 1 臂;本臂不是「#295 的本地形受益臂」。
41
54
  * 3. 其余(远程沙箱 / REMOTE_EXEC=host 未注册 / 多租户)⇒ `undefined`:root 在 server 坐标系里
42
55
  * 不可知,调用方**不铸 scope**,落 core 的 global 缺省(= #295 修前行为,恒不更宽;也绝不铸一个
43
56
  * 与规则判定时 cwd 不同坐标系的 root —— 那会静默把「不再询问」变成恒不命中)。
package/dist/task-cwd.js CHANGED
@@ -54,9 +54,22 @@ export function inProcessSingleUserLane(config) {
54
54
  /**
55
55
  * #295(F-1,[4512] 修向 (b')):卡批「不再询问」落盘规则的 **project root** —— 「这次授权是在哪个
56
56
  * 工作目录里点的」。取值与既有 cwd 语义同源,不新造坐标系:
57
- * 1. session 显式注册的 cwd(cwd seam,host lane;与 {@link effectiveHostWorkspace} 第 1 优先级同源);
58
- * 2. in-process 单用户 lane({@link inProcessSingleUserLane}):引擎自身 `process.cwd()` ——
59
- * 壳把引擎 spawn 在用户目录的本地形([851]P3a 的 PROCESS 级消费者口径,hooks/C4 同判);
57
+ * 1. session 显式注册的 cwd(cwd seam,host lane;与 {@link effectiveHostWorkspace} 第 1 优先级同源)
58
+ * —— **今天唯一真的会命中的那一臂**;⚠️ 它**不含 lane 判据**,lane 语义完全依赖「`perSessionCwd`
59
+ * 只在 `cwdHonored` 为真时被写」这条跨文件外部不变量(写侧唯一入口 boot/resolve-spec.ts)。这是
60
+ * 刻意的单写者惯用法,不是疏漏:在消费端补第二道闸 = 把一条不变量拆给两个属主(同族消费者
61
+ * {@link effectiveHostWorkspace} 形状完全相同)。机器背书 = test/task-cwd.test.ts 的 A-054.2 两格。
62
+ * 2. 🔴 **前瞻/防御臂,本仓当前装配下零命中**(A-054.21,2026-08-19 合并重扫 partial,存活断言):
63
+ * in-process 单用户 lane({@link inProcessSingleUserLane})取引擎自身 `process.cwd()`。它读起来像在
64
+ * 服务一个真部署形,其实到不了 —— 该 lane 的 `remoteExec` 未设 ⇒ boot/execution-env.ts 六条工厂臂全
65
+ * 不命中 ⇒ core 落 `StubExecutionEnv` ⇒ `handsEnabled=false` ⇒ 不挂 Bash ⇒ 未知工具在 agent-loop 走
66
+ * `tool.not_found`(**不进审批座**)⇒ 无 `ruleSuggestions` ⇒ 本函数在规则车道上根本不被调用;同 lane
67
+ * 第 1 臂也因写侧闸只在 `cwdHonored` 下开而恒空。危害口径按 refuter **降级**:死枝 ≡ 落第 3 臂
68
+ * `undefined` ≡ core global 缺省 ≡ #295 修前行为,**今天零用户可见伤害**,不是「恒不命中」类缺陷。
69
+ * 🚧 **给将来接手的人的红线**:这条 lane 若哪天真接上 hands,root 必须取 `executionEnv.cwd`(core 的
70
+ * `taskRootFinal` 就是它),**不是** `process.cwd()` —— 两者在静态 env 装配形下可以完全不同,照现注
71
+ * 直接接线会铸出一个真的歪 root。真正的本地开箱形(`CONFIG_PROVIDER=local`)被 config 默认成
72
+ * **host** lane,受益的是第 1 臂;本臂不是「#295 的本地形受益臂」。
60
73
  * 3. 其余(远程沙箱 / REMOTE_EXEC=host 未注册 / 多租户)⇒ `undefined`:root 在 server 坐标系里
61
74
  * 不可知,调用方**不铸 scope**,落 core 的 global 缺省(= #295 修前行为,恒不更宽;也绝不铸一个
62
75
  * 与规则判定时 cwd 不同坐标系的 root —— 那会静默把「不再询问」变成恒不命中)。
@@ -182,8 +182,22 @@ export interface ToolApprovalFrame {
182
182
  * `argsOmitted: true`) when over the byte cap or unserializable. */
183
183
  args?: unknown;
184
184
  argsOmitted?: boolean;
185
- /** "tool_approval_complete" only: how the ask settled. `allowed`/`denied` = a human decision; `expired` = TTL/
186
- * abort/disconnect (fail-closed deny the shell should render as such). Cosmetic dialog-dismiss. */
185
+ /**
186
+ * "tool_approval_complete" only: how the ask settled. `allowed`/`denied` = a human decision.
187
+ *
188
+ * 🔴 `expired` = **这张卡失效了**(TTL / abort / 断连 / 批内兄弟被撤卡 superseded),**不等于「这次调用
189
+ * 被拒了」**(A-054.10:本注上一版逐字写着「fail-closed deny the shell should render as such」——那是
190
+ * #241 断连转 park 与 #280 R-13 窗到期转 park **之前**的语义,两批都没回来改这一行)。同一个词今天有
191
+ * 三种落点,由部署决定,壳**不能**从这个词自己推出终局:
192
+ * · park 设施在场 + 缺省 `park` 政策 ⇒ core durable park(run 挂起候补批,`shapeOutcome` 交
193
+ * `"unavailable"` 而本帧仍发 `expired`);
194
+ * · `UNATTENDED_APPROVAL_POLICY=deny` ⇒ 当场 fail-closed deny(旧注只描述了这一支);
195
+ * · 无 park 设施的部署 ⇒ core 自己 fail-closed deny。
196
+ * ⇒ 壳把它渲成「卡消失了」,**终局读 done 帧的 `status`/`errorCode`/`pendingGate`**。成文真源 =
197
+ * docs/ASSISTANT-WIRE-CONTRACT.md §4a-bis(「别把 expired 本身当拒绝回执渲染」)。
198
+ * 成因表也补了第四支:`settleVoidedSiblings` 的撤卡兄弟走的同样是这个词,那既不是 TTL 也不是断连。
199
+ * Cosmetic dialog-dismiss(这句仍成立——它讲的是**帧的角色**,不是终局)。
200
+ */
187
201
  outcome?: "allowed" | "denied" | "expired";
188
202
  }
189
203
  /** The per-run context `ask` recovers via ALS (mirrors QuestionRunContext + sessionId, which keys the allow-all). */
@@ -607,7 +621,52 @@ export declare class ToolApprovalCoordinator {
607
621
  * pending 的 checkpoint —— 人再批一次就走。真正的收口 = 把窗/emit-全灭/取消三条 ambiguous-CAS catch
608
622
  * 统一到一次**持久**恢复流程(带 CAS 与终态回读),那是店语义级改动,须由本模块属主立设计件,施工车
609
623
  * 不私开(本批已试过整段复用 `convergeFromDurableState`,被自己的全量跑打回,理由见其顶注)。
624
+ *
625
+ * ⚠️ **第二条已登记的残余(A-054.15,2026-08-19 合并重扫 confirmed)**:本口读出的 `true` 交给 `settle`
626
+ * 时第三形参 `updatedInput` 恒缺席 —— 行上没有那一列,读不出「这次批准是否带过 ctrl+g 编辑」。上一条
627
+ * 残余的方向是 fail-closed(该跑的没跑),这一条**反过来**:core 的 `d.updatedInput === undefined` 分支
628
+ * 落回原始未改写实参执行 ⇒ 人批的是改写后的命令、真跑的是原命令 = 执行面**宽于**人所批准。因此它不能
629
+ * 只靠注释登记,已按 CLAUDE.md #157 记 `P-DEBT` 债({@link recordRowDerivedApproveReplay},四个同形站点
630
+ * 同一个 tag)。收口 = 给 ask 行加 `updated_input` 列(店语义级,须属主立件),届时债与 tag 同批销。
631
+ */
632
+ /**
633
+ * 🔴 session grant 短路前的**持久出处复核**(A-054.20 的 codex R1-[high] 补丁;A-057.1 换判据)。
634
+ *
635
+ * 回 `true` = 「这只 ask 从来没有过持久出处」⇒ 一揽子放行可以短路;
636
+ * 回 `false` = 行在(任何状态),**或**读不出来(不知道 = 不放宽)⇒ 调用方必须往下走真出卡。
637
+ *
638
+ * 为什么只读不写:短路的全部价值就是「不铸卡、不落行、不占 pending」,做一次主键点读保住这个价值,
639
+ * 而 `ensureAsk` 会写行。为什么行不在就放行:确定性 askId 的行只可能由**这只 ask 自己**铸出来,
640
+ * 行不在 ⇒ 从来没有过一份能反驳本地判据的持久出处(这也是常见形:grant 命中的 ask 多半是头一次到)。
641
+ *
642
+ * ## 判据的单一原则:**短路不得产生与「没有 grant 时那条路」不同的终局**
643
+ *
644
+ * 没有 grant 时,同一条行在下面 `askBroadcast` 里都有确定去处:终局行(DECIDED/PARKED/DENIED/VOID)
645
+ * 走 `replayTerminalAskRow`(deny 回放 `false`、park 回放 `"unavailable"`);活着的 STREAM_PENDING 行走
646
+ * `ensureAsk` 的幂等命中(采信行上的卡与 deadline,等真决议)。短路一旦 return,这两条腿结构上都够不着
647
+ * —— 所以只要行在,短路就是在用一个**更宽**的答案顶替那条路。⇒ 判据 = `row === null`。
648
+ *
649
+ * 这条原则是分两次到位的,两次漏的都是「行 = 真源」的一部分:
650
+ * · **A-054.20**(codex R1-[high]):首版只查**本地** peek,行上标治理的那一半漏了 —— 本地标表是
651
+ * 进程内表,failover / 重启后幂等命中既有行时它恒空。
652
+ * · **A-057.1**(2026-08-19 三轴组复审,refuter 真运行复现三形):补丁把行读到手里,却只看
653
+ * `card.governanceForced` 一个位 —— 行处于 `DECIDED=deny`(人已明确拒过这只调用)时照样回 `true`,
654
+ * 一揽子放行把一次**已落盘的人类拒绝**在重入 / failover / deny-后到 三形里翻成放行(帧 0、行不变、
655
+ * 无 recordFailOpen ⇒ 遥测零痕迹)。
656
+ * · **codex 对抗复审**(A-057.1 同批,验真后采纳):只补终态判据仍漏「卡还在别处挂着」——
657
+ * STREAM_PENDING 行意味着已有一张卡在等人,凭 grant 抢跑会让行上的终局与真实执行长期矛盾
658
+ * (那张卡随后可被人/另一副本决成相反答案,或 TTL 到点被收敛成幻影 gate)。
659
+ * 三次都不是各自独立的 bug,是同一句话没说全,故最终判据收成一条而不是三个分支。
660
+ *
661
+ * 零额外 IO(行已经在手里),且 fail-closed **by construction**:将来 `AskState` 加词也不需要动这里。
662
+ *
663
+ * 🔴 **已登记的残余(A-057.60,PARTIAL,本批不修)**:这一步是一次**非原子的 check-then-act** ——
664
+ * 另一副本正在为同一确定性 askId 落行、而本次点读抢在其提交前完成时,本副本仍会短路。真解 = 把出处
665
+ * 判定与 grant 消费合并成**同一次**原子存储操作(带条件 upsert / CAS),属店语义级改动,须本模块属主
666
+ * 立件(与上面 R4-F1/A-054.15 两条同源残余同一处置)。「行不在 ⇒ 不短路」不是修法:grant 命中的 ask
667
+ * 多半头一次到,那等于把一揽子放行整只废掉。窗的上界由本判据定死:行**一提交**窗即关。
610
668
  */
669
+ private sessionGrantUncontestedByRow;
611
670
  private readDecidedForRecovery;
612
671
  /** #280 codex R3-F2:同一 askId 下**其余本地注册**(重复注册,见 {@link pendingByAskId} 顶注)按
613
672
  * **park 路由**收尾 —— 行已 PARKING,它们看的是同一条行,终局必须与赢家同形(`unattendedPolicy`
@@ -47,6 +47,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
47
47
  import { uuidv7, MAX_RULE_TEXT_CHARS } from "@sema-agent/core";
48
48
  import { redactDeep, redactSecrets } from "./trace/redact.js";
49
49
  import { createLogger } from "./observability/logger.js";
50
+ import { recordFailOpen } from "./observability/fail-open.js";
50
51
  import { deriveAskId, deriveBatchId, MAX_DECISION_NOTE_CHARS } from "./approval-ask-machine.js";
51
52
  import { ApprovalCardEnvelopeSchema, MAX_RULE_SUGGESTIONS, buildApprovalCard, buildApprovalCardEnvelope, buildApprovalRequestFrame, buildRevokeFrame, readProbeCause, readRuleEvidence, } from "./approval-card.js";
52
53
  import { governanceAskMarksFor, runWithGovernanceAskScope } from "./governance-ask-marks.js";
@@ -320,6 +321,20 @@ function replayTerminalAskRow(row) {
320
321
  return "unavailable";
321
322
  return false; // DENIED | VOID(STREAM_PENDING 由调用点排除在外)
322
323
  }
324
+ /**
325
+ * 🔴 A-054.15(2026-08-19 合并重扫 confirmed;CLAUDE.md #157「P 类改不动的记 P-DEBT」)—— 一条**行派生**的
326
+ * `DECIDED=approve` 被交成裸 `true` 时的记债口。上面那条 JSDoc 早就写着「持久层不存 `updatedInput`,
327
+ * ctrl+g 编辑重放的字面丢失属已知、可接受的降级」——但那句只活在注释里,遥测里这条降级与「一切正常」
328
+ * 同形。它的方向是**错的一侧**:core 的 `d.updatedInput === undefined` 分支落回原始未改写实参执行 ⇒
329
+ * 人批的是改写后的命令、真跑的是原命令(执行面宽于人所批准),所以它不是可以静默的 F 类。
330
+ *
331
+ * **调用纪律**:只在真把 `true` 交出去的那一刻调(读到行不算——单赢者已落定时本腿并不 settle,记了就是
332
+ * 假阳性);deny 不调(没有编辑可丢)。四个同形站点一个不落,选择性登记比不登记更坏(遥测会说谎)。
333
+ * 终局收口 = 给 ask 行加 `updated_input` 列(店语义级,须本模块属主立件),届时本 tag 与本函数同批销。
334
+ */
335
+ function recordRowDerivedApproveReplay(where) {
336
+ recordFailOpen("server.hitl.row-derived-approve-drops-updated-input", where);
337
+ }
323
338
  /**
324
339
  * Coordinates the live tool-approval HITL for the singleton runner. Process-local + same-replica (the pending map is
325
340
  * in memory, like QuestionCoordinator): a respond that lands on another replica finds nothing → 404. Present (passed
@@ -692,7 +707,72 @@ export class ToolApprovalCoordinator {
692
707
  * pending 的 checkpoint —— 人再批一次就走。真正的收口 = 把窗/emit-全灭/取消三条 ambiguous-CAS catch
693
708
  * 统一到一次**持久**恢复流程(带 CAS 与终态回读),那是店语义级改动,须由本模块属主立设计件,施工车
694
709
  * 不私开(本批已试过整段复用 `convergeFromDurableState`,被自己的全量跑打回,理由见其顶注)。
710
+ *
711
+ * ⚠️ **第二条已登记的残余(A-054.15,2026-08-19 合并重扫 confirmed)**:本口读出的 `true` 交给 `settle`
712
+ * 时第三形参 `updatedInput` 恒缺席 —— 行上没有那一列,读不出「这次批准是否带过 ctrl+g 编辑」。上一条
713
+ * 残余的方向是 fail-closed(该跑的没跑),这一条**反过来**:core 的 `d.updatedInput === undefined` 分支
714
+ * 落回原始未改写实参执行 ⇒ 人批的是改写后的命令、真跑的是原命令 = 执行面**宽于**人所批准。因此它不能
715
+ * 只靠注释登记,已按 CLAUDE.md #157 记 `P-DEBT` 债({@link recordRowDerivedApproveReplay},四个同形站点
716
+ * 同一个 tag)。收口 = 给 ask 行加 `updated_input` 列(店语义级,须属主立件),届时债与 tag 同批销。
695
717
  */
718
+ /**
719
+ * 🔴 session grant 短路前的**持久出处复核**(A-054.20 的 codex R1-[high] 补丁;A-057.1 换判据)。
720
+ *
721
+ * 回 `true` = 「这只 ask 从来没有过持久出处」⇒ 一揽子放行可以短路;
722
+ * 回 `false` = 行在(任何状态),**或**读不出来(不知道 = 不放宽)⇒ 调用方必须往下走真出卡。
723
+ *
724
+ * 为什么只读不写:短路的全部价值就是「不铸卡、不落行、不占 pending」,做一次主键点读保住这个价值,
725
+ * 而 `ensureAsk` 会写行。为什么行不在就放行:确定性 askId 的行只可能由**这只 ask 自己**铸出来,
726
+ * 行不在 ⇒ 从来没有过一份能反驳本地判据的持久出处(这也是常见形:grant 命中的 ask 多半是头一次到)。
727
+ *
728
+ * ## 判据的单一原则:**短路不得产生与「没有 grant 时那条路」不同的终局**
729
+ *
730
+ * 没有 grant 时,同一条行在下面 `askBroadcast` 里都有确定去处:终局行(DECIDED/PARKED/DENIED/VOID)
731
+ * 走 `replayTerminalAskRow`(deny 回放 `false`、park 回放 `"unavailable"`);活着的 STREAM_PENDING 行走
732
+ * `ensureAsk` 的幂等命中(采信行上的卡与 deadline,等真决议)。短路一旦 return,这两条腿结构上都够不着
733
+ * —— 所以只要行在,短路就是在用一个**更宽**的答案顶替那条路。⇒ 判据 = `row === null`。
734
+ *
735
+ * 这条原则是分两次到位的,两次漏的都是「行 = 真源」的一部分:
736
+ * · **A-054.20**(codex R1-[high]):首版只查**本地** peek,行上标治理的那一半漏了 —— 本地标表是
737
+ * 进程内表,failover / 重启后幂等命中既有行时它恒空。
738
+ * · **A-057.1**(2026-08-19 三轴组复审,refuter 真运行复现三形):补丁把行读到手里,却只看
739
+ * `card.governanceForced` 一个位 —— 行处于 `DECIDED=deny`(人已明确拒过这只调用)时照样回 `true`,
740
+ * 一揽子放行把一次**已落盘的人类拒绝**在重入 / failover / deny-后到 三形里翻成放行(帧 0、行不变、
741
+ * 无 recordFailOpen ⇒ 遥测零痕迹)。
742
+ * · **codex 对抗复审**(A-057.1 同批,验真后采纳):只补终态判据仍漏「卡还在别处挂着」——
743
+ * STREAM_PENDING 行意味着已有一张卡在等人,凭 grant 抢跑会让行上的终局与真实执行长期矛盾
744
+ * (那张卡随后可被人/另一副本决成相反答案,或 TTL 到点被收敛成幻影 gate)。
745
+ * 三次都不是各自独立的 bug,是同一句话没说全,故最终判据收成一条而不是三个分支。
746
+ *
747
+ * 零额外 IO(行已经在手里),且 fail-closed **by construction**:将来 `AskState` 加词也不需要动这里。
748
+ *
749
+ * 🔴 **已登记的残余(A-057.60,PARTIAL,本批不修)**:这一步是一次**非原子的 check-then-act** ——
750
+ * 另一副本正在为同一确定性 askId 落行、而本次点读抢在其提交前完成时,本副本仍会短路。真解 = 把出处
751
+ * 判定与 grant 消费合并成**同一次**原子存储操作(带条件 upsert / CAS),属店语义级改动,须本模块属主
752
+ * 立件(与上面 R4-F1/A-054.15 两条同源残余同一处置)。「行不在 ⇒ 不短路」不是修法:grant 命中的 ask
753
+ * 多半头一次到,那等于把一揽子放行整只废掉。窗的上界由本判据定死:行**一提交**窗即关。
754
+ */
755
+ async sessionGrantUncontestedByRow(origin, req) {
756
+ if (!this.askStore)
757
+ return true; // 无持久面 ⇒ 本地 peek 就是全部真相(零行为变化)
758
+ const sourceTaskId = req.sourceTaskId ?? origin.taskId;
759
+ const askId = deriveAskId(sourceTaskId, origin.taskId, req.toolCallId, origin.legKey, req.delegation?.parentToolCallId);
760
+ try {
761
+ const row = await this.withStoreDeadline(this.askStore.getAsk(askId), "getAsk(session-grant-provenance)", DURABLE_CONVERGE_READ_TIMEOUT_MS);
762
+ // 🔴 A-057.1 + codex 对抗复审(同函数,验真后采纳):**有行即不短路**。
763
+ // 判据不再按状态分支,理由见顶注:短路不得产生与「没有 grant 时那条路」不同的终局,而**任何**
764
+ // 状态的行在无 grant 时都有确定去处 —— 终局行走 `replayTerminalAskRow`(deny 回 false、park 回
765
+ // "unavailable"),活着的 STREAM_PENDING 行走 ensureAsk 的幂等命中(采信行上的卡、等真决议)。
766
+ // 早先那版按 `governanceForced` 一个位判,漏的是「人已落盘的 deny」;只补终态判据仍漏「卡还在
767
+ // 别处挂着」——凭 grant 抢跑会让行上的终局与真实执行长期矛盾(那张卡随后可被人决成相反答案,
768
+ // 或 TTL 到点被收敛成幻影 gate)。两处漏的是同一件事:**行是真源**。
769
+ return row === null;
770
+ }
771
+ catch (err) {
772
+ this.noteStoreError(err, "getAsk(session-grant-provenance)");
773
+ return false; // 不知道 ⇒ 不放宽(方向收紧:人会多看到一张卡,而不是被静默代答)
774
+ }
775
+ }
696
776
  async readDecidedForRecovery(askId) {
697
777
  if (!this.askStore)
698
778
  return undefined;
@@ -897,23 +977,61 @@ export class ToolApprovalCoordinator {
897
977
  * 送达某个客户端之后发生(respond 需要客户端手上有这个 id),这是 outcome==="allowed"/"denied" 与
898
978
  * "expired"(TTL/abort/run-exit-cleanup/emit 广播全灭四类共用)的天然分野。 */
899
979
  async askBroadcast(origin, ctxs, req, signal) {
980
+ // 🔴 **这一条不过政策口,是刻意的**(A-054.17 同批成文):`signal` 是 core 交给**这一只 ask** 的信号,
981
+ // 它 aborted = 这次工具调用本身正在被拆掉,不是「没人可答」。core 侧同判据排在 onAsk 之前
982
+ // (`resolveAsk` 先判 signal 再调 onAsk)与之后(拿到 `"unavailable"` 也照样覆盖成 deny)各有一道,
983
+ // 所以这里回 park 路由既不可能被 core 采纳、也无 checkpoint 可落 —— 走政策口只会造出一条说谎的遥测。
900
984
  if (signal?.aborted)
901
985
  return false;
902
- // 连接级存活过滤:单连接(ask() 的 ALS 语义,今日 100% 生产流量)沿用「这条连接已经死了 ⇒ false」的
903
- // 原判(entry 时刻检查,零行为变化)。多活集合(boundAsk 广播)下,entry 时已死的目标不计入广播——
904
- // 若全体目标 entry 时已死,同样直接 false(单连接场景的自然推广:数组退化成 1 个已死 ctx 时结果
905
- // 相同)
986
+ // 连接级存活过滤:entry 时刻已死的投递目标不计入广播。
987
+ // 🔴 A-054.17(2026-08-19 合并重扫 partial,存活断言):**全体目标 entry 时已死 ⇒ 走
988
+ // {@link unattendedOutcome} 这一个口**,不再裸 `false`。它与 {@link boundAsk} 的「查无活流」
989
+ // (`live.length === 0`,臂 (e) 的入口形)是同一件事实的两个时刻——那一条是 broker 集合已空,这一条是
990
+ // 集合里躺着的连接自己已 abort;此前两者一个走政策口、一个裸 deny,于是同一句「卡送不到任何人」的
991
+ // 终局取决于 core 收卷与子代 ask 到达谁先跑到。可达形:session-scoped bg / 委派子代经宿主 `boundAsk`
992
+ // 发 ask(core 对这类子代刻意不挂宿主 abort 联动,子代自己的信号是活的 ⇒ 上面那条 signal 门不触发),
993
+ // 落在「宿主 ctx 已 abort、`runWithContext` 的 finally 尚未摘除」的窗口里。红先钉 =
994
+ // `test/unattended-approval-policy.test.ts` 的 A-054.17 格。
906
995
  const aliveCtxs = ctxs.filter((c) => !c.abortSignal?.aborted);
907
996
  if (aliveCtxs.length === 0)
908
- return false;
997
+ return this.unattendedOutcome();
909
998
  const primary = aliveCtxs[0];
999
+ // [2942]/[2943]:治理来源标的**一次** peek(非消费式)。此前它只在下面的帧字面量里就地读 ——
1000
+ // #204 件7 起规则车道也要它(治理档的 ask 不进车道),两处读同一个值必须是**同一次** peek:
1001
+ // 分开读会让「帧说这是治理门 / 车道说不是」这种自相矛盾的卡在理论上存在。
1002
+ // 🔴 A-054.20 把这次 peek **提到 session-grant 短路之前**(此前它排在短路之后,结构上够不着)——
1003
+ // 消费点从两处变三处,但仍是**同一次** peek,上面那条不变式不变。
1004
+ const governanceForced = (this.governanceAskMarks ?? governanceAskMarksFor(this.streamKey(primary.owner, primary.sessionId, origin.taskId))).isMarked(req.toolCallId);
910
1005
  // Session allow-all (CC "Yes, allow all edits during this session"): keyed [owner, sessionId, category] — the
911
1006
  // asking tool's OWN capability category must have been granted in THIS owner's session (修2; module header).
912
1007
  // 多活集合下 owner/sessionId 对全体 ctxs 恒同(streamKey 分组保证),primary 取值代表整个集合安全。
913
1008
  // #287([4434]② P0):`requiresRealApproval` 的成文语义=「必须真人逐次批,一揽子放行不得覆盖」——
914
1009
  // 这条短路正是它要禁的 blanket-allow,安全类 ask 无视既有 grant、恒往下走真出卡(core 侧持久规则
915
1010
  // 半场已源头关死:ruleSuggestionsOf 对该位返 {};流内这半在此)。写侧配对臂见 finishRespond。
916
- if (req.requiresRealApproval !== true && primary.sessionId && this.allowAllSessions.has(sessionAllowKey(primary.owner, primary.sessionId, sessionAllowCategory(req.toolName)))) {
1011
+ // 🔴 A-054.20(2026-08-19 合并重扫 confirmed,真运行复现):**治理档的 ask 同样无视既有 grant**。
1012
+ // 本仓自己的教条早就成文在 `buildRuleLaneMaterial`(「一次『不再询问』= 静默拆掉 operator 的档位,
1013
+ // 且拆完之后遥测里连 ask 都不再出现」)与回决侧的 `rule_governance_forced` 第二道门;session grant
1014
+ // 是同形、只是更短命的一揽子放行,此前两侧都没门 —— 短路排在出卡/落行/发帧之前,于是同一 (owner,
1015
+ // session) 同能力桶的后续治理 ask 直接 `return true`,连一条 ask 痕迹都不留。wire 契约面
1016
+ // (docs/ASSISTANT-WIRE-CONTRACT.md 的 governanceForced 段)逐字承诺「客户端表态掀不掉」。
1017
+ // 🔴 判据 = 本地 peek **∪ 持久行上的出处**(codex 对抗复审 R1-[high],验真后修)。
1018
+ // 本注上一版写的是「本地 peek 说是治理门就够了,行说是的那一半由下面的车道门 fail-closed 兜住」——
1019
+ // **那句话是错的**,而且错在最坏的地方:短路一旦 return,下面那道车道门根本不会执行。本文件自己
1020
+ // 早就成文「本地表是**进程内**表,failover / 重启后幂等命中既有行时它**恒空**,而行上的卡里存着
1021
+ // 落库那一刻的判定」(见 ensureAsk 段的 F2 家族三条)。于是缺口是:一个副本上人点过一次同能力桶的
1022
+ // 普通 allow_session ⇒ 本进程有 grant;随后一只**行上已标治理**的 ask 在本副本重入(本地标表空)
1023
+ // ⇒ 旧形按本地 peek 判「非治理」直接 return true —— 运维治理门被掀掉,且不出卡、不落行、无痕迹。
1024
+ // 处置照本文件既定的**放宽动作 fail-closed 合成**纪律(与规则车道逐字同形:任一侧说治理门就不放宽):
1025
+ // · 无 `askStore` ⇒ 不存在能反驳本地 peek 的行,本地判据即全部真相,短路照旧(零行为变化);
1026
+ // · 有 `askStore` ⇒ 先做一次**确定性 askId 的点读**:行不在(常见形:这只 ask 头一次铸)⇒ 放行;
1027
+ // 行在且卡说治理 ⇒ 不短路(往下走真出卡);读失败/超时 ⇒ **不短路**(不知道 = 不放宽)。
1028
+ // 代价如实记:grant 命中时多一次主键点读;店抖动时人会多看到一张卡(方向是收紧,且 noteStoreError
1029
+ // 留痕)。这不违反 D5「裁决可用性不因店抖动而降级」——人照样答得了,只是不再被**静默**代答。
1030
+ if (!governanceForced &&
1031
+ req.requiresRealApproval !== true &&
1032
+ primary.sessionId &&
1033
+ this.allowAllSessions.has(sessionAllowKey(primary.owner, primary.sessionId, sessionAllowCategory(req.toolName))) &&
1034
+ (await this.sessionGrantUncontestedByRow(origin, req))) {
917
1035
  return true;
918
1036
  }
919
1037
  // #151 车3 刀 3b(设计稿 §0 X-2):**写侧准入门** —— 落 pending / 建 timer / 落行 / 发帧之前。
@@ -936,10 +1054,6 @@ export class ToolApprovalCoordinator {
936
1054
  const id = uuidv7();
937
1055
  const child = isChildAsk(req);
938
1056
  const bounded = boundArgs(req.args);
939
- // [2942]/[2943]:治理来源标的**一次** peek(非消费式)。此前它只在下面的帧字面量里就地读 ——
940
- // #204 件7 起规则车道也要它(治理档的 ask 不进车道),两处读同一个值必须是**同一次** peek:
941
- // 分开读会让「帧说这是治理门 / 车道说不是」这种自相矛盾的卡在理论上存在。
942
- const governanceForced = (this.governanceAskMarks ?? governanceAskMarksFor(this.streamKey(primary.owner, primary.sessionId, origin.taskId))).isMarked(req.toolCallId);
943
1057
  // 🔴 **规则车道用的是本地 peek 与持久行的并集**(#204 件7,codex 对抗复审 R1-中,验真后修)。
944
1058
  // 本地表是进程内的:failover / 重启后幂等命中一条既有行时它恒空,而行上的卡里存着落库那一刻的判定。
945
1059
  // 下面 `ensureAsk` 的对账段按「行 = 真源」同步**帧上**那个键(那是出处属性,行说了算);但规则车道
@@ -1155,6 +1269,8 @@ export class ToolApprovalCoordinator {
1155
1269
  // 写下的 PARKING 行会让**同一只 ask** 的终局取决于「重试没重试」:首次 deny、重入 park。
1156
1270
  // 人的真决议(DECIDED ⇒ true/false)不经这个口 —— 那不是「无人可答」,回放它是幂等复述。
1157
1271
  const replayed = replayTerminalAskRow(existingOrCreated.row);
1272
+ if (replayed === true)
1273
+ recordRowDerivedApproveReplay("ensure-ask-idempotent-replay"); // A-054.15
1158
1274
  return replayed === "unavailable" ? this.unattendedOutcome() : replayed;
1159
1275
  }
1160
1276
  // 🔴 F2 修:行 = 真源。刚插的行上这三样与本地一致(采信是恒等操作);**重入**拿到既有行时,
@@ -1450,6 +1566,7 @@ export class ToolApprovalCoordinator {
1450
1566
  settle(false, "expired");
1451
1567
  }
1452
1568
  else if (outcome === true) {
1569
+ recordRowDerivedApproveReplay(`converge:${where}`); // A-054.15
1453
1570
  settle(true, "allowed");
1454
1571
  }
1455
1572
  else {
@@ -1486,7 +1603,19 @@ export class ToolApprovalCoordinator {
1486
1603
  windowRouteUnavailable = true;
1487
1604
  settle(false, "expired", undefined, hostSettledBy);
1488
1605
  };
1489
- const windowExpired = () => {
1606
+ /**
1607
+ * 🔴 `hostSelfReport` = 本次调用**结束这场等待的真实原因**能不能被本仓如实说出(A-054.13,2026-08-19
1608
+ * 合并重扫 confirmed)。缺省 `"timeout"` = 真的是本仓的窗走完了(定时器腿);断连强转的**有店支**
1609
+ * 整段委托本函数,它必须传 `undefined` —— 结束等待的是连接没了,`"timeout"` 会把「任务的流没了」谎成
1610
+ * 「没人答」(core 对该词铸的是「approver 的窗走完了没人答」)。此前有店支不传、于是继承了缺省词,
1611
+ * 与三处成文(本臂自注 / `PendingApproval.forceParkNow` 的契约句 / CHANGELOG 7.34.0 + 五臂表)逐字相反,
1612
+ * 而无店支一直是对的 —— 同一件事实的两条腿归因分家。自报只在 `deny` 政策下可见(park 路由把词吞掉)。
1613
+ *
1614
+ * ⚠️ 形参**必填、无缺省**(施工时先写成 `= "timeout"` 缺省形,当场被自己的红先钉打回:JS 的缺省参数
1615
+ * 对**显式传入的 `undefined`** 同样生效,`windowExpired(undefined)` 拿到的还是 `"timeout"`)。必填形
1616
+ * 顺带把「这条腿凭什么说得出这个词」变成每个调用点都要当场回答的问题。
1617
+ */
1618
+ const windowExpired = (hostSelfReport) => {
1490
1619
  void (async () => {
1491
1620
  if (!this.askStore || askId === undefined || batchId === undefined) {
1492
1621
  // D1(无持久 ask 店)——终局**完全**由本仓这个窗决定,没有第二个写者。
@@ -1502,7 +1631,7 @@ export class ToolApprovalCoordinator {
1502
1631
  // (根本不是 deny),词的存在理由被 park 语义整体取代;而 `deny` 政策下结束这次等待的**确实**
1503
1632
  // 是本仓的窗,词仍然如实,必须留。
1504
1633
  // 单赢者提交(codex R2-F1):人已落定 ⇒ 一个字都不改(D1 的 respond 是同步收尾,这条交错真实可达)。
1505
- settleParkRouteIfUnsettled("timeout");
1634
+ settleParkRouteIfUnsettled(hostSelfReport);
1506
1635
  return;
1507
1636
  }
1508
1637
  try {
@@ -1562,10 +1691,12 @@ export class ToolApprovalCoordinator {
1562
1691
  // 本修只补「人的真决议不许被丢」这**一条**缺口,不顺手改别人的语义。
1563
1692
  const decided = await this.readDecidedForRecovery(askId);
1564
1693
  if (decided !== undefined && !done) {
1694
+ if (decided)
1695
+ recordRowDerivedApproveReplay("expire-error-recovery"); // A-054.15:行派生 approve ⇒ 记债
1565
1696
  settle(decided, decided ? "allowed" : "denied");
1566
1697
  return;
1567
1698
  }
1568
- settleParkRouteIfUnsettled("timeout");
1699
+ settleParkRouteIfUnsettled(hostSelfReport); // A-054.13:断连强转委托进来时这里恒缺席
1569
1700
  }
1570
1701
  })();
1571
1702
  };
@@ -1574,6 +1705,9 @@ export class ToolApprovalCoordinator {
1574
1705
  // ⚠️ #280 R-13 之后无店支**仍不**复用 windowExpired,理由换了一条:两臂的 park/deny 方向如今同判
1575
1706
  // (A1),但**宿主自报**不同——窗到期臂说得出 `"timeout"`(结束等待的是本仓的窗),断连臂说不出
1576
1707
  // (结束等待的是连接没了,那个词会把「任务的流没了」谎成「没人答」)。自报只在 `deny` 政策下可见。
1708
+ // 🔴 A-054.13:**有店支也归这条纪律管**。此前它裸调 `windowExpired()`,于是继承了那边的缺省
1709
+ // `"timeout"`——店报错 catch 臂一落词,断连臂就说出了自己明写「说不出」的那个词(三处成文同时被打脸,
1710
+ // 且只在 `deny` 部署上可观测,所以一直没人撞见)。现在显式传 `undefined`:两条腿的归因从此同形。
1577
1711
  const forceParkNow = () => {
1578
1712
  if (done)
1579
1713
  return;
@@ -1581,11 +1715,13 @@ export class ToolApprovalCoordinator {
1581
1715
  settleParkRouteIfUnsettled(); // 单赢者提交(自报缺席:结束等待的是断连,不是窗)
1582
1716
  return;
1583
1717
  }
1584
- windowExpired();
1718
+ windowExpired(undefined);
1585
1719
  };
1586
1720
  // F2 修:定时器量的是**行上那个绝对 deadline** 的剩余时长(重入拿到既有行时,剩余时长比一整个窗短
1587
1721
  // —— 旧形会给一条早就该到期的行重新挂一个满窗的表)。store 缺席时逐字沿用 `effectiveWindowMs`(D1)。
1588
- timer = setTimeout(windowExpired, this.askStore ? Math.max(0, persistedExpiresAtMs - Date.now()) : effectiveWindowMs);
1722
+ // 🔴 包一层箭头而不是裸传函数引用:`windowExpired` 现在带必填形参(A-054.13),裸传会让 timer 的实参
1723
+ // 语义直接落到 `hostSelfReport` 上。定时器腿**结束等待的确实是本仓的窗** ⇒ 如实自报 `"timeout"`。
1724
+ timer = setTimeout(() => windowExpired("timeout"), this.askStore ? Math.max(0, persistedExpiresAtMs - Date.now()) : effectiveWindowMs);
1589
1725
  timer.unref?.();
1590
1726
  // #151 车2(D2 取消竞争者:task signal abort / 连接全灭同路):askStore 缺席 ⇒ settle(false,
1591
1727
  // "expired") 逐字不变(D1)。在场 ⇒ settle 前先打一把 transitionAsk(STREAM_PENDING→VOID)CAS。
@@ -2109,10 +2245,16 @@ export class ToolApprovalCoordinator {
2109
2245
  // 只放行**这一次**调用、绝不铸 session grant——铸了就等于把「必须逐次真人批」的位翻译成「批一次
2110
2246
  // 解锁整族」。rememberApplied=false 诚实回显(壳不得渲染「本 session 全放行」)。读侧配对臂
2111
2247
  // (askBroadcast 短路跳过)保证即使别的普通 ask 铸过同族 grant,安全类 ask 也照样出卡。
2248
+ // 🔴 A-054.20 写侧配对臂(与 #287 的安全类臂同形、同理由):**治理档的卡上点 allow_session 不铸 grant**。
2249
+ // 铸了就等于把「运维治理层下的那道门」翻译成「批一次解锁整族」;`buildRuleLaneMaterial` 对持久规则
2250
+ // 早已是这个判据(治理 ask 不进车道),session grant 只是它更短命的一揽子形。读侧配对臂在
2251
+ // `askBroadcast`(治理 ask 无视既有 grant)—— **两臂各管一半**:读侧还额外覆盖「普通卡铸的 grant
2252
+ // 掀掉后续治理门」这条更宽的面,写侧管的是「治理卡自己别再去铸一个」。
2112
2253
  let rememberApplied;
2254
+ const grantable = entry.requiresRealApproval !== true && entry.governanceForced !== true;
2113
2255
  if (parsed.value === "allow_session")
2114
- rememberApplied = Boolean(entry.sessionId) && entry.requiresRealApproval !== true;
2115
- if (parsed.value === "allow_session" && entry.sessionId && entry.requiresRealApproval !== true) {
2256
+ rememberApplied = Boolean(entry.sessionId) && grantable;
2257
+ if (parsed.value === "allow_session" && entry.sessionId && grantable) {
2116
2258
  // Bounded remember (insertion-order evict). 修2 scope: the grant is keyed by THIS ask's own capability
2117
2259
  // category — a Write/Edit-family ask unlocks the fs-write family for the (owner, session); ANY other tool's
2118
2260
  // allow_session unlocks only that same tool. A cheap non-write ask can therefore never become a session-wide