@sema-agent/server 7.35.1 → 7.36.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 (53) hide show
  1. package/README.zh-CN.md +1 -1
  2. package/USAGE.md +10 -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 +17 -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 +18 -5
  31. package/dist/http/server.js +5 -1
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.js +14 -0
  34. package/dist/main.js +17 -28
  35. package/dist/memory-posture.d.ts +14 -1
  36. package/dist/memory-posture.js +2 -0
  37. package/dist/observability/fail-open.d.ts +12 -0
  38. package/dist/observability/fail-open.js +12 -0
  39. package/dist/parked-decide.d.ts +3 -1
  40. package/dist/parked-decide.js +34 -11
  41. package/dist/rules-consent.d.ts +20 -0
  42. package/dist/rules-consent.js +21 -0
  43. package/dist/security.d.ts +22 -0
  44. package/dist/security.js +24 -0
  45. package/dist/shared-memory-scope-authorizer.d.ts +36 -3
  46. package/dist/shared-memory-scope-authorizer.js +19 -4
  47. package/dist/store-live-probe.d.ts +26 -0
  48. package/dist/store-live-probe.js +60 -0
  49. package/dist/task-cwd.d.ts +16 -3
  50. package/dist/task-cwd.js +16 -3
  51. package/dist/tool-approval.d.ts +61 -2
  52. package/dist/tool-approval.js +159 -17
  53. package/package.json +2 -2
@@ -137,6 +137,26 @@ export interface RuleImportLayerInput {
137
137
  * `project:/a:b` 悄悄截成 `/a`,那是一条**放宽面**上的静默改写(更短的 root 覆盖更多 cwd)。
138
138
  */
139
139
  export declare function serializeRuleScope(scope: RuleScope): string;
140
+ /**
141
+ * 🔴 判别式串的**上限,由铸侧的尺派生**(A-054.24,2026-08-19 合并重扫 confirmed —— 修的是「两个数字
142
+ * 各挑各的」这件事本身,不是把某个数字调大一点)。
143
+ *
144
+ * 病:铸侧 `cardRuleScopeRoot` 对 root 长度零把关(入口尺 = `isValidCwd` 的 {@link MAX_CWD_CHARS}),
145
+ * core 与两个店都只校验 `root.min(1)` ⇒ 写面全程无阻;而 `DELETE /v1/rules` 的体 schema 此前独立写死
146
+ * `scope: max(1088)`,且 safeParse 排在 `parseRuleScope`/权限判据**之前** ⇒ 超限 root 的规则
147
+ * `GET /v1/rules` **列得出**、按同一串 DELETE **恒 400**。全仓只有一个 `lane.removeRule` 调用点(直删
148
+ * 店行是无墓碑硬删、会被 sync 的 join 复活),所以那是**一条已列出的常驻放行规则在受支持接口上不可撤**;
149
+ * 唯一的钝器补偿是 `PERMISSION_RULES_ENABLED=false`(关掉全部规则,两口自身随之 501)。
150
+ *
151
+ * ⚠️ 可达性在 A-054.F-A 之后**变严重**:那批把铸侧出口从 `realpath` 改成 `path.resolve`,而 realpath
152
+ * 要求目录真实存在(macOS PATH_MAX=1024 ⇒ 宿主上 root 恒 ≤1023,结构性免疫);`path.resolve` 纯词法、
153
+ * 不碰盘 ⇒ **任何宿主上** 1081..4096 字符的注册 cwd 都能铸出这样一条规则。
154
+ *
155
+ * 取值 = `"project:"` 前缀 + 铸侧 root 的入口尺。`path.resolve` 对「已绝对、无 `..` 段」的路径不加长
156
+ * (`isValidCwd` 两条都要求),故 {@link MAX_CWD_CHARS} 是 root 的真上界。**两侧从此同源**:谁改铸侧
157
+ * 的尺,撤侧自动跟。
158
+ */
159
+ export declare const MAX_RULE_SCOPE_CHARS: number;
140
160
  /** {@link serializeRuleScope} 的逆。读不出形 ⇒ `undefined`(调用方 400,绝不猜一个 global 出来 ——
141
161
  * 猜 global 会把一次「删项目内规则」的请求变成一次删不掉任何东西的 no-op,或者更糟)。 */
142
162
  export declare function parseRuleScope(text: string): RuleScope | undefined;
@@ -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
  }
@@ -11,6 +11,8 @@
11
11
  * - 翻转披露(§M):live→dead warn `store_probe_dead`、dead→live info `store_probe_recovered`,
12
12
  * 只在翻转拍记(连续死不逐拍刷日志——down 的 DB 不该制造日志风暴);
