@sema-agent/server 7.11.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 (78) hide show
  1. package/README.md +1 -1
  2. package/USAGE.md +86 -2
  3. package/dist/adoption/plan.d.ts +38 -4
  4. package/dist/adoption/plan.js +72 -0
  5. package/dist/adoption/quiesce.d.ts +70 -0
  6. package/dist/adoption/quiesce.js +148 -0
  7. package/dist/adoption/runner.js +63 -5
  8. package/dist/adoption/sql.d.ts +15 -0
  9. package/dist/adoption/sql.js +18 -0
  10. package/dist/adoption/wire.d.ts +7 -1
  11. package/dist/adoption/wire.js +6 -0
  12. package/dist/approval-card.d.ts +5 -0
  13. package/dist/approval-card.js +22 -0
  14. package/dist/auth-keys.d.ts +28 -4
  15. package/dist/auth-keys.js +60 -15
  16. package/dist/boot/parked-revive-gate.d.ts +18 -2
  17. package/dist/boot/parked-revive-gate.js +136 -14
  18. package/dist/boot/permission-rules-audit.d.ts +49 -0
  19. package/dist/boot/permission-rules-audit.js +85 -0
  20. package/dist/boot/reapers.d.ts +15 -0
  21. package/dist/boot/reapers.js +101 -44
  22. package/dist/boot/resolve-spec.js +43 -12
  23. package/dist/boot/runner-deps.d.ts +16 -2
  24. package/dist/boot/runner-deps.js +5 -4
  25. package/dist/budget.js +22 -0
  26. package/dist/config-types.d.ts +31 -11
  27. package/dist/config.d.ts +28 -2
  28. package/dist/config.js +348 -79
  29. package/dist/governance-ask-marks.js +8 -2
  30. package/dist/http/active-run-conflict.d.ts +33 -8
  31. package/dist/http/active-run-conflict.js +37 -2
  32. package/dist/http/route-ctx.d.ts +6 -3
  33. package/dist/http/routes/adoption.js +25 -2
  34. package/dist/http/routes/approvals-assistant.js +35 -4
  35. package/dist/http/routes/capabilities.js +69 -10
  36. package/dist/http/routes/images.js +18 -0
  37. package/dist/http/routes/rules.d.ts +19 -7
  38. package/dist/http/routes/rules.js +180 -4
  39. package/dist/http/routes/runs.js +21 -5
  40. package/dist/http/routes/tasks.js +18 -6
  41. package/dist/http/server.d.ts +30 -10
  42. package/dist/http/server.js +183 -19
  43. package/dist/http/wire-types.d.ts +48 -0
  44. package/dist/main.js +65 -7
  45. package/dist/observability/fail-open.d.ts +8 -0
  46. package/dist/observability/fail-open.js +8 -0
  47. package/dist/observability/metrics.js +2 -1
  48. package/dist/observability/tool-trace.d.ts +5 -1
  49. package/dist/observability/tool-trace.js +33 -6
  50. package/dist/parked-decide.d.ts +13 -3
  51. package/dist/parked-decide.js +10 -1
  52. package/dist/plugins/adoption-log-sql.d.ts +40 -0
  53. package/dist/plugins/adoption-log-sql.js +69 -2
  54. package/dist/plugins/file-run-store.d.ts +85 -1
  55. package/dist/plugins/file-run-store.js +450 -17
  56. package/dist/plugins/permission-rule-store-file.d.ts +83 -0
  57. package/dist/plugins/permission-rule-store-file.js +371 -0
  58. package/dist/plugins/permission-rule-store-sql.d.ts +52 -0
  59. package/dist/plugins/permission-rule-store-sql.js +71 -2
  60. package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
  61. package/dist/plugins/shared-memory-store-sql.js +55 -18
  62. package/dist/plugins/sql-driver.d.ts +19 -0
  63. package/dist/plugins/sql-driver.js +12 -0
  64. package/dist/plugins/store-backend.d.ts +12 -6
  65. package/dist/plugins/store-backend.js +82 -10
  66. package/dist/rules-consent.d.ts +98 -1
  67. package/dist/rules-consent.js +84 -1
  68. package/dist/run-local.js +126 -15
  69. package/dist/runtime-governance.d.ts +33 -0
  70. package/dist/runtime-governance.js +41 -3
  71. package/dist/task-settings.d.ts +44 -0
  72. package/dist/task-settings.js +57 -1
  73. package/dist/tool-approval.d.ts +38 -1
  74. package/dist/tool-approval.js +125 -26
  75. package/dist/trace/core-keyset-guard.d.ts +14 -3
  76. package/dist/trace/project.d.ts +19 -2
  77. package/dist/trace/project.js +24 -4
  78. package/package.json +3 -3
