@sema-agent/server 7.12.0 → 7.13.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 (51) hide show
  1. package/USAGE.md +30 -2
  2. package/dist/adoption/plan.d.ts +38 -4
  3. package/dist/adoption/plan.js +72 -0
  4. package/dist/adoption/quiesce.d.ts +70 -0
  5. package/dist/adoption/quiesce.js +148 -0
  6. package/dist/adoption/runner.js +63 -5
  7. package/dist/adoption/sql.d.ts +15 -0
  8. package/dist/adoption/sql.js +18 -0
  9. package/dist/adoption/wire.d.ts +7 -1
  10. package/dist/adoption/wire.js +6 -0
  11. package/dist/approval-card.d.ts +5 -0
  12. package/dist/approval-card.js +22 -0
  13. package/dist/boot/permission-rules-audit.js +29 -1
  14. package/dist/boot/reapers.d.ts +15 -0
  15. package/dist/boot/reapers.js +101 -44
  16. package/dist/boot/runner-deps.d.ts +16 -2
  17. package/dist/boot/runner-deps.js +5 -4
  18. package/dist/config-types.d.ts +14 -2
  19. package/dist/http/active-run-conflict.d.ts +33 -8
  20. package/dist/http/active-run-conflict.js +37 -2
  21. package/dist/http/routes/adoption.js +25 -2
  22. package/dist/http/routes/approvals-assistant.js +33 -3
  23. package/dist/http/routes/capabilities.js +25 -1
  24. package/dist/http/routes/images.js +18 -0
  25. package/dist/http/routes/runs.js +21 -5
  26. package/dist/http/routes/tasks.js +18 -6
  27. package/dist/http/server.d.ts +26 -8
  28. package/dist/http/server.js +102 -16
  29. package/dist/main.js +46 -6
  30. package/dist/observability/fail-open.d.ts +4 -0
  31. package/dist/observability/fail-open.js +4 -0
  32. package/dist/plugins/adoption-log-sql.d.ts +40 -0
  33. package/dist/plugins/adoption-log-sql.js +69 -2
  34. package/dist/plugins/file-run-store.d.ts +85 -1
  35. package/dist/plugins/file-run-store.js +450 -17
  36. package/dist/plugins/permission-rule-store-sql.d.ts +45 -0
  37. package/dist/plugins/permission-rule-store-sql.js +60 -2
  38. package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
  39. package/dist/plugins/shared-memory-store-sql.js +55 -18
  40. package/dist/plugins/sql-driver.d.ts +19 -0
  41. package/dist/plugins/sql-driver.js +12 -0
  42. package/dist/plugins/store-backend.js +24 -1
  43. package/dist/rules-consent.d.ts +33 -4
  44. package/dist/rules-consent.js +43 -2
  45. package/dist/run-local.js +6 -2
  46. package/dist/runtime-governance.d.ts +33 -0
  47. package/dist/runtime-governance.js +32 -0
  48. package/dist/tool-approval.d.ts +32 -0
  49. package/dist/tool-approval.js +20 -0
  50. package/dist/trace/core-keyset-guard.d.ts +1 -1
  51. package/package.json +3 -3
@@ -13,17 +13,37 @@
13
13
  * (`gate_not_tool_approval`/`gate_not_resumable`/`wake.gate_pending` 的指路句)。三处文案与本表若漂移,
14
14
  * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
15
15
  */
16
+ import { type Autonomy } from "../runtime-governance.js";
17
+ /** #220 归因的部署侧入参(两个治理旋钮;判据属主见 `governanceMandatesShellGateAlways`)。 */
18
+ export interface GovernancePosture {
19
+ autonomy?: Autonomy;
20
+ manualModeShellGate?: "always" | "classify";
21
+ }
16
22
  /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
17
23
  export declare const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run \u2014 POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
24
+ /**
25
+ * 409 体(与 SSE done 帧 result 共用)里的**待批门材料**。
26
+ *
27
+ * `kind`/`decidePath` = 出路(去哪决议);#220 追加的 `governanceForced` = **出身**(这道门是谁下的)——
28
+ * [3513] T3「park 后待批通道事件」的读者此前只知道「有一道 X 型门」,不知道它是运维治理层强制的还是
29
+ * 别的来源,于是「我都开了 bypassPermissions 为什么还在问」这个 UX 缺口在 park 腿上原样复现
30
+ * ([3031]#1/[3038] 在活卡腿上的实证)。
31
+ *
32
+ * 🔴 **additive + 只在为真时在场**:与活卡帧 `ToolApprovalFrame.governanceForced` **同一条纪律**——
33
+ * 缺席 ≠「这不是治理门」,而是「没有治理来源的证据」(判据够不着的形也落在缺席里,见装配处的注)。
34
+ */
35
+ export interface PendingGateMaterial {
36
+ kind: string;
37
+ decidePath: string;
38
+ /** 见 {@link PendingGateMaterial} 顶注:`true` 才在场,恒不写 `false`。 */
39
+ governanceForced?: true;
40
+ }
18
41
  export interface ActiveRunConflictBody {
19
42
  error: string;
20
43
  errorCode: "conflict.session_active_run";
21
44
  activeTaskId: string | null;
22
45
  activeTaskStatus?: string;
23
- pendingGate?: {
24
- kind: string;
25
- decidePath: string;
26
- };
46
+ pendingGate?: PendingGateMaterial;
27
47
  }