13
13
  * - 诚实缺席:首拍落地前 state() = undefined(/health 键缺席=「还没探过」,不冒充「活」)。
14
+ * - 面分级(#305):`state().error` 是**原始** message —— 它只喂给凭证后/本机的消费面;免鉴权的
15
+ * `/health` 一格由 wire 装配点过 {@link publicStoreProbeError} 换成闭集词(理由见该常量头注)。
14
16
  *
15
17
  * 探针原语:调用方给 `probe`(main.ts 传 `() => backend.dbNowMs!()`——既有 S10 时钟探针的真 DB
16
18
  * 往返,零新 SQL 面)。timer 全部 unref(环不得阻止进程退出)。
@@ -28,6 +30,30 @@ export interface StoreLiveProbe {
28
30
  state: () => StoreLiveState | undefined;
29
31
  stop: () => void;
30
32
  }
33
+ /**
34
+ * #305(安全轴):`/health` 免鉴权面上 `storeProbe.error` 可披露的**闭集词表**。
35
+ *
36
+ * 为什么必须是闭集而不是「脱敏后的原文」:`/health` 排在 `http/server.ts` 的鉴权门**之前**、缺省全网卡
37
+ * 监听 ⇒ 任何能连到这个端口的人都读得到这一格;而它此前装的是**驱动原始异常 message 逐字**,DB 驱动
38
+ * 的连接类异常惯于把整条 DSN 回声进去(`mysql://user:pass@host:3306/db`)或带上内网主机名/账号名。
39
+ * `trace/redact.ts` 的 `redactSecrets` **挡不住这条**:它的 URL-userinfo 规则只把口令换成 `«redacted»`,
40
+ * **用户名与 `@` 之后的主机原样留下** —— 而本条要防的正是那两个。闭集词是严格更强的形(值恒为下列
41
+ * 字面量之一,构造上不可能携带调用方文本),因此不再叠加脱敏/长度帽:对一个恒为字面量的返回值,那两道
42
+ * 都只是装饰。
43
+ *
44
+ * 信息不丢:**完整原文走日志轴** —— 环在 live→dead 翻转拍打的 `store_probe_dead` warn 带 `{ error }`
45
+ * 原文,运维在自己的日志/采集里读得到全文,那一面本来就是凭证后的。
46
+ */
47
+ export declare const STORE_PROBE_PUBLIC_PHRASES: readonly ["probe_timeout", "connection_refused", "connection_reset", "host_unreachable", "dns_failure", "network_timeout", "auth_failed", "connection_limit", "probe_failed"];
48
+ export type StoreProbePublicPhrase = (typeof STORE_PROBE_PUBLIC_PHRASES)[number];
49
+ /**
50
+ * 原始探针异常 message → 免鉴权面可披露的闭集词。**返回值恒是 {@link STORE_PROBE_PUBLIC_PHRASES} 的
51
+ * 一个字面量**(返回类型即闭集,写错一个词是编译错误),永不回声入参。
52
+ *
53
+ * 误分类的代价是「运维读到的分类不精确」,**不是泄露** —— 所以这里的判别用宽松的子串包含即可,不必为
54
+ * 精确性把原文碎片带出来。臂序有意义:自家的 race 超时先于泛化的 timeout 臂。
55
+ */
56
+ export declare function publicStoreProbeError(raw: string): StoreProbePublicPhrase;
31
57
  export declare function createStoreLiveProbe(opts: {
32
58
  probe: () => Promise<unknown>;
33
59
  intervalMs: number;
@@ -11,12 +11,72 @@
11
11
  * - 翻转披露(§M):live→dead warn `store_probe_dead`、dead→live info `store_probe_recovered`,
12
12
  * 只在翻转拍记(连续死不逐拍刷日志——down 的 DB 不该制造日志风暴);
13
13
  * - 诚实缺席:首拍落地前 state() = undefined(/health 键缺席=「还没探过」,不冒充「活」)。
14
+ * - 面分级(#305):`state().error` 是**原始** message —— 它只喂给凭证后/本机的消费面;免鉴权的
15
+ * `/health` 一格由 wire 装配点过 {@link publicStoreProbeError} 换成闭集词(理由见该常量头注)。
14
16
  *
15
17
  * 探针原语:调用方给 `probe`(main.ts 传 `() => backend.dbNowMs!()`——既有 S10 时钟探针的真 DB
16
18
  * 往返,零新 SQL 面)。timer 全部 unref(环不得阻止进程退出)。
17
19
  */
18
20
  /** 单拍探针上界——挂死连接的判死时间;3s 对 15s 默认间隔留足余量(环内串行,无叠拍)。 */
19
21
  export const STORE_PROBE_TIMEOUT_MS = 3000;
22
+ /**
23
+ * #305(安全轴):`/health` 免鉴权面上 `storeProbe.error` 可披露的**闭集词表**。
24
+ *
25
+ * 为什么必须是闭集而不是「脱敏后的原文」:`/health` 排在 `http/server.ts` 的鉴权门**之前**、缺省全网卡
26
+ * 监听 ⇒ 任何能连到这个端口的人都读得到这一格;而它此前装的是**驱动原始异常 message 逐字**,DB 驱动
27
+ * 的连接类异常惯于把整条 DSN 回声进去(`mysql://user:pass@host:3306/db`)或带上内网主机名/账号名。
28
+ * `trace/redact.ts` 的 `redactSecrets` **挡不住这条**:它的 URL-userinfo 规则只把口令换成 `«redacted»`,
29
+ * **用户名与 `@` 之后的主机原样留下** —— 而本条要防的正是那两个。闭集词是严格更强的形(值恒为下列
30
+ * 字面量之一,构造上不可能携带调用方文本),因此不再叠加脱敏/长度帽:对一个恒为字面量的返回值,那两道
31
+ * 都只是装饰。
32
+ *
33
+ * 信息不丢:**完整原文走日志轴** —— 环在 live→dead 翻转拍打的 `store_probe_dead` warn 带 `{ error }`
34
+ * 原文,运维在自己的日志/采集里读得到全文,那一面本来就是凭证后的。
35
+ */
36
+ export const STORE_PROBE_PUBLIC_PHRASES = [
37
+ "probe_timeout",
38
+ "connection_refused",
39
+ "connection_reset",
40
+ "host_unreachable",
41
+ "dns_failure",
42
+ "network_timeout",
43
+ "auth_failed",
44
+ "connection_limit",
45
+ /** 未识别臂 —— **刻意的**:分类不认得的异常一律丢原文取本词(fail-closed 的默认方向是「少说」,
46
+ * 不是「截断了再说」;截断的前 160 字恰恰是驱动最爱放 DSN 的位置)。 */
47
+ "probe_failed",
48
+ ];
49
+ /**
50
+ * 原始探针异常 message → 免鉴权面可披露的闭集词。**返回值恒是 {@link STORE_PROBE_PUBLIC_PHRASES} 的
51
+ * 一个字面量**(返回类型即闭集,写错一个词是编译错误),永不回声入参。
52
+ *
53
+ * 误分类的代价是「运维读到的分类不精确」,**不是泄露** —— 所以这里的判别用宽松的子串包含即可,不必为
54
+ * 精确性把原文碎片带出来。臂序有意义:自家的 race 超时先于泛化的 timeout 臂。
55
+ */
56
+ export function publicStoreProbeError(raw) {
57
+ const t = raw.toLowerCase();
58
+ if (t.includes("probe timed out"))
59
+ return "probe_timeout"; // 本环自铸的 race 超时(见 probeOnce)
60
+ if (t.includes("econnrefused") || t.includes("connection refused"))
61
+ return "connection_refused";
62
+ if (t.includes("econnreset") || t.includes("connection reset"))
63
+ return "connection_reset";
64
+ if (t.includes("ehostunreach") || t.includes("enetunreach") || t.includes("enetdown"))
65
+ return "host_unreachable";
66
+ if (t.includes("enotfound") || t.includes("eai_again") || t.includes("getaddrinfo"))
67
+ return "dns_failure";
68
+ if (t.includes("etimedout") || t.includes("timed out") || t.includes("timeout"))
69
+ return "network_timeout";
70
+ // mysql `ER_ACCESS_DENIED_ERROR` / pg `28P01 password authentication failed` / 通用 auth 措辞。
71
+ if (t.includes("access denied") || t.includes("authentication failed") || t.includes("28p01") || t.includes("er_access_denied") || t.includes("er_dbaccess_denied")) {
72
+ return "auth_failed";
73
+ }
74
+ // mysql `ER_CON_COUNT_ERROR` / pg `53300 sorry, too many clients already` / 池耗尽措辞。
75
+ if (t.includes("too many connections") || t.includes("too many clients") || t.includes("53300") || t.includes("er_con_count_error") || t.includes("connection slots")) {
76
+ return "connection_limit";
77
+ }
78
+ return "probe_failed";
79
+ }
20
80
  export function createStoreLiveProbe(opts) {
21
81
  const timeoutMs = opts.timeoutMs ?? STORE_PROBE_TIMEOUT_MS;
22
82
  let last;
@@ -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`