@@ -1,12 +1,20 @@
1
1
  import { z } from "zod";
2
2
  import { sendJson, sendError } from "../send.js";
3
- import { gatedPrincipal } from "../principal-gate.js";
3
+ import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
4
4
  import { createLogger } from "../../observability/logger.js";
5
5
  // 等待秒数的单一真源在车道:它同时被「放回认领时判这张票还撑不撑得过这段窗」用到(见那侧头注)。
6
- import { RULE_IMPORT_RETRY_AFTER_SEC } from "../../rules-consent.js";
6
+ // scope wire 形也在车道(列举列 / 查询参数 / 删除体 / 游标四处同一份表示,见那侧头注)。
7
+ import { RULE_IMPORT_RETRY_AFTER_SEC, parseRuleScope, serializeRuleScope } from "../../rules-consent.js";
7
8
  const logger = createLogger();
8
9
  export const RULES_CC_IMPORT_PREPARE_PATH = "/v1/rules/cc-import/prepare";
9
10
  export const RULES_CC_IMPORT_REDEEM_PATH = "/v1/rules/cc-import/redeem";
11
+ /** 撤销面两口共用的字面路径(GET 列举 / DELETE 撤销)。 */
12
+ export const RULES_PATH = "/v1/rules";
13
+ /** 一页的上下限。50 = 一屏治理列表;200 = 一次响应的上界(一条规则行带 `adds[]`,不是一个标量)。
14
+ * 与 `GET /v1/sessions` 的 keyset 姿势同族(sessions-list.ts:137 的 `Math.min(100, …)`),数值按本面
15
+ * 的行宽另定 —— 抄一个数比抄一条理由更容易漂。 */
16
+ const RULES_PAGE_DEFAULT = 50;
17
+ const RULES_PAGE_MAX = 200;
10
18
  /** 一层 settings 的上限。一份 CC settings 是人手写的 JSON,不是数据集;16 KiB 宽到装得下几百条 allow,
11
19
  * 窄到一次请求不能拿它当上传通道。层数同理(core v1 只读三层,给 3 就够,多给等于给一个放大面)。 */
12
20
  const MAX_LAYER_CONTENT_CHARS = 16_384;
@@ -35,6 +43,44 @@ const CcImportPrepareBodySchema = z
35
43
  })
36
44
  .strict();
37
45
  const CcImportRedeemBodySchema = z.object({ ticket: z.string().min(1).max(190) }).strict();