28
48
  /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
29
49
  export declare function resumeEntryForGate(kind: string, ids: {
@@ -43,10 +63,7 @@ export interface ActiveRunConflictDoneResult {
43
63
  errorMessage: string;
44
64
  activeTaskId: string | null;
45
65
  activeTaskStatus?: string;
46
- pendingGate?: {
47
- kind: string;
48
- decidePath: string;
49
- };
66
+ pendingGate?: PendingGateMaterial;
50
67
  }
51
68
  export declare function toDoneFrameResult(body: ActiveRunConflictBody): ActiveRunConflictDoneResult;
52
69
  interface ConflictProbeDeps<TToken> {
@@ -58,12 +75,20 @@ interface ConflictProbeDeps<TToken> {
58
75
  checkpointStore?: {
59
76
  peekPendingScope?: (sessionId: string) => Promise<string | null | undefined>;
60
77
  findPendingTokenBySession?: (sessionId: string, scope?: string) => Promise<TToken | null | undefined>;
78
+ /** 结构形(不 import core 的 `Checkpoint`):只声明本模块**真读**的两格。`riskDescriptor` 是
79
+ * core 在 mint 时挂到升级门上的判读元数据(display/triage only),`shellGateDoctrine` 是 2026-08-05
80
+ * 裁定加的取证格 —— 见下方 `governanceOriginOf` 的推导论证。 */
61
81
  get?: (token: TToken) => Promise<{
62
82
  gate?: {
63
83
  kind?: string;
84
+ riskDescriptor?: {
85
+ shellGateDoctrine?: "classify" | "always";
86
+ };
64
87
  };
65
88
  } | null | undefined>;
66
89
  } | undefined;
90
+ /** #220:出身归因的部署侧一半(缺席 ⇒ 归不出治理出身 ⇒ 键缺席 —— 与 best-effort 同方向)。 */
91
+ governance?: GovernancePosture | undefined;
67
92
  }
68
93
  export declare function buildActiveRunConflict<TToken>(deps: ConflictProbeDeps<TToken>, sessionId: string, activeTaskId: string | null | undefined): Promise<ActiveRunConflictBody>;
69
94
  export {};
@@ -13,6 +13,7 @@
13
13
  * (`gate_not_tool_approval`/`gate_not_resumable`/`wake.gate_pending` 的指路句)。三处文案与本表若漂移,
14
14
  * test/session-active-conflict-materials.test.ts 的分门用例会红。认不出的 kind ⇒ null(诚实缺席,不铸假门)。
15
15
  */
16
+ import { governanceMandatesShellGateAlways } from "../runtime-governance.js";
16
17
  /** 与 runs.ts/tasks.ts 三个 409 位共享的旧文案(byte-frozen:api-error-text-freeze 门认这句)。 */
17
18
  export const ACTIVE_RUN_CONFLICT_BASE_TEXT = "session already has an active run — POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)";
18
19
  /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
@@ -39,6 +40,37 @@ export function resumeEntryForGate(kind, ids) {
39
40
  return null; // 未知门型:宁缺毋假 —— 客户端仍有 activeTaskStatus + cancel 这条保底真路
40
41
  }
41
42
  }
43
+ /**
44
+ * #220:推「这道门是运维治理层下的吗」。判据是**合取**,两半各管一件事:
45
+ *
46
+ * 行上的取证格 `gate.riskDescriptor.shellGateDoctrine === "always"`
47
+ * ∧ 本部署的治理层本来就要求 always(`governanceMandatesShellGateAlways`,单一判据属主在
48
+ * `runtime-governance.ts`,那里写着为什么单看行不够 —— SUP 路由姿态同样产 `"always"`)
49
+ *
50
+ * 第一半:core 在 mint 这道门时把「当时活着的 doctrine」如实写进 `riskDescriptor.shellGateDoctrine`
51
+ * (2026-08-05 取证裁定;`prepare-task` 只在**被 shell 门铸**的 ask 上写它),所以持久行自己带着证据 ——
52
+ * park 腿天然跨副本、跨重启,活卡腿那张 ALS 标记表在这里结构上够不着。
53
+ * 第二半:把「有效档 always」收窄到「治理层是真成因」,否则一条 SUP 路由出来的门会被谎报成治理强制。
54
+ *
55
+ * ⚠️ 残留(双向失真的窄在场向 + 根治路径)登记在 `governanceMandatesShellGateAlways` 的顶注,别在这里
56
+ * 复述;本位只承担「分诊提示」的分量,不参与任何门/CAS/resume 判定。
57
+ *
58
+ * 🔴 `"classify"` 档**刻意不标**:那一档由 core 的分类器逐调用裁决,一次 shell ask 可能出自分类器(治理)
59
+ * 也可能出自 `APPROVAL_REQUIRE` 这类别的门,行上无从分辨 —— 与活卡腿同一条「宁缺毋假」纪律。
60
+ *
61
+ * ⚠️ **askId 为什么不在这里**(#220 的另一半,如实记账):待批 ask 的 `askId` 只活在 `approval_ask` 行上
62
+ * (checkpoint 行没有这一列,core 的 `CheckpointGate`/`PendingAction`/`Checkpoint` 顶层都不带它)。反向
63
+ * (checkpoint → ask)今天**没有读口**:两条 list 读口都硬过滤 `state='STREAM_PENDING'`,而 park 完的行是
64
+ * `PARKED`;唯一精确的连接键是 ask 行上的 `gate_token`,要按它反查得给店加一个新读口(SQL 面 = 双库集成
65
+ * 门)。派生 `deriveAskId(...)` 也不行:它吃 `legKey`(= 上一腿 resume token 的摘要)与 `parentToolCallId`,
66
+ * 这两维从 checkpoint 行推不出来,二腿/子代形上会算出一个**错**的 id —— 在「等的谁」这条通道上,错 id
67
+ * 比缺席坏得多(本文件 `resumeEntryForGate` 的 default 臂是同一条纪律)。故本批诚实缺席。
68
+ */
69
+ function governanceOriginOf(gate, governance) {
70
+ if (gate?.riskDescriptor?.shellGateDoctrine !== "always")
71
+ return undefined;
72
+ return governanceMandatesShellGateAlways(governance ?? {}) ? true : undefined;
73
+ }
42
74
  export function toDoneFrameResult(body) {
43
75
  return {
44
76
  status: "failed",
@@ -75,10 +107,13 @@ export async function buildActiveRunConflict(deps, sessionId, activeTaskId) {
75
107
  if (cs?.findPendingTokenBySession) {
76
108
  const scope = (await cs.peekPendingScope?.(sessionId)) ?? undefined;
77
109
  const token = scope === null ? null : await cs.findPendingTokenBySession(sessionId, scope);
78
- const kind = token ? (await cs.get?.(token))?.gate?.kind : undefined;
110
+ const gate = token ? (await cs.get?.(token))?.gate : undefined;
111
+ const kind = gate?.kind;
79
112
  const decidePath = kind ? resumeEntryForGate(kind, { sessionId, taskId: activeTaskId }) : null;
113
+ // #220:出身格与出路格**同一次读**里取(不为它多打一次库),`true` 才写键(见 PendingGateMaterial 顶注)。
114
+ const governanceForced = governanceOriginOf(gate, deps.governance);
80
115
  if (kind && decidePath)
81
- pendingGate = { kind, decidePath };
116
+ pendingGate = { kind, decidePath, ...(governanceForced ? { governanceForced } : {}) };
82
117
  }
83
118
  return {
84
119
  ...base,
@@ -1,6 +1,6 @@
1
1
  import { createAdoptionRunner } from "../../adoption/runner.js";
2
2
  import { AdoptionRequestSchema } from "../../adoption/wire.js";
3
- import { checkAdoptionWidths } from "../../adoption/plan.js";
3
+ import { checkAdoptionPrincipalShape, checkAdoptionWidths } from "../../adoption/plan.js";
4
4
  import { sendJson, sendError } from "../send.js";
5
5
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
6
6
  const ADOPTION_ID_RE = /^\/v1\/adoption\/([^/]+)$/;
@@ -21,6 +21,11 @@ function sendOutcome(res, out) {
21
21
  return;
22
22
  // 闭集穷举:`AdoptionRejectCode` 加一个成员而这里没加臂 ⇒ **编译红**(拒绝码是 wire 契约,
23
23
  // 不许有一个走到默认臂的静默码)。
24
+ //
25
+ // 🔴 A-010.19(验真后修):上面这句话此前是**一句自我声明,不是一道门**。内层 switch 只有两条 case
26
+ // 而结尾是一个裸 `return;` —— 加第三个拒绝码时 TS 一个字都不会说,请求会走到那个裸 return、
27
+ // **一个字节都不写**就把响应扔在那里:调用方看到的是一条挂住的连接直到超时,而不是任何错误。
28
+ // 现在由下面的 `never` 断言真正兑现它:少一条臂 ⇒ `out` 收窄不到 `never` ⇒ 赋值失败 ⇒ 编译红。
24
29
  case "rejected":
25
30
  switch (out.code) {
26
31
  case "adoption.destination_conflict":
@@ -37,9 +42,19 @@ function sendOutcome(res, out) {
37
42
  });
38
43
  return;
39
44
  }
40
- return;
45
+ // 穷举证明:走到这里 `out.code` 必须已经收窄成 `never`。加一个 `AdoptionRejectCode` 成员而不加臂
46
+ // ⇒ 这一行编译红,红在**加码的那一刻**,而不是在生产上表现为一条永不应答的请求。
47
+ return assertNoRejectCodeLeft(out.code);
41
48
  }
42
49
  }
50
+ /** 闭集穷举的落点。**入参类型是 `never`** —— 这就是那道门本身;函数体永远跑不到,写一句响亮抛是为了
51
+ * 「万一有人用 `as` 把一个未知码塞进来」时仍然 fail-loud(不是静默挂住连接)。
52
+ * ⚠️ 形参**刻意不叫 `code`**:`error-code-key-gate` 按 `code:` 的字面形扫全树错误体(3.0.0 起
53
+ * `errorCode` 是唯一机器判别键),一个叫 `code` 的形参会被它当成一处 legacy 键站点。名字换掉比放宽
54
+ * 那道门便宜得多 —— 门宽一分,真站点就可能溜过去。 */
55
+ function assertNoRejectCodeLeft(unhandled) {
56
+ throw new Error(`adoption: unhandled reject code ${JSON.stringify(unhandled)} — the wire contract's closed set and this switch have drifted apart`);
57
+ }
43
58
  export async function handleAdoption(req, res, url, ctx) {
44
59
  const miss = { fell: false };
45
60
  await handleAdoptionBody(req, res, url, ctx, miss);
@@ -101,6 +116,14 @@ async function handleAdoptionBody(req, res, url, ctx, miss) {
101
116
  return;
102
117
  }
103
118
  const { fromPrincipal, toPrincipal } = parsed.data;
119
+ // 🔴 键空间卫生在**列宽之前**(A-010.15):带首尾空白的身份在两方言上的唯一键语义不等价
120
+ // (MySQL utf8mb4_bin 是 PAD SPACE),而它在本仓根本不是一个可能存在的真身份 —— 详见该函数头注。
121
+ // 排在列宽前面的理由与列宽自己那条次序判据同族:两条都能命中时,报**更根本**的那一个。
122
+ const badShape = checkAdoptionPrincipalShape(fromPrincipal, toPrincipal);
123
+ if (badShape !== undefined) {
124
+ sendError(res, 400, "request.field_invalid", "the adoption principals are not in a portable identity shape", { detail: badShape });
125
+ return;
126
+ }
104
127
  // 🔴 列宽校验在**铸行之前**(codex R1-F3):合法长度的 principal 也可能装不进最窄的那根被写列,
105
128
  // 而那种失败若发生在腿的中途,phase 会永远停在 INTENT —— 每次重发和每次 boot 扫描都再撞一次同样的墙。
106
129
  const tooWide = checkAdoptionWidths(fromPrincipal, toPrincipal);
@@ -1,7 +1,7 @@
1
1
  import { principalFrom, decodeCheckpointScope, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../../security.js";
2
2
  import { verifyDirectDoorProof } from "../../principal-jwt.js";
3
3
  import { MAX_APPROVAL_REASON_CHARS } from "../../approval-hmac.js";
4
- import { redactedPreview } from "../../trace/redact.js";
4
+ import { redactedPreview, redactSecrets } from "../../trace/redact.js";
5
5
  import { fleetRunLabels } from "../../fleet/fleet-bus.js"; // [2069]④ §3 行展示名与 fleet 行同源(见用处的注)
6
6
  import { sleep } from "../sse-log.js";
7
7
  import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
@@ -68,7 +68,9 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
68
68
  const scope = operator
69
69
  ? (new URL(req.url ?? "", "http://x").searchParams.get("owner") ?? undefined) // operator: all (or ?owner)
70
70
  : (principal ?? "__none__"); // non-operator: only its own scope (never others' pending)
71
- sendJson(res, 200, { pending: await cs.listPending(scope) });
71
+ // #209 件4:行整只上 wire(键集契约不变),但 `riskDescriptor.shadowedRule` 这一格先脱敏 ——
72
+ // 理由与 `/v1/approvals/stream` 共用同一个投影函数,见 `redactPendingDisclosures` 顶注。
73
+ sendJson(res, 200, { pending: (await cs.listPending(scope)).map(redactPendingDisclosures) });
72
74
  return;
73
75
  }
74
76
  // exemptions surface — the UI's "本会话不再询问" state (list) + revoke. Same authz shape as
@@ -659,6 +661,32 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
659
661
  * connection runs its OWN poll loop — fine for a handful of operators; a shared fan-out poll is a future
660
662
  * optimization if the operator count grows.) */
661
663
  const APPROVALS_STREAM_POLL_MS = 3000;
664
+ /**
665
+ * 🔴 #209 件4(codex 对抗复审 R3-[high],验真后修)—— **durable 读面的脱敏投影**。
666
+ *
667
+ * core 5.25.0 的 `RiskDescriptor.shadowedRule` = 被越级的那条持久规则的**原文**。core 侧只过
668
+ * `inlineUntrusted`(中和 + 限长),**不**做秘密脱敏 —— 它的 JSDoc 也只承诺中和,不承诺无秘密
669
+ * (与同结构里的 `summary` 不同:那一格 core 明写「NEVER raw secrets / full args / env」)。
670
+ * 而规则原文是**人手写的文本**,一条 `Bash(curl -H "Authorization: Bearer …")` 形的规则完全拼得出来。
671
+ *
672
+ * 同步路(`tool_approval` 帧 / `card_json`)在铸点就 `redactSecrets` 了;durable 路两条读面
673
+ * (`GET /v1/approvals` 与 `/v1/approvals/stream`)此前把 `listPending()` 的行**整只**上 wire ⇒
674
+ * 同一份内容在两条腿上一条脱敏、一条不脱敏,而 operator 的队列是**跨租户**可见的那一条。
675
+ *
676
+ * 修在**读面**而不是落库点,理由是本仓成文的分工(同文件 inbox 段那条 `redactedPreview(s.toolInput)` 的
677
+ * 逐字理由):「core bounds but does NOT redact it — redaction is the consumer's job」。落库点改写会与
678
+ * core 的属主面打架,而且救不了**别的副本**已经写下的行。
679
+ *
680
+ * 🔴 只动这一格,不顺手洗整只 descriptor:`summary` 有 core 的无秘密承诺、`touchedPaths` 是路径,
681
+ * 两者的现行为不在本批辖域内(要改得走各自的三问)。键集**不变** —— 「整只透传、不按键投影」那条契约
682
+ * (`test/approval-shadowed-rule-wire.test.ts` §3)照旧成立,变的只是这一格的**内容**。
683
+ */
684
+ function redactPendingDisclosures(row) {
685
+ const rd = row.riskDescriptor;
686
+ if (rd === null || rd === undefined || typeof rd.shadowedRule !== "string")
687
+ return row;
688
+ return { ...row, riskDescriptor: { ...rd, shadowedRule: redactSecrets(rd.shadowedRule) } };
689
+ }
662
690
  /**
663
691
  * GET /v1/approvals/stream (design/80 native push): SSE — pushes pending-approval deltas so the portal
664
692
  * SUBSCRIBES ONCE instead of polling GET /v1/approvals every ~10s (better UX: near-real-time + no client poll
@@ -696,7 +724,9 @@ export async function streamApprovals(req, res, cs, scope, pollMs = APPROVALS_ST
696
724
  while (!closed) {
697
725
  let pending;
698
726
  try {
699
- pending = await cs.listPending(scope);
727
+ // #209 件4:与 `GET /v1/approvals` **同一个**投影函数 —— 两条 durable 读面各洗各的就会漂
728
+ // (SSE 那条恰恰是 operator 常驻订阅的那条,漏掉它等于没修)。
729
+ pending = (await cs.listPending(scope)).map(redactPendingDisclosures);
700
730
  }
701
731
  catch {
702
732
  // a transient TiDB blip must NOT kill the subscription — heartbeat + retry next tick (fail-soft)
@@ -3,6 +3,7 @@ import { cwdHonored } from "../../task-cwd.js";
3
3
  import { mcpInjectionHonored } from "../../task-mcp.js";
4
4
  import { sendJson, sendError } from "../send.js";
5
5
  import { resolveStreamApprovalGate } from "../../tool-approval.js";
6
+ import { gatedPrincipal } from "../principal-gate.js";
6
7
  export async function handleCapabilities(req, res, url, ctx) {
7
8
  const miss = { fell: false };
8
9
  await handleCapabilitiesBody(req, res, url, ctx, miss);
@@ -75,6 +76,14 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
75
76
  : false;
76
77
  })(),
77
78
  leader: Boolean(deps.leaderEndpoint),
79
+ // #F1([3397]-5 同族):sandbox-image-pool 的 P1 面(`GET /v1/images`、`/v1/images/:profile`、
80
+ // `/v1/images/digests/:digest`、`POST /v1/images/select`)。谓词**逐字**是 routes/images.ts 里那条
81
+ // 挂载合取式的 `deps.imageIndex` —— 缺席时该域整个不挂载,同文件的能力臂回 501
82
+ // `capability.image_index_required`(此前是与拼错路由同码的 404,消费端只能 trial-by-404)。
83
+ // local 后端刻意不实现 `imageIndex()`(池是 cloud/fleet-only),所以单机形上这一位诚实为 false。
84
+ // ⚠️ 辖域**只到 P1**:`/v1/images/bakes*`(P2 烤制控制面)由独立的 `imageBakes` 依赖挂载,
85
+ // 且是 operator/runner 内部面,不在本位的承诺里。
86
+ images: Boolean(deps.imageIndex),
78
87
  // design/138 S1 (clay 2026-07-08): the legacy MemoryStore surface (GET/DELETE /v1/memory + the MF-30
79
88
  // session write verbs) was DEPRECATED with the store itself — the new memory engine is model file skills
80
89
  // over the injected memory dir, with NO service HTTP verbs. Hard-coded false so a shell that still knows
@@ -103,7 +112,22 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
103
112
  // #154 车二 + #203 §2:规则车道(cc-import 两口 + 撤销面两口 + respond 的 persistRule 兑付口)。
104
113
  // 谓词与 `/v1/rules/*` 全族的 501 同源:ruleConsent lane 在场 ⟺ 总开关未被显式关 ∧ 规则店 wired
105
114
  // (SQL 双方言,或 #203 起 local 的 File 三面束)。说 yes ⟺ **四口都活**,不是「有一口活」。
106
- permissionRules: Boolean(deps.ruleConsent),
115
+ //
116
+ // 🔴 A-010.20(验真后修,补第二条合取项):店在场**不等于**这四口活。规则车道是
117
+ // **principal 寻址**的 —— `routes/rules.ts` 的域头逐字是 `if (principal === undefined) 401`
118
+ // (无条件,与 `requirePrincipal` 无关),而铸卡侧 `buildRuleLaneMaterial` 与回决侧
119
+ // `persistRuleAfterDecision` 都有同源的 `owner === null ⇒ 不进车道 / rule_lane_unavailable` 一门。
120
+ // 于是「装了店、但调用方没有可验证身份」的部署上,旧谓词报 true 而**四口全废**:两口恒 401、
121
+ // 卡上恒无候选、`persistRule` 恒被拒。#203 给 local 车道接上 File 店之后,「单机 + 无 principal」
122
+ // 第一次同时满足其余条件 —— 这不再是纸面形。
123
+ //
124
+ // 🔴 为什么这一位是**按调用方**算而不是按部署算:`requirePrincipal=false` 的部署上,带 principal
125
+ // 的调用方与不带的调用方**答案真的不同**(前者四口全活,后者全废),没有任何一个部署级布尔能同时
126
+ // 对两者不说谎。`/v1/capabilities` 是每请求读面且与 `/v1/rules` 走同一道全局凭证门,所以
127
+ // 「这一位说 yes」⟺「**你**拿同一份头去打那四口真的能用」——这正是本文件顶注承诺的那条等价。
128
+ // ⚠️ 与下面的 `permissionRulesRevoke` **刻意不同源**:那一位是**存在性/版本**信号(见其注),
129
+ // 必须与调用方身份无关,否则老版本探测会把「我没带 principal」误读成「这个 worker 太老」。
130
+ permissionRules: Boolean(deps.ruleConsent) && gatedPrincipal(req, deps.config) !== undefined,
107
131
  // #205 SDK 车上游请托二连(2026-08-10):
108
132
  // · `permissionRulesRevoke` —— 撤销面两口(GET/DELETE /v1/rules)**存在性**的独立信号。谓词与
109
133
  // `permissionRules` 同源,但**在场性**本身就是版本信号:≤7.11.0 的 worker `permissionRules` 已为
@@ -490,6 +490,24 @@ async function handleImagesBody(req, res, url, ctx, miss) {
490
490
  return;
491
491
  }
492
492
  }
493
+ else if (url === "/v1/images" || url.startsWith("/v1/images/")) {
494
+ // 🔴 #F1([3397]-5 同族缺口):索引缺席时**整个域不挂载**,请求此前一路落到全局路由表尾的
495
+ // `404 not_found.route` —— 与「路由拼错了」同一个码,消费端结构上分不出「这个部署没接这个面」
496
+ // 与「我 URL 写错了」(trial-by-404 的最坏形:两种成因的处置完全相反)。改成诚实的能力拒。
497
+ //
498
+ // 谓词与上面那条挂载合取式**同一个符号**(`deps.imageIndex`),所以 `/v1/capabilities` 的 `images`
499
+ // 位(同一谓词)说 yes ⟺ 这条臂不触发 ⟺ 目录读面真能用。
500
+ //
501
+ // ⚠️ 判据是**按段**的(`=== "/v1/images"` 或 `"/v1/images/"` 前缀),不是上面挂载式那个裸前缀:
502
+ // `/v1/imagesfoo` 是**拼错的路由**,不是本域的路径,它该继续拿 404(索引在场的部署上它本来就落 404
503
+ // —— 两种部署形对同一个手滑给出不同的码,那正是本臂要消灭的那种含糊)。
504
+ //
505
+ // ⚠️ bake 子面**不误伤**:`deps.imageBakes` 在场时 `/v1/images/bakes*` 已在本函数最上面的域里应答
506
+ // 并 `return`,走不到这里;两者都缺席时(local 后端的真实形态——两个工厂它都不实现)本臂如实
507
+ // 回答「这个部署没有 sandbox-image-pool」,那对 bakes 路径也是真话。
508
+ sendError(res, 501, "capability.image_index_required", "the sandbox-image-pool requires a SQL store backend (DB_BACKEND=mysql|pg) — the local backend wires no image index");
509
+ return;
510
+ }
493
511
  miss.fell = true;
494
512
  }
495
513
  /** The image_bake SSE (P2.2/P2.8) — the SAME pump, with the run-trace-SSE-shaped bake frame
@@ -161,11 +161,27 @@ async function streamRunEvents(req, res, deps, runStore, taskId, staleMs) {
161
161
  // 「开关关 = 逐字零变化」(§6.5/§7.2)当场破。谓词同时省掉了关闭态每次开流的一次 store 往返
162
162
  // 与那份 store 故障暴露面。钉:场景9(零帧 **且** 零 store 读 —— 只钉零帧会放过「读了但没投」)。
163
163
  preamble: async (signal) => {
164
- if (!deps.config.streamApproval.enabled)
164
+ // 🔴 A-010.7(验真后修):谓词是**上场门**,不是裸开关。
165
+ // 原式是 `if (!config.streamApproval.enabled) return []` + 一个 `backend?.approvalAsk()` 兜底,
166
+ // 那是本仓早就收编掉的「局部合取式」形(见本文件 askDecision 口的头注:车4 落地时的临时判据,
167
+ // 交接条件写死了「换成消费**那一个**谓词」)。两处不同源的后果在 `STREAM_APPROVAL` 默认翻 ON
168
+ // 之后才显形:上场门是**五**项合取(还要 toolApproval 在场、账本非易失 `kind !== "local"`、
169
+ // park 设施在场),而这里只看了两项 ⇒ 一个协议**没有上场**的 SQL 部署
170
+ // (典型:`TOOL_APPROVAL_ENABLED=false`,或没开 `DURABLE_APPROVAL` ⇒ 无 park 设施)
171
+ // **每一次开流都照打一次库** —— 一次纯白打的 `listPendingByTask`,外加那份 store 故障暴露面,
172
+ // 而它按契约永远只能投出零帧(协议没上场 ⇒ 没有任何一条 `STREAM_PENDING` 行是本协议铸的)。
173
+ // 换成单一谓词后,「能力面说 false」⟺「本腿零 store 往返」是结构成立的。
174
+ const gate = resolveStreamApprovalGate({
175
+ toolApprovalEnabled: Boolean(deps.toolApproval),
176
+ streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
177
+ backend: deps.backend,
178
+ parkFacility: deps.checkpointStore !== undefined,
179
+ });
180
+ // 协议未上场 ⇒ 零帧**且零 store 读**(场景9 的钉打的就是这两件事,不只是零帧)。
181
+ // env-only worker(无 durable backend)在 `no_backend` 那条臂上一并收掉:无 ask 账 ⇒ 无对账基准。
182
+ if (!gate.active)
165
183
  return [];
166
- const store = deps.backend?.approvalAsk();
167
- if (!store)
168
- return []; // env-only worker(无 durable backend)= 无 ask 账 ⇒ 无对账基准,诚实缺席
184
+ const store = gate.askStore;
169
185
  const rows = await store.listPendingByTask(taskId, signal);
170
186
  const { frames, skipped, dropped } = buildApprovalPreamble(rows, Date.now(), deps.config.streamApproval.replayMax);
171
187
  if (skipped > 0)
@@ -284,7 +300,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
284
300
  return { status: 202, body: { taskId: clientTaskId, sessionId, status: "running" } };
285
301
  }
286
302
  // [2255]① 真出路材料:activeTaskStatus + parked 时 pendingGate{kind,decidePath}(best-effort,失败退旧形)
287
- return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore }, sessionId, created.activeTaskId) };
303
+ return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore, governance: deps.config }, sessionId, created.activeTaskId) };
288
304
  }
289
305
  // Durable F4: persist the resume rebuild inputs (sessionId-keyed) so an operator can resume from any
290
306
  // replica even after this worker is gone — core's checkpoint blob can't carry service scenario context.
@@ -219,14 +219,26 @@ async function handleTasksBody(req, res, url, ctx, miss) {
219
219
  // 有界(2s)+ fail-open:store 抛或挂起都只记一次 warn、零卡继续开流(§5.3)。
220
220
  // 🔴 开关谓词与 runs.ts 同源同理由(codex 复审第二轮 high:回滚下表里可能有残留 STREAM_PENDING
221
221
  // 行,无谓词会破「开关关 = 逐字零变化」;顺带省掉关闭态每次开流的一次 store 往返)。钉:场景9。
222
- const replaySessionId = deps.config.streamApproval.enabled ? prepared.spec.sessionId : undefined;
222
+ //
223
+ // 🔴 A-010.7 随修(**兄弟扫查**:台账只点名了 runs.ts 那一处,这里是同形的第二处):谓词是
224
+ // **上场门**不是裸开关。五项合取(协调器在场 / 开关真 / backend 在场 / 账本非易失 / park 设施在场)
225
+ // 里少查三项 ⇒ 一个协议根本没上场的 SQL 部署,每次开流仍要白打一次库;回滚时更坏 —— 别的副本
226
+ // 落下的残留 `STREAM_PENDING` 行会被无门的重放腿照常投上 wire。换成与协调器注入 / 能力面 /
227
+ // 回决 501 口**同一个**谓词,「说没上场」⟺「零帧且零 store 读」才是结构成立的。
228
+ const replayGate = resolveStreamApprovalGate({
229
+ toolApprovalEnabled: Boolean(deps.toolApproval),
230
+ streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
231
+ backend: deps.backend,
232
+ parkFacility: deps.checkpointStore !== undefined,
233
+ });
234
+ const replaySessionId = replayGate.active ? prepared.spec.sessionId : undefined;
223
235
  if (replaySessionId) {
224
236
  const replayOwner = gatedPrincipal(req, deps.config) ?? null;
237
+ const askStore = replayGate.active ? replayGate.askStore : undefined;
225
238
  const preambleFrames = await collectSsePreamble(async (signal) => {
226
- const store = deps.backend?.approvalAsk();
227
- if (!store)
239
+ if (!askStore)
228
240
  return [];
229
- const rows = await store.listPendingBySession(replaySessionId, replayOwner, signal);
241
+ const rows = await askStore.listPendingBySession(replaySessionId, replayOwner, signal);
230
242
  const { frames, skipped, dropped } = buildApprovalPreamble(rows, Date.now(), deps.config.streamApproval.replayMax);
231
243
  if (skipped > 0)
232
244
  deps.logger?.warn?.("approval_preamble_row_skipped", { sessionId: replaySessionId, skipped, face: "tasks.stream" });
@@ -307,7 +319,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
307
319
  if (created.ok)
308
320
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget; in-titler dedupe;model 跟 turn([1992]②)
309
321
  if (!created.ok) {
310
- const conflict = await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore }, prepared.spec.sessionId, created.activeTaskId); // [2255]① 真出路材料
322
+ const conflict = await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config }, prepared.spec.sessionId, created.activeTaskId); // [2255]① 真出路材料
311
323
  // Headers are already SSE — encode the rejection as the stream's terminal event; the 409 in
312
324
  // the returned resp is for a deduplicated concurrent caller (and is never idem-cached).
313
325
  // done 帧 additive 携带同一份材料 —— 形状铸在 toDoneFrameResult(有名字有类型),不在这行内联挑键。
@@ -1225,7 +1237,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1225
1237
  const tid = uuidv7();
1226
1238
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
1227
1239
  if (!created.ok)
1228
- return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore }, prepared.spec.sessionId, created.activeTaskId) }; // [2255]① 真出路材料
1240
+ return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config }, prepared.spec.sessionId, created.activeTaskId) }; // [2255]① 真出路材料
1229
1241
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model 跟 turn([1992]②)
1230
1242
  durableTaskId = tid;
1231
1243
  }
@@ -516,18 +516,36 @@ export declare function validateUserSkills(skills: unknown): string | null;
516
516
  export declare function createHttpServer(rawDeps: ServiceDeps): http.Server & {
517
517
  denyExpiredApprovals: (now: number) => Promise<void>;
518
518
  };
519
- /** SSE: replay durable events after Last-Event-ID, then tail until the run is terminal (or stale). */
520
- /**
521
- * The differences a concrete log (task_run | image_bake) feeds the ONE resumable SSE reader (P2.8). Everything
522
- * the reader does — Last-Event-ID/`?from=` resume, the 416 retention boundary, the per-poll concurrent
523
- * status+events read, the terminal re-fetch (the terminal event lands in the gap before the status flips), the
524
- * stale fallback, the 15-min cap, the 15s idle heartbeat — is provider-agnostic and lives in `streamSseLog`.
525
- * 🔴 The task_run provider MUST keep the existing wire bytes EXACTLY (center's relay + 730+ tests depend on it).
526
- */
527
519
  /** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
528
520
  * guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
529
521
  * via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
530
522
  export declare function isBillableSubmitPath(url: string): boolean;
523
+ /**
524
+ * A-010.23(#209 件5)—— **改写门**:不烧模型、但在**没有任何 service credential** 的部署形下必须与
525
+ * billable / bake 两族一样 fail-closed 的写门。谓词与 `isBillableSubmitPath` **刻意分开**:这几扇门
526
+ * `billable=false`(零模型工作),塞进那张名单会让它们连带吃 drain 与 model-roster-pending 两道 503 ——
527
+ * 而收编与规则读写在排空期/roster 未落时做完全无害。
528
+ *
529
+ * 判据(两个合取项,缺一不进本表)= 「授权的唯一输入是 principal 头」∧「动作是**持久改写**」:
530
+ * · `POST /v1/adoption` —— operator-only 的一次性部署级动作,把一个 principal 名下**每一行**重写到另一个
531
+ * 身份;授权判据 `explicitOperatorOk(gatedPrincipal(...))`。
532
+ * · `POST /v1/rules/cc-import/*` —— principal lane 的**持久 allow 规则**铸造口;一条持久 allow 规则对后续
533
+ * 同命令的 classify 档 ask 常驻消音(core #144 之后仍然如此,只是不再消 mandated 的那些)。
534
+ * · `DELETE /v1/rules` —— 同一个规则店的**撤销**口。方向相反(收紧)但同样是持久改写,而且它多一条
535
+ * operator 越权域(`?principal=` / body 的 `principal` 让 operator 收回**任一**租户的规则)⇒ 伪造一个
536
+ * 列在 `OPERATOR_PRINCIPALS` 里的头,就能把每一位租户的规则一次清空。
537
+ * 无 service token 的部署上 `verifiedPrincipal` 走的是「BFF/gated:头已由上游验过」那一支,而这一形恰恰
538
+ * 是**没有**那个上游 —— 于是伪造一个头就能收编别人、替任意租户种下常驻放行、或替他们全删。bake 门当年
539
+ * 补的就是同一句话,`isDestructiveSessionWrite` 从逐条路由白名单改成族判定也是同一句话(那次的措辞逐字是
540
+ * 「a forged principal header alone could **tighten/DoS** a session's tools」——撤销口落的正是 DoS 那一半)。
541
+ *
542
+ * 🔴 **方法感知**(codex 对抗复审 R2-[high],验真后修):撤销走的是 `DELETE`,而本门最初只挂在 `POST` 的
543
+ * 合取里 —— 于是一个自称「持久改写」的谓词把爆炸半径最大的那条动词漏在门外,正是 `isDestructiveSessionWrite`
544
+ * 当年被抓到的同一形。签名因此收方法,与那只谓词逐字同形。
545
+ * 🔴 读面不进本表:`GET /v1/adoption/:id` 与 `GET /v1/rules` 各有自己的属主/operator 门,本门只拦写。
546
+ * 行为面钉:`test/rewrite-door-service-token-gate.test.ts`(四扇门各一正一反 + 逃生口格 + 读面负控)。
547
+ */
548
+ export declare function isCredentialGatedRewrite(method: string, url: string): boolean;
531
549
  /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
532
550
  * [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
533
551
  * 落 ":id"、`/v1/images/{bakes,select,register}` 落 ":profile"、`/v1/images/bakes/claim` 落