46
+ /** `DELETE /v1/rules` 的体。`.strict()` 同族理由:把 `scope` 拼错成 `scopes` 的客户端应该当场知道,
47
+ * 而不是拿到一个「删掉了 global 那条」的意外结果。`principal` = operator 越权域(缺席 = 删自己的)。
48
+ * 长度上限与 principal 入口上限同源(`PRINCIPAL_MAX_LENGTH = 190`,security.ts);`rule` 的 1024 与
49
+ * cc-import 的 `root` 同族(一条规则文本是人写的一行,不是载荷)。 */
50
+ const RuleRevokeBodySchema = z
51
+ .object({
52
+ rule: z.string().min(1).max(1024),
53
+ scope: z.string().min(1).max(1088), // "project:" + root(1024)+ 余量
54
+ principal: z.string().min(1).max(190).optional(),
55
+ })
56
+ .strict();
57
+ const RulesCursorSchema = z
58
+ .object({ rev: z.number().int().nonnegative().safe(), principal: z.string(), scope: z.string(), after: z.string() })
59
+ .strict();
60
+ function encodeRulesCursor(c) {
61
+ return Buffer.from(JSON.stringify(c)).toString("base64url");
62
+ }
63
+ /**
64
+ * 读不出形 ⇒ `{ok:false, why}`,由调用方拒(400)。两条纪律:
65
+ * · **刻意不**「读不出就当第一页」(sessions-list 的旧姿势):那条腿的游标只影响顺序,这条腿的游标
66
+ * 一旦被静默丢弃,客户端会以为自己列完了整份清单。
67
+ * · 失败**带着原因出来**而不是塌成一个 `undefined`:调用点把 `why` 写进服务端日志。一个只回默认值的
68
+ * catch 是本仓在册的静默降级形(#191 门② SHAPE C)——这里不需要那种便利,原因本来就有用。
69
+ */
70
+ function decodeRulesCursor(raw) {
71
+ try {
72
+ const parsed = RulesCursorSchema.safeParse(JSON.parse(Buffer.from(raw, "base64url").toString()));
73
+ return parsed.success ? { ok: true, cursor: parsed.data } : { ok: false, why: parsed.error.message };
74
+ }
75
+ catch (err) {
76
+ return { ok: false, why: err instanceof Error ? err.message : String(err) };
77
+ }
78
+ }
79
+ /** 排序键 —— 与 `RuleConsentLane.listRules` 的 (scope, rule) 二元排序**等价**(两处若各写一份必然漂,
80
+ * 而漂的表现是分页静默漏行)。分隔符必须**小于**任何可能出现的字符,否则串比较与元组比较会在
81
+ * 「一个 scope 是另一个的前缀」处分岔(`project:/a` vs `project:/ab`)。用空格是错的 ——
82
+ * `Bash(echo hi)` 里就有一个;\u0000 是码点最小的字符,而且规则文本与路径里都不可能出现它。 */
83
+ const rowKey = (r) => `${r.scope}\u0000${r.rule}`;
38
84
  export async function handleRules(req, res, url, ctx) {
39
85
  const miss = { fell: false };
40
86
  await handleRulesBody(req, res, url, ctx, miss);
@@ -45,15 +91,21 @@ async function handleRulesBody(req, res, url, ctx, miss) {
45
91
  const { readJson, rateLimited } = ctx.helpers;
46
92
  const isPrepare = req.method === "POST" && url === RULES_CC_IMPORT_PREPARE_PATH;
47
93
  const isRedeem = req.method === "POST" && url === RULES_CC_IMPORT_REDEEM_PATH;
48
- if (!isPrepare && !isRedeem) {
94
+ // 🔴 判路写**字面量**而不是引 `RULES_PATH`( server.ts 的 `ROUTE_LABEL_LITERALS` 那条注同族理由):
95
+ // 路由名册门与文案接线门的枚举器扫的是 `url === "…"` 的**源码文本**,引常量会让这两口对两道门隐形
96
+ // ——那不是「过了门」,是绕过门。常量仍导出给测试/SDK 侧当单一真源用。
97
+ const isList = req.method === "GET" && url === "/v1/rules";
98
+ const isRevoke = req.method === "DELETE" && url === "/v1/rules";
99
+ if (!isPrepare && !isRedeem && !isList && !isRevoke) {
49
100
  miss.fell = true;
50
101
  return;
51
102
  }
52
103
  // 🔴 `prepare` 每次调用**都会**落一条 pending 审批记录 + 一张票 —— 它是本域唯一一条「一次请求 = 一行」
53
104
  // 的写面,没有限流就是一条行洪口。与 `POST /v1/tasks/:id/asks/:askId/decision` 同姿势(限流在最前)。
105
+ // 撤销面两口同罩:`DELETE` 是写(每次铸一个 dot + 一条墓碑),`GET` 是整桶读 —— 两者都不该无限打。
54
106
  if (rateLimited(req, res))
55
107
  return;
56
- // 鉴权**先于**可用性(501-before-auth 先例的反面同理):这两口没有可被探测的部署事实,但
108
+ // 鉴权**先于**可用性(501-before-auth 先例的反面同理):这四口没有可被探测的部署事实,但
57
109
  // principal 是本域的全部授权基础,先立住它再谈别的。
58
110
  const principal = gatedPrincipal(req, deps.config);
59
111
  if (principal === undefined) {
@@ -65,6 +117,12 @@ async function handleRulesBody(req, res, url, ctx, miss) {
65
117
  sendError(res, 501, "capability.rule_store_required", "this deployment has no permission-rule store wired");
66
118
  return;
67
119
  }
120
+ // ── 撤销面两口(#203 §2)──────────────────────────────────────────────────────────────────────
121
+ // 放在读体之前:`GET` 没有体,`readJson` 对它是一次白等。
122
+ if (isList) {
123
+ await handleRuleList(req, res, principal, lane, deps.config.operatorPrincipals);
124
+ return;
125
+ }
68
126
  let rawBody;
69
127
  try {
70
128
  rawBody = await readJson(req);
@@ -73,6 +131,10 @@ async function handleRulesBody(req, res, url, ctx, miss) {
73
131
  sendError(res, 400, "request.body_shape", "body must be valid JSON");
74
132
  return;
75
133
  }
134
+ if (isRevoke) {
135
+ await handleRuleRevoke(res, rawBody, principal, lane, deps.config.operatorPrincipals);
136
+ return;
137
+ }
76
138
  if (isPrepare) {
77
139
  const parsed = CcImportPrepareBodySchema.safeParse(rawBody);
78
140
  if (!parsed.success) {
@@ -114,4 +176,118 @@ async function handleRulesBody(req, res, url, ctx, miss) {
114
176
  }
115
177
  sendJson(res, 200, { result: redeemed.result });
116
178
  }
179
+ /**
180
+ * 解析**目标 principal**(自有域 vs operator 越权域)。
181
+ *
182
+ * 🔴 判据链是硬的:①没给 = 自己(最常见的路,零授权提问);②给了但等于自己 = 还是自己(一个把
183
+ * principal 显式写出来的客户端不该因此被要求当 operator);③给了别人 = **必须** `explicitOperatorOk`。
184
+ * 第三条用的是 `explicitOperatorOk` 而不是 `isOperator`:后者在 `OPERATOR_PRINCIPALS` 为空时「人人
185
+ * 都是 operator」,那在跨租户读规则这条轴上是一道世界可写的门(principal-gate.ts 的头注逐字)。
186
+ * **绝不**从 service token 推断:一个共享的部署凭证不是一个人,而这条口的授权对象是人。
187
+ */
188
+ function resolveTargetPrincipal(requested, caller, operatorPrincipals) {
189
+ if (requested === undefined || requested === caller)
190
+ return { ok: true, principal: caller };
191
+ if (!explicitOperatorOk(caller, operatorPrincipals))
192
+ return { ok: false };
193
+ return { ok: true, principal: requested };
194
+ }
195
+ /** `GET /v1/rules` —— 列出一位 principal 名下**活着**的规则。 */
196
+ async function handleRuleList(req, res, caller, lane, operatorPrincipals) {
197
+ // `url` 在派发前已被剥掉 query,查询参数只能从 `req.url` 重新取(与 `/v1/outcomes` 同姿势)。
198
+ const q = new URL(req.url ?? "/", "http://localhost").searchParams;
199
+ const target = resolveTargetPrincipal(q.get("principal") ?? undefined, caller, operatorPrincipals);
200
+ if (!target.ok) {
201
+ sendError(res, 403, "auth.operator_only", "reading another principal's rules is an operator-only action");
202
+ return;
203
+ }
204
+ // limit:非数/越界一律夹取(与 sessions-list 同姿势——一个分页参数写错不该让治理面整个打不开)。
205
+ const limit = Math.min(RULES_PAGE_MAX, Math.max(1, Number(q.get("limit") ?? RULES_PAGE_DEFAULT) || RULES_PAGE_DEFAULT));
206
+ // scope 过滤:**给了就必须读得出形**。读不出不当「没过滤」——那会把一次「只看这个项目」的请求
207
+ // 悄悄变成一次全量列举,而客户端会照单全收当成该项目的清单。
208
+ const scopeRaw = q.get("scope");
209
+ const scopeFilter = scopeRaw === null ? undefined : parseRuleScope(scopeRaw);
210
+ if (scopeRaw !== null && scopeFilter === undefined) {
211
+ sendError(res, 400, "request.query_invalid", "scope must be 'global' or 'project:<root>' with a non-empty root");
212
+ return;
213
+ }
214
+ const cursorRaw = q.get("cursor");
215
+ const decoded = cursorRaw === null ? undefined : decodeRulesCursor(cursorRaw);
216
+ if (decoded !== undefined && !decoded.ok) {
217
+ logger.info("rules cursor unreadable", { why: decoded.why });
218
+ sendError(res, 400, "request.query_invalid", "the rules cursor is unreadable — restart the listing without one");
219
+ return;
220
+ }
221
+ const cursor = decoded?.ok === true ? decoded.cursor : undefined;
222
+ // 过滤键用**规范形**(`serializeRuleScope` 的产物),不是客户端交上来的原串:行上的 scope 是规范的,
223
+ // 拿一个等价但拼法不同的原串去比会静默过滤成空表。空串 = 未过滤(游标里也用这一个表示)。
224
+ const filterKey = scopeFilter === undefined ? "" : serializeRuleScope(scopeFilter);
225
+ const listing = await lane.listRules(target.principal);
226
+ if (cursor !== undefined && (cursor.rev !== listing.rev || cursor.principal !== target.principal || cursor.scope !== filterKey)) {
227
+ // 游标绑 (rev, principal, scope)(见 {@link RulesCursor} 头注)。**拒**而不是从头给:静默重置会让
228
+ // 客户端把第二页当成「接着上一页」,治理面上于是出现一份既漏行又重行的清单,而没有任何人知道。
229
+ // 三项合并成一句文案是刻意的:对客户端来说处置完全相同(丢掉游标从头列),而把「你换了 principal」
230
+ // 单独成句会让一个 operator 从别人桶的 rev 上读出信息。
231
+ sendError(res, 400, "request.query_invalid", "the rules cursor was minted at a different revision — restart the listing", { rev: listing.rev });
232
+ return;
233
+ }
234
+ const matching = listing.rows.filter((r) => (filterKey === "" || r.scope === filterKey) && (cursor === undefined || rowKey(r) > cursor.after));
235
+ const page = matching.slice(0, limit);
236
+ const last = page[page.length - 1];
237
+ const nextCursor = matching.length > limit && last !== undefined
238
+ ? encodeRulesCursor({ rev: listing.rev, principal: target.principal, scope: filterKey, after: rowKey(last) })
239
+ : undefined;
240
+ sendJson(res, 200, { rules: page, rev: listing.rev, ...(nextCursor !== undefined ? { nextCursor } : {}) });
241
+ }
242
+ /** `DELETE /v1/rules` —— 按内容(rule, scope)撤销。 */
243
+ async function handleRuleRevoke(res, rawBody, caller, lane, operatorPrincipals) {
244
+ const parsed = RuleRevokeBodySchema.safeParse(rawBody);
245
+ if (!parsed.success) {
246
+ sendError(res, 400, "request.body_shape", "body must be {rule, scope} with an optional principal");
247
+ return;
248
+ }
249
+ const scope = parseRuleScope(parsed.data.scope);
250
+ if (scope === undefined) {
251
+ sendError(res, 400, "request.body_shape", "scope must be 'global' or 'project:<root>' with a non-empty root");
252
+ return;
253
+ }
254
+ const target = resolveTargetPrincipal(parsed.data.principal, caller, operatorPrincipals);
255
+ if (!target.ok) {
256
+ // 与读那一半**同码同姿势**:方向(收紧)决定了这条越权域存在,身份判据不因方向而放松。
257
+ sendError(res, 403, "auth.operator_only", "revoking another principal's rules is an operator-only action");
258
+ return;
259
+ }
260
+ const outcome = await lane.removeRule({ principal: target.principal, rule: parsed.data.rule, scope });
261
+ switch (outcome.status) {
262
+ case "removed":
263
+ // 🔴 `stillLive` **必须上 wire**(design/203 v2 §6 F3)。它为真的意思是「墓碑落了,但这次调用
264
+ // 期间又落了一次新的批准,规则按 add-wins 仍然活着」——把它折进 200 不说,客户端会把一次
265
+ // **没有完成**的撤销读成完成,而那正是治理面最不能出错的地方。
266
+ sendJson(res, 200, { status: "removed", rev: outcome.rev, stillLive: outcome.stillLive });
267
+ return;
268
+ case "no-op": {
269
+ // 幂等:重复删 / 本来就没有,一律 200 `no-op`。**不是 404** —— 一个「这条规则存在过吗」的
270
+ // 404 就是一台存在性 oracle(operator 越权域下更是跨租户的那种),而删除对「有没有过」
271
+ // 本来就不该作 oracle。客户端要判「我删到东西了吗」,读 `status` 即可。
272
+ //
273
+ // 🔴 `stillLive` 这一支**也要有**(codex 对抗复审 R1-F2,验真后修)。core 的原语在初始快照里
274
+ // 找不到匹配 dot 时**立刻**回 no-op —— 它不做 removed 那支的读回。于是「快照之后、应答之前落了
275
+ // 一次批准」的窗口里,规则**是活的**而端点说 no-op,客户端合理地读成「已经没有这条规则了」。
276
+ // 那与 F3 修好的 removed+stillLive 是**同一个**危害,只是走了另一支。
277
+ // 处置 = server 自己补一次读回,把**观察到的**事实如实上 wire;两支 200 的键集因此同形,消费端
278
+ // 一套分支读得通。读回失败不改判(no-op 本身是 core 裁定的)⇒ 保守报 `stillLive:false`?**不**:
279
+ // 那是编一个结论。读不出来就让它抛,由本域的全局错误臂报 500 —— 一个说不清结果的撤销不该回 200。
280
+ // 让 core 的原语自己报这一位是更根治的形,已作为上游请托登记(server 不改上游语义,[2862])。
281
+ const stillLive = (await lane.listRules(target.principal)).rows.some((r) => r.rule === parsed.data.rule && r.scope === serializeRuleScope(scope));
282
+ sendJson(res, 200, { status: "no-op", rev: outcome.rev, stillLive });
283
+ return;
284
+ }
285
+ case "failed":
286
+ // core 对 `failed` 的承诺是「读回确认什么都没写」或「读回本身也失败了,结局不定」——两种都是
287
+ // **重试可改判**的,所以 503 而不是 500。理由与 `state.rule_import_retry` 同族。
288
+ logger.info("rule revoke failed", { error: outcome.error });
289
+ sendError(res, 503, "state.rule_remove_failed", "the rule could not be revoked right now — retry, then reconcile with GET /v1/rules");
290
+ return;
291
+ }
292
+ }
117
293
  //# sourceMappingURL=rules.js.map
@@ -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
  }
@@ -334,8 +334,10 @@ export interface ServiceSeamDeps {
334
334
  parkedKnownAgentTypes?: ReadonlySet<string>;
335
335
  /** [1596]/[1597] 跨进程父约束重供:decide 腿按部署配置重建 host 任务的同语义父策略链(exempt 锚=
336
336
  * row.rootSessionId),经 core 1.396 的 `parkedResume.inheritedGate` 席位透传——缺席时重启后带
337
- * requiresParentConstraint 的 parked checkpoint 恒被 core pre-CAS 门诚实拒(不可赎回)。 */
338
- parkedReviveInheritedGate?: (row: import("@sema-agent/core").BackgroundAgentRecord) => unknown;
337
+ * requiresParentConstraint 的 parked checkpoint 恒被 core pre-CAS 门诚实拒(不可赎回)。
338
+ * ⚠️ **async**(A-010.1):重建要按本行 principal 现解 entitlement——理由在 parked-decide.ts 的
339
+ * `rebuildInheritedGate` 头注。 */
340
+ parkedReviveInheritedGate?: (row: import("@sema-agent/core").BackgroundAgentRecord) => Promise<unknown>;
339
341
  }
340
342
  /** **可观测面**:日志/指标/累加器。全体 fail-open——观测失败绝不改变业务面行为。 */
341
343
  export interface ServiceObservabilityDeps {
@@ -514,18 +516,36 @@ export declare function validateUserSkills(skills: unknown): string | null;
514
516
  export declare function createHttpServer(rawDeps: ServiceDeps): http.Server & {
515
517
  denyExpiredApprovals: (now: number) => Promise<void>;
516
518
  };
517
- /** SSE: replay durable events after Last-Event-ID, then tail until the run is terminal (or stale). */
518
- /**
519
- * The differences a concrete log (task_run | image_bake) feeds the ONE resumable SSE reader (P2.8). Everything
520
- * the reader does — Last-Event-ID/`?from=` resume, the 416 retention boundary, the per-poll concurrent
521
- * status+events read, the terminal re-fetch (the terminal event lands in the gap before the status flips), the
522
- * stale fallback, the 15-min cap, the 15s idle heartbeat — is provider-agnostic and lives in `streamSseLog`.
523
- * 🔴 The task_run provider MUST keep the existing wire bytes EXACTLY (center's relay + 730+ tests depend on it).
524
- */
525
519
  /** POST endpoints that trigger BILLABLE work — the fail-closed auth guard must cover ALL of them (council: the
526
520
  * guard's inline list had drifted from the handlers and missed `/v1/approvals/:id/decide`, which resumes a run
527
521
  * via resumeCheckpoint/store.decide → paid tokens). Keep this in sync when adding a billable POST route. */
528
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;
529
549
  /** Stable, low-cardinality route label for metrics/logs (ids collapsed to `:id`).
530
550
  * [#104] 字面量**先于**模式:此前模式先查,五条精确路由被形状桶吞掉(`/v1/approvals/stream`
531
551
  * 落 ":id"、`/v1/images/{bakes,select,register}` 落 ":profile"、`/v1/images/bakes/claim` 落