@sema-agent/server 7.2.0 → 7.4.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 (140) hide show
  1. package/README.md +2 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +26 -1
  4. package/dist/approval-ask-machine.d.ts +39 -0
  5. package/dist/approval-ask-machine.js +101 -0
  6. package/dist/approval-card.d.ts +244 -0
  7. package/dist/approval-card.js +237 -0
  8. package/dist/approval-deny-reasons.d.ts +56 -0
  9. package/dist/approval-deny-reasons.js +54 -0
  10. package/dist/approval-reconciler.d.ts +174 -0
  11. package/dist/approval-reconciler.js +307 -0
  12. package/dist/boot/coordinators.d.ts +1 -0
  13. package/dist/boot/coordinators.js +39 -4
  14. package/dist/boot/deferred-sandbox-path-env.d.ts +99 -0
  15. package/dist/boot/deferred-sandbox-path-env.js +279 -0
  16. package/dist/boot/execution-env.js +11 -1
  17. package/dist/boot/lexical-path-env.d.ts +10 -0
  18. package/dist/boot/lexical-path-env.js +88 -0
  19. package/dist/boot/reapers.d.ts +34 -0
  20. package/dist/boot/reapers.js +198 -23
  21. package/dist/boot/resolve-spec.js +97 -33
  22. package/dist/capabilities/center-prompts.js +4 -1
  23. package/dist/capabilities/oa-tools.d.ts +15 -0
  24. package/dist/capabilities/oa-tools.js +54 -0
  25. package/dist/config-types.d.ts +68 -1
  26. package/dist/config.d.ts +1 -0
  27. package/dist/config.js +138 -2
  28. package/dist/elicitation.d.ts +4 -0
  29. package/dist/elicitation.js +7 -3
  30. package/dist/finance/cost-taxonomy.d.ts +34 -0
  31. package/dist/finance/cost-taxonomy.js +26 -0
  32. package/dist/hooks/hook-runner.js +32 -0
  33. package/dist/http/routes/capabilities.js +14 -0
  34. package/dist/http/routes/diagnostics.d.ts +84 -0
  35. package/dist/http/routes/diagnostics.js +140 -0
  36. package/dist/http/routes/runs.d.ts +1 -0
  37. package/dist/http/routes/runs.js +548 -16
  38. package/dist/http/routes/tasks.js +175 -12
  39. package/dist/http/server.d.ts +6 -1
  40. package/dist/http/server.js +120 -4
  41. package/dist/http/sse-log.d.ts +51 -0
  42. package/dist/http/sse-log.js +64 -0
  43. package/dist/http/wire-types.d.ts +20 -5
  44. package/dist/main.js +35 -4
  45. package/dist/observability/fail-open.d.ts +98 -0
  46. package/dist/observability/fail-open.js +216 -0
  47. package/dist/observability/prompt-manifest.d.ts +13 -0
  48. package/dist/observability/prompt-manifest.js +8 -0
  49. package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
  50. package/dist/plugins/approval-ask-store-memory.js +299 -0
  51. package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
  52. package/dist/plugins/approval-ask-store-sql.js +705 -0
  53. package/dist/plugins/approval-store-sql.d.ts +116 -0
  54. package/dist/plugins/approval-store-sql.js +151 -0
  55. package/dist/plugins/background-agent-store-sql.js +20 -1
  56. package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
  57. package/dist/plugins/checkpoint-store-sql.js +297 -16
  58. package/dist/plugins/file-workflow-journal-store.d.ts +12 -0
  59. package/dist/plugins/file-workflow-journal-store.js +12 -0
  60. package/dist/plugins/local-checkpoint-store.d.ts +6 -5
  61. package/dist/plugins/local-checkpoint-store.js +4 -0
  62. package/dist/plugins/pg-approval-store.d.ts +9 -0
  63. package/dist/plugins/pg-approval-store.js +9 -0
  64. package/dist/plugins/pg-breaker-state.d.ts +8 -0
  65. package/dist/plugins/pg-breaker-state.js +8 -0
  66. package/dist/plugins/pg-checkpoint-store.d.ts +10 -0
  67. package/dist/plugins/pg-checkpoint-store.js +10 -0
  68. package/dist/plugins/pg-file-snapshot-store.d.ts +8 -0
  69. package/dist/plugins/pg-file-snapshot-store.js +8 -0
  70. package/dist/plugins/pg-image-bake.d.ts +12 -0
  71. package/dist/plugins/pg-image-bake.js +11 -0
  72. package/dist/plugins/pg-image-index.d.ts +12 -0
  73. package/dist/plugins/pg-image-index.js +11 -0
  74. package/dist/plugins/pg-outcome-ledger.d.ts +12 -0
  75. package/dist/plugins/pg-outcome-ledger.js +11 -0
  76. package/dist/plugins/pg-pool.js +11 -0
  77. package/dist/plugins/pg-resume-anchor-store.d.ts +7 -0
  78. package/dist/plugins/pg-resume-anchor-store.js +7 -0
  79. package/dist/plugins/pg-run-store.d.ts +9 -0
  80. package/dist/plugins/pg-run-store.js +9 -0
  81. package/dist/plugins/pg-session-policy-store.d.ts +7 -0
  82. package/dist/plugins/pg-session-policy-store.js +7 -0
  83. package/dist/plugins/pg-session-store.d.ts +12 -0
  84. package/dist/plugins/pg-session-store.js +12 -0
  85. package/dist/plugins/pg-tool-result-store.d.ts +9 -0
  86. package/dist/plugins/pg-tool-result-store.js +9 -0
  87. package/dist/plugins/pg-workflow-journal-store.d.ts +9 -0
  88. package/dist/plugins/pg-workflow-journal-store.js +9 -0
  89. package/dist/plugins/pg-workflow-run-store.d.ts +9 -0
  90. package/dist/plugins/pg-workflow-run-store.js +9 -0
  91. package/dist/plugins/store-backend.d.ts +18 -0
  92. package/dist/plugins/store-backend.js +10 -0
  93. package/dist/plugins/tidb-approval-store.d.ts +8 -0
  94. package/dist/plugins/tidb-approval-store.js +8 -0
  95. package/dist/plugins/tidb-breaker-state.d.ts +7 -0
  96. package/dist/plugins/tidb-breaker-state.js +7 -0
  97. package/dist/plugins/tidb-checkpoint-store.d.ts +9 -0
  98. package/dist/plugins/tidb-checkpoint-store.js +9 -0
  99. package/dist/plugins/tidb-file-snapshot-store.d.ts +8 -0
  100. package/dist/plugins/tidb-file-snapshot-store.js +8 -0
  101. package/dist/plugins/tidb-image-bake.d.ts +12 -0
  102. package/dist/plugins/tidb-image-bake.js +11 -0
  103. package/dist/plugins/tidb-image-index.d.ts +12 -0
  104. package/dist/plugins/tidb-image-index.js +11 -0
  105. package/dist/plugins/tidb-outcome-ledger.d.ts +12 -0
  106. package/dist/plugins/tidb-outcome-ledger.js +12 -0
  107. package/dist/plugins/tidb-pool.js +27 -4
  108. package/dist/plugins/tidb-resume-anchor-store.d.ts +7 -0
  109. package/dist/plugins/tidb-resume-anchor-store.js +7 -0
  110. package/dist/plugins/tidb-run-store.d.ts +10 -0
  111. package/dist/plugins/tidb-run-store.js +9 -0
  112. package/dist/plugins/tidb-session-policy-store.d.ts +7 -0
  113. package/dist/plugins/tidb-session-policy-store.js +7 -0
  114. package/dist/plugins/tidb-tool-result-store.d.ts +8 -0
  115. package/dist/plugins/tidb-tool-result-store.js +10 -0
  116. package/dist/plugins/tidb-workflow-journal-store.d.ts +9 -0
  117. package/dist/plugins/tidb-workflow-journal-store.js +9 -0
  118. package/dist/plugins/tidb-workflow-run-store.d.ts +10 -0
  119. package/dist/plugins/tidb-workflow-run-store.js +10 -0
  120. package/dist/plugins/workflow-journal-limits.d.ts +12 -0
  121. package/dist/plugins/workflow-journal-limits.js +12 -0
  122. package/dist/question.d.ts +21 -14
  123. package/dist/question.js +83 -34
  124. package/dist/runs.d.ts +16 -1
  125. package/dist/runs.js +61 -3
  126. package/dist/runtime-caps-resolver.d.ts +7 -1
  127. package/dist/runtime-caps-resolver.js +65 -3
  128. package/dist/sema-registry.d.ts +41 -0
  129. package/dist/sema-registry.js +40 -0
  130. package/dist/spec-fields.d.ts +4 -0
  131. package/dist/spec-fields.js +6 -0
  132. package/dist/task-settings.d.ts +36 -15
  133. package/dist/task-settings.js +19 -5
  134. package/dist/tool-approval.d.ts +296 -3
  135. package/dist/tool-approval.js +1074 -50
  136. package/dist/trace/core-keyset-guard.d.ts +2 -2
  137. package/dist/trace/ledger-sink.js +14 -1
  138. package/dist/trace/project.d.ts +90 -0
  139. package/dist/trace/project.js +188 -0
  140. package/package.json +5 -4
@@ -0,0 +1,54 @@
1
+ import { Type } from "typebox";
2
+ import { stablePrompt } from "./prompt.js";
3
+ async function oaFetch(ctx, path, init) {
4
+ if (!ctx.baseUrl || !ctx.token)
5
+ return "OA 后端未配置(OA_API_BASEURL / OA_SERVICE_TOKEN)";
6
+ try {
7
+ const res = await fetch(ctx.baseUrl + path, {
8
+ method: init?.method ?? "GET",
9
+ headers: {
10
+ "content-type": "application/json",
11
+ authorization: `Bearer ${ctx.token}`,
12
+ ...(ctx.principal ? { "x-agent-principal": ctx.principal } : {}),
13
+ },
14
+ body: init?.body !== undefined ? JSON.stringify(init.body) : undefined,
15
+ });
16
+ const text = await res.text();
17
+ if (!res.ok)
18
+ return `OA ${res.status}: ${text.slice(0, 600)}`;
19
+ return text.slice(0, 60_000);
20
+ }
21
+ catch (e) {
22
+ return `调用 OA 失败:${e.message}`;
23
+ }
24
+ }
25
+ const needWrite = (ctx) => (!ctx.principal ? "缺少身份(principal),无法代表用户写库;请让调用方带上 x-agent-principal。" : null);
26
+ export function oaTools(ctx) {
27
+ const obj = Type.Object({}, { additionalProperties: true });
28
+ return [
29
+ { name: "oa_schema", effect: "read", description: "取 OA 数据结构(每类资产的字段/必填/条件必填 requiredWhen/联动 showWhen/枚举、生命周期、表名)。建/改前先调它,别凭记忆。", parameters: Type.Object({}), execute: () => oaFetch(ctx, "/api/v1/meta") },
30
+ { name: "oa_alerts", effect: "read", description: "派生告警:GP 闲置(已注册未变现) / APP 未上架(已接广告未 LIVE)。", parameters: Type.Object({}), execute: () => oaFetch(ctx, "/api/v1/alerts") },
31
+ { name: "oa_list", effect: "read", description: "列出某类资产。type∈gp/package/ad-platform/ad-unit/email/environment/proxy。可选 q 关键字。", parameters: Type.Object({ type: Type.String(), q: Type.Optional(Type.String()) }), execute: (a) => { const x = a; return oaFetch(ctx, `/api/v1/entities/${encodeURIComponent(x.type)}${x.q ? `?q=${encodeURIComponent(x.q)}` : ""}`); } },
32
+ { name: "oa_get", effect: "read", description: "取某资产详情(敏感字段为掩码)。", parameters: Type.Object({ type: Type.String(), id: Type.String() }), execute: (a) => { const x = a; return oaFetch(ctx, `/api/v1/entities/${encodeURIComponent(x.type)}/${encodeURIComponent(x.id)}`); } },
33
+ { name: "oa_lookup", effect: "read", description: "约定字段值反查(同手机/证件=同一实物)。field: phone|idCardNo|bizLicenseNo。", parameters: Type.Object({ field: Type.String(), value: Type.String() }), execute: (a) => { const x = a; return oaFetch(ctx, `/api/v1/lookup?field=${encodeURIComponent(x.field)}&value=${encodeURIComponent(x.value)}`); } },
34
+ { name: "oa_create", effect: "write", description: "创建实体(走 OA 校验+当前用户 RBAC+审计)。type + data(字段对象,参照 oa_schema)。失败会返回缺失/非法字段。", parameters: Type.Object({ type: Type.String(), data: obj }), execute: (a) => { const e = needWrite(ctx); if (e)
35
+ return e; const x = a; return oaFetch(ctx, `/api/assets/${encodeURIComponent(x.type)}`, { method: "POST", body: x.data ?? {} }); } },
36
+ { name: "oa_update", effect: "write", description: "改字段(敏感留空=保持原值;状态字段改不动,用 oa_transition)。", parameters: Type.Object({ type: Type.String(), id: Type.String(), data: obj }), execute: (a) => { const e = needWrite(ctx); if (e)
37
+ return e; const x = a; return oaFetch(ctx, `/api/assets/${encodeURIComponent(x.type)}/${encodeURIComponent(x.id)}`, { method: "PATCH", body: x.data ?? {} }); } },
38
+ { name: "oa_transition", effect: "write", description: "受控状态流转(先 oa_schema 看可用迁移;风险/弃用等需 note)。", parameters: Type.Object({ type: Type.String(), id: Type.String(), to: Type.String(), note: Type.Optional(Type.String()) }), execute: (a) => { const e = needWrite(ctx); if (e)
39
+ return e; const x = a; return oaFetch(ctx, `/api/assets/${encodeURIComponent(x.type)}/${encodeURIComponent(x.id)}/transition`, { method: "POST", body: { to: x.to, note: x.note ?? null } }); } },
40
+ ];
41
+ }
42
+ // OA business prompt MOVED OUT — migration complete (design/29, 2026-06-05). Persona / 工作准则 / UI
43
+ // markers (::FORM:: / ::GOTO:: / ::SUGGEST:: / ::ISSUE_FORM::) / 分级确认 / 报bug流程 are OA's business,
44
+ // and OA now injects the WHOLE thing per request via `request.systemPrompt` (it holds the service token,
45
+ // content is stable per OA version → cacheable prefix; page context still rides `objective`). Keeping
46
+ // any of it here would just DUPLICATE OA's prompt, so the base is EMPTY — single source of truth = OA.
47
+ // The service stays business-agnostic: it provides the oa_* tools (referenced by NAME from OA's prompt;
48
+ // they carry their own descriptions) and nothing else. `stablePrompt` keeps STABLE→VARIABLE order —
49
+ // OA's systemPrompt is appended after this (empty) base, before core's <user_memory>.
50
+ // (::ISSUE_FORM:: is parsed/rendered + submitted entirely by OA's frontend via OA /api/ai/issue — the
51
+ // service never touches it; `open_gitea_issue` remains only as the unattended/automated fallback.)
52
+ const OA_SYSTEM = "";
53
+ export const oaPrompt = stablePrompt(OA_SYSTEM);
54
+ //# sourceMappingURL=oa-tools.js.map
@@ -9,6 +9,7 @@ import type { SealedKeyPoison } from "./sealed-key.js";
9
9
  import type { McpServerSpec, Model, ModelRoles } from "@sema-agent/core";
10
10
  import type { ApprovalHmacKey, PrincipalJwtKey } from "./auth-keys.js";
11
11
  import type { ElicitationThrottle } from "./elicitation.js";
12
+ import type { QuestionThrottle } from "./question.js";
12
13
  import type { InfraCostRates } from "./observability/cost-taxonomy.js";
13
14
  import type { Autonomy, CommandRule } from "./runtime-governance.js";
14
15
  /** A sema-registry MCP server resolved to a core spec (env-NAME refs already → real values) plus the
@@ -21,6 +22,44 @@ export interface ScopedMcpServer {
21
22
  * off (the control plane never mounts). The runner-facing facts (the runner principal, the build-host CACHE_BASE,
22
23
  * the default base ref) live here so image-api injects only VETTED, server-fixed values into the build.sh argv —
23
24
  * operator free-text never reaches the runner. */
25
+ /** #151 车3(design/172 流内审批协议)的配置面。总开关默认 **false**;四个从属旋钮只在开关为真时生效
26
+ * (语义与「谁需要 / 谁被伤 / 什么补偿」三问见 `ServiceConfigFlat.streamApproval` 的注)。 */
27
+ export interface StreamApprovalConfig {
28
+ /** 协议总开关。`STREAM_APPROVAL_ENABLED`,默认 **false**。关 ⇒ 全链逐字现行为。
29
+ * 翻真要等回决端点(车4)与对账收敛器(车5)到位——在此之前是个不完整协议(有卡无正规回决口、
30
+ * 有 PARKING 无收敛器、跨副本回决无唤醒路径)。 */
31
+ enabled: boolean;
32
+ /** 开关为真时作为 `ToolApprovalCoordinator` 的 `ttlMs`(design/172 §3.3 的「配置窗默认」)。
33
+ * `STREAM_ASK_WINDOW_MS`,默认 **60000**。`0` = 运维显式关窗 ⇒ 恒 park(§3.3 窗=0 语义)。
34
+ * 开关为假 ⇒ 本值不参与,窗保持既有 `DEFAULT_APPROVAL_TTL_MS`(5min)。 */
35
+ windowMs: number;
36
+ /** 开流重放(§5)每次最多投几张未决卡——**读面**的帽,超出只投最新的并记一次 warn,开流不失败。
37
+ * `STREAM_APPROVAL_REPLAY_MAX`,默认 **50**。 */
38
+ replayMax: number;
39
+ /** 写侧准入门(§0 X-2)的 per-task 未决 ask 上限。超限 ⇒ 在落 pending/建 timer/发帧**之前**返回
40
+ * `"unavailable"`(park 路由,**永不 deny**——过载 bypass 到 park 是 §3.3 的硬条款)。
41
+ * `STREAM_APPROVAL_ADMIT_MAX_PER_TASK`,默认 **32**。消费点在刀 3b。 */
42
+ admitMaxPerTask: number;
43
+ /** 同上,per-owner(租户)维。`STREAM_APPROVAL_ADMIT_MAX_PER_OWNER`,默认 **256**。 */
44
+ admitMaxPerOwner: number;
45
+ /** #151 车5 §5 治理旋钮(全部 `numEnvBounded` 有界解析,§9 C6)—— 对账收敛器每 tick **每段**
46
+ * (PARKING / 孤儿 STREAM_PENDING)最多处理的行数。超出的下轮接着扫(靠 `deferReconcile` 的队列
47
+ * 轮转保证「接着」是真的,不是永远同一批)。`STREAM_APPROVAL_RECONCILE_BATCH`,默认 **200**,
48
+ * 有界 [1, 10000]。 */
49
+ reconcileBatch: number;
50
+ /** 崩溃恢复扫描的**宽限**(§8 D-1.1):只有 `expiresAtMs + 本值 < now` 的孤儿 `STREAM_PENDING` 行才
51
+ * 被 reaper 代打 `expireAsk`。活属主进程里的窗到期竞争者在这个宽限内常态必胜 ⇒ reaper 退回纯崩溃
52
+ * 兜底,不与在场闭包抢(抢 = 制造挂死面)。`STREAM_APPROVAL_PENDING_GRACE_MS`,默认 **30000**,
53
+ * 有界 [0, 3600000]。 */
54
+ pendingGraceMs: number;
55
+ /** 判据 4(adhoc 腿)的窗后宽限。`STREAM_APPROVAL_ADHOC_GRACE_MS`,默认 **60000**,有界 [0, 86400000]。
56
+ * 🔴 跨旋钮不变量:必须 **< `orphanTtlMs`**(否则判据 5 的兜底会先于判据 4 触发,adhoc 腿拿到的
57
+ * 归因就成了 `orphan_ttl_exceeded`,归因诚实性失效)——boot 期校验,破坏形**拒启**。 */
58
+ adhocGraceMs: number;
59
+ /** 判据 5(遗孤最终可判)的上界,量 immutable 的 `createdAtMs`。`STREAM_APPROVAL_ORPHAN_TTL_MS`,
60
+ * 默认 **7d**,有界 [60000, 90d]。 */
61
+ orphanTtlMs: number;
62
+ }
24
63
  export interface ImageBakeConfig {
25
64
  enabled: boolean;
26
65
  /** The dedicated operator principal the bake-runner authenticates as on the internal claim/ingest routes
@@ -793,12 +832,40 @@ export interface ServiceConfigFlat {
793
832
  * stream (the durable-suspend leg is wired separately). A CC cap → posture-gated (single-user turnkey →
794
833
  * ON; multi-tenant opt-in); `ASK_QUESTION_ENABLED=true/false` overrides. */
795
834
  askQuestionEnabled: boolean;
835
+ /** #166 裁2:AskUserQuestion 的**裁量窗**——每 run 腿的并发帽 / 总量帽 / 无人应答的释放窗。窗用尽或到期
836
+ * 不是拒绝、也不是替人作答:协调器报 `unavailable`,落点(durable park / 非 durable 合成续跑)归 core。
837
+ * `QUESTION_MAX_CONCURRENT_PER_RUN`(默认 2)/ `QUESTION_MAX_TOTAL_PER_RUN`(20)/ `QUESTION_TTL_MS`
838
+ * (300000),越界值启动期响亮拒(照 `MCP_ELICITATION_*` 同门,不静默夹取)。缺席 ⇒ 协调器出厂窗
839
+ * {@link import("./question.js").DEFAULT_QUESTION_THROTTLE}(与本旋钮族默认逐字同值)。 */
840
+ questionThrottle?: QuestionThrottle;
796
841
  /** [816]/[820]② live tool-approval HITL — mount the ToolApprovalCoordinator on `RunnerDeps.onAsk` + the
797
842
  * `POST /v1/tool-approvals/:id/respond` route so a policy `ask` (the fs-write gate, ask-lists, non-durable safety
798
843
  * asks) routes to the live human as the CC three-choice card on the run's stream (core 1.290 sync-ask leg). A CC
799
844
  * cap → posture-gated like askQuestion (single-user turnkey → ON; multi-tenant opt-in);
800
845
  * `TOOL_APPROVAL_ENABLED=true/false` overrides. Absent ⇒ core's headless auto-deny stands ([819]⑤ fail-closed). */
801
846
  toolApprovalEnabled: boolean;
847
+ /** #151 车2(design/172 §3.3 D3,窗长三元的安全余量):`ToolApprovalCoordinator` 的可选 `windowMarginMs`
848
+ * 构造项——有效窗 = `min(ttlMs, legRemainingMs − 本值)`,余量不足 ⇒ 不开窗直接走窗到期同路(park)。
849
+ * 仅在装配点把 `ToolApprovalRunContext.legDeadlineMonotonic` 传给协调器(车3 的活)时才实际生效——本
850
+ * 旋钮本身缺省对现行为零影响(D1)。`STREAM_ASK_WINDOW_MARGIN_MS`,默认 10000(10s)。ttl 概念沿用既有
851
+ * `approvalTimeoutSec` 族,本旋钮不新增第二个 TTL 概念。 */
852
+ streamAskWindowMarginMs: number;
853
+ /** #151 车3(design/172 流内审批协议)总开关 + 四个从属旋钮。**协议整体默认 OFF**——开关关闭时全链
854
+ * 逐字现行为:不发 `approval_request` 帧、不落 `approval_asks` 行、窗 = 既有 `DEFAULT_APPROVAL_TTL_MS`
855
+ * (5min)、`askStore` 不注入协调器。理由(三问):
856
+ * - 谁需要 60s 窗:开了协议的部署——approver 就盯着流、秒级应答,窗短 ⇒ 少占一条活腿、更快落到可恢复
857
+ * 的 park 面。这是协议的设计意图,不是随手取的数。
858
+ * - 谁被伤:①未 opt-in 的存量部署(把 5min 砍成 60s = 人离开工位 90 秒回来卡已经没了);②**park 设施
859
+ * 不在场**的部署——窗到期后 `"unavailable"` 没有降级目的地,core fail-closed deny,60s 会把
860
+ * 「人 90 秒后批准 = 放行」变成「拒绝」,是真行为回归。
861
+ * - 补偿:把 60s 绑在**新旋钮 + 新开关**上,一个字不动 `DEFAULT_APPROVAL_TTL_MS`;两边都不伤,也不给
862
+ * 既有腿新增第二个 TTL 概念。
863
+ *
864
+ * `STREAM_APPROVAL_ENABLED` / `STREAM_ASK_WINDOW_MS` / `STREAM_APPROVAL_REPLAY_MAX` /
865
+ * `STREAM_APPROVAL_ADMIT_MAX_PER_TASK` / `STREAM_APPROVAL_ADMIT_MAX_PER_OWNER` /
866
+ * `STREAM_APPROVAL_RECONCILE_BATCH` / `STREAM_APPROVAL_PENDING_GRACE_MS` /
867
+ * `STREAM_APPROVAL_ADHOC_GRACE_MS` / `STREAM_APPROVAL_ORPHAN_TTL_MS`(后四键 = 车5 收敛器)。 */
868
+ streamApproval: StreamApprovalConfig;
802
869
  /** [824]① (clay A 案 = workflow 权限全面 CC parity): workflow 子 agent 的默认权限基线与主 LLM 同权(base `{}`)。
803
870
  * 安全论证:主 LLM 与子 agent 同 root 同信任域,主 LLM 本就能写这棵树([816] ask 门照管),单独钳子 agent 的
804
871
  * 安全增益≈0(只防绕路不防直路的门不是边界);实测产品代价=[814]A 死锁。`WORKFLOW_AGENTS_READONLY=true` =
@@ -915,7 +982,7 @@ export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "ses
915
982
  export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "cascadeLadder" | "degrade">;
916
983
  /** 组:approval(审批 / HITL 门)。`directDoorActive` 无 env 解析腿(装配层三域合取的产物),但语义上
917
984
  * 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
918
- export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "toolApprovalEnabled" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
985
+ export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "streamAskWindowMarginMs" | "streamApproval" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate">;
919
986
  /** 组:memory(记忆面 + TOC 同步腿)。 */
920
987
  export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memorySync" | "memoryOrgAdmissionMode" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
921
988
  /** 组:auth(鉴权 / 身份 / 治理棒)。`commandPolicy` 只有 sema-registry 腿(无 env 标量形),故 env 解析
package/dist/config.d.ts CHANGED
@@ -60,6 +60,7 @@ export declare function drainConfigNotices(): Array<{
60
60
  * (run-local historically did exactly that), so the drain lives beside the collectors, not in one entrypoint. */
61
61
  export declare function logConfigDiagnostics(logger: {
62
62
  warn: (event: string, fields?: Record<string, unknown>) => void;
63
+ info?: (event: string, fields?: Record<string, unknown>) => void;
63
64
  debug?: (event: string, fields?: Record<string, unknown>) => void;
64
65
  }): void;
65
66
  /** design/158 N8/N9 — the BOOLEAN member of this module's env-parser family (`env`/`env2`/`numEnv`/
package/dist/config.js CHANGED
@@ -5,6 +5,7 @@ import { CODE_AGENT_PROMPT, formatUserScope, isThinkingLevel, PROTOCOL_TABLE, pr
5
5
  import { ROSTER_PRIMARY_ROLES, ROSTER_CHEAP_ROLES } from "@sema-agent/registry-core";
6
6
  import { parseApprovalHmacKeys, parsePrincipalJwks } from "./auth-keys.js"; // design/158 A4: the parser leaf — NOT security.js (base config layer must not value-import the 55KiB auth module)
7
7
  import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
8
+ import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
8
9
  function csv(name) {
9
10
  return (process.env[name] ?? "")
10
11
  .split(",")
@@ -188,6 +189,30 @@ const CONFIG_NOTICES = [];
188
189
  export function drainConfigNotices() {
189
190
  return CONFIG_NOTICES.splice(0, CONFIG_NOTICES.length);
190
191
  }
192
+ /** #157([2762]§五,F 类观测,零行为变化)—— 本次 boot 的执行车道落点 + 它的来源。
193
+ * 为什么单开一格:REMOTE_EXEC 未设/空串时的落点由 CONFIG_PROVIDER 隐式决定(local ⇒ host 真机,其余 ⇒
194
+ * 进程内 stub),而这个落点此前在日志里零留痕 —— 运维拿到一台 worker,无从确认「它跑的是哪条执行通道、
195
+ * 是显式配的还是默认落的」。与 CONFIG_WARNINGS / CONFIG_NOTICES 同为 pre-logger 收集(config 解析早于
196
+ * logger 建立),但级别是 **info**:这是每次 boot 都成立的常态事实,不是异常,而 CONFIG_NOTICES 那一格
197
+ * 固定按 warn 排空。
198
+ * 单槽(不是队列):一次 boot 只有一个落点;重复 loadConfig(测试)覆写即可,不会堆积。
199
+ * ⚠️ 写入时机=装配尾(见 loadConfig),不是解析途中:写早了,任何**后置**拒启(limits 域数值、跨域
200
+ * 不变量)都会留下一条「其实没启起来」的落点;而 loadConfig 开头的清槽保证失败的一轮不会把**上一轮**
201
+ * 的落点串给下一次排空(交叉复审 medium,2026-08-06)。
202
+ * ⚠️ 边界:记的是 **ENV 解析出的**落点。local 形的 `config.d/remote-exec.json` 覆写发生在 main.ts 的
203
+ * applyLocalRemoteExec(晚于本行排空),它自己另记 `remote_exec_from_file` —— 两行合读才是最终 lane。 */
204
+ let EXEC_LANE_NOTICE;
205
+ /** 从**已装配好的** remoteExec 派生落点一行(而不是在 REMOTE_EXEC 那条三元链的各臂里分别记账):
206
+ * 分臂记账要靠「校验过了 ⇒ 必落对应那一臂」这条人肉维护的同源假设,链子哪天多一臂就悄悄失真;
207
+ * 取装配结果则天然跟随。`in-process` = REMOTE_EXEC 未设的进程内 stub 姿态,沿用既有词表
208
+ * (config-center/types.ts 的 provider 词表、run-local 的 `?? "in-process"` 同名)。 */
209
+ function buildExecLaneNotice(remoteExec) {
210
+ const explicit = process.env.REMOTE_EXEC !== undefined && process.env.REMOTE_EXEC !== "";
211
+ return {
212
+ lane: remoteExec?.provider ?? "in-process",
213
+ source: explicit ? "explicit-env" : process.env.CONFIG_PROVIDER === "local" ? "default-local-provider" : "default-unset",
214
+ };
215
+ }
191
216
  /** F10 (codex audit): drain + log BOTH pre-logger diagnostic collectors (S20 soft-knob warnings + [792]④ boot
192
217
  * notices) in one place. EVERY entrypoint that calls loadConfig() then builds a logger must call this right
193
218
  * after (main.ts, run-local.ts) — a new entry that forgets it would silently drop operator-facing diagnostics
@@ -197,6 +222,13 @@ export function logConfigDiagnostics(logger) {
197
222
  logger.warn("config_env_invalid_using_default", { env: w.env, raw: w.raw });
198
223
  for (const n of drainConfigNotices())
199
224
  logger.warn(n.event, n.fields);
225
+ // #157 F 类观测:执行车道落点一行(见 EXEC_LANE_NOTICE 头注)。无条件发 —— boot 期本来就只跑一次,
226
+ // 不需要 once-per-hit 抑制。先取后清:排空语义与上面两格一致(重复调用不重放同一条)。`info` 与
227
+ // `debug` 同为可选,理由同上 —— 老 embedder 传进来的 warn-only logger 仍能编译、仍拿得到警告。
228
+ const laneNotice = EXEC_LANE_NOTICE;
229
+ EXEC_LANE_NOTICE = undefined;
230
+ if (laneNotice && logger.info)
231
+ logger.info("exec_lane_selected", { lane: laneNotice.lane, source: laneNotice.source });
200
232
  // design/158 N9 (polarity self-report): a `*_ENABLED` suffix does NOT carry its default — the repo grew four
201
233
  // dialects for it (opt-in / opt-out / posture tri-state / negative name). Rather than force operators to read
202
234
  // config.ts to learn what a knob does when unset, boolEnv-parsed knobs register themselves and we print one
@@ -874,6 +906,45 @@ export function formatUnmatchableToolNames(source, bad) {
874
906
  return (`${source} names tool(s) that can never match a live tool (core 5.0.0 RB-476 — the alias/auto-prefix folds are gone): ` +
875
907
  bad.map((b) => `${b.name} → ${b.guidance}`).join("; "));
876
908
  }
909
+ /**
910
+ * #151 流内审批协议(design/172)的九键段(车3 刀 3a 五键 + 车5 收敛器四键)。
911
+ *
912
+ * **总开关默认 false** ⇒ 全链逐字现行为(不发 approval_request、不落 ask 行、不起收敛器腿、窗仍是
913
+ * `DEFAULT_APPROVAL_TTL_MS`)。窗/帽四键沿用 `numEnv` fail-loud 形(坏形启动期炸,不静默折 NaN);
914
+ * 车5 的四键一律 **`numEnvBounded`**(§9 C6:batch 整数 ≥1 有上帽,grace/TTL 非负有界)——它们直接
915
+ * 决定 reaper 每 tick 的库压与「多久算遗孤」,一个手滑的 `0` 或 `1e12` 都是运维事故面。
916
+ *
917
+ * 🔴 跨旋钮不变量(§9 C6,`#157` 三分类 P 族姿势):`ADHOC_GRACE < ORPHAN_TTL`。破坏形**拒启** ——
918
+ * 若 adhoc 宽限 ≥ 遗孤 TTL,判据 5 会先于判据 4 触发,一条 adhoc 腿拿到的归因就成了
919
+ * `orphan_ttl_exceeded`(「遗孤兜底」)而不是 `adhoc_leg_no_durable_domain`(「结构上无对账域」),
920
+ * 审计面从此读不出真实成因。默认值(60s vs 7d)自然满足;只有显式改坏才会撞上。
921
+ */
922
+ function streamApprovalConfig() {
923
+ const cfg = {
924
+ enabled: boolEnv("STREAM_APPROVAL_ENABLED", false),
925
+ windowMs: numEnv("STREAM_ASK_WINDOW_MS", "60000"),
926
+ replayMax: numEnv("STREAM_APPROVAL_REPLAY_MAX", "50"),
927
+ admitMaxPerTask: numEnv("STREAM_APPROVAL_ADMIT_MAX_PER_TASK", "32"),
928
+ admitMaxPerOwner: numEnv("STREAM_APPROVAL_ADMIT_MAX_PER_OWNER", "256"),
929
+ reconcileBatch: numEnvBounded("STREAM_APPROVAL_RECONCILE_BATCH", "200", 1, 10_000),
930
+ pendingGraceMs: numEnvBounded("STREAM_APPROVAL_PENDING_GRACE_MS", "30000", 0, 3_600_000),
931
+ adhocGraceMs: numEnvBounded("STREAM_APPROVAL_ADHOC_GRACE_MS", "60000", 0, 86_400_000),
932
+ orphanTtlMs: numEnvBounded("STREAM_APPROVAL_ORPHAN_TTL_MS", String(7 * 24 * 60 * 60 * 1000), 60_000, 90 * 24 * 60 * 60 * 1000),
933
+ };
934
+ // 🔴 不变量的**正确形**(codex 交叉复审 C7,2026-08-06 验真):两条臂量的不是同一个起点 ——
935
+ // 判据 4 在 `expiresAtMs + ADHOC_GRACE` 触发,而 `expiresAtMs ≈ createdAtMs + WINDOW`;判据 5 在
936
+ // `createdAtMs + ORPHAN_TTL` 触发。只比 `ADHOC_GRACE < ORPHAN_TTL` 会漏掉窗:例如 WINDOW 与
937
+ // ORPHAN_TTL 等长时校验放行,但遗孤兜底必定先到,adhoc 腿的归因被写成 `orphan_ttl_exceeded`。
938
+ // 正确判据 = `WINDOW + ADHOC_GRACE < ORPHAN_TTL`(两边都是有界有限值,不会溢出)。
939
+ if (cfg.windowMs + cfg.adhocGraceMs >= cfg.orphanTtlMs) {
940
+ throw new Error(`env STREAM_ASK_WINDOW_MS=${cfg.windowMs} + STREAM_APPROVAL_ADHOC_GRACE_MS=${cfg.adhocGraceMs} must be STRICTLY LESS than STREAM_APPROVAL_ORPHAN_TTL_MS=${cfg.orphanTtlMs} — the adhoc arm fires at (createdAt + window + grace) while the orphan backstop fires at (createdAt + ttl); if the backstop wins, every adhoc-leg row is attributed "orphan_ttl_exceeded" instead of "adhoc_leg_no_durable_domain" (attribution honesty, design/172 §6.1)`);
941
+ }
942
+ // 整数门(batch 是行数,不是时长):`numEnvBounded` 只管区间,`200.5` 会一路走到 SQL `LIMIT` 上。
943
+ if (!Number.isInteger(cfg.reconcileBatch)) {
944
+ throw new Error(`env STREAM_APPROVAL_RECONCILE_BATCH=${cfg.reconcileBatch} must be an INTEGER row count`);
945
+ }
946
+ return cfg;
947
+ }
877
948
  function parseApprovalDomain(ctx) {
878
949
  const { postureOn } = ctx; // 跨域入参②:posture 三态(single-user turnkey ⇒ HITL 面默认 ON)
879
950
  // design/80 D-G: the direct-connect approval door anchors (all must be present to ACTIVATE; fail-closed).
@@ -928,7 +999,21 @@ function parseApprovalDomain(ctx) {
928
999
  },
929
1000
  },
930
1001
  askQuestionEnabled: postureOn("ASK_QUESTION_ENABLED"), // §4④ AskUserQuestion live HITL; posture-gated (single-user → ON)
1002
+ // #166 裁2:AskUserQuestion 裁量窗三旋钮,姿势照 MCP_ELICITATION_* 抄(bounded ⇒ 越界启动期拒,不静默
1003
+ // 夹取)。默认值 = 协调器出厂窗逐字(纯旋钮化,零行为改动)。
1004
+ questionThrottle: {
1005
+ maxConcurrentPerRun: numEnvBounded("QUESTION_MAX_CONCURRENT_PER_RUN", String(DEFAULT_QUESTION_THROTTLE.maxConcurrentPerRun), 1, 64),
1006
+ maxTotalPerRun: numEnvBounded("QUESTION_MAX_TOTAL_PER_RUN", String(DEFAULT_QUESTION_THROTTLE.maxTotalPerRun), 1, 10_000),
1007
+ ttlMs: numEnvBounded("QUESTION_TTL_MS", String(DEFAULT_QUESTION_THROTTLE.ttlMs), 1_000, 3_600_000),
1008
+ },
931
1009
  toolApprovalEnabled: postureOn("TOOL_APPROVAL_ENABLED"), // [816]/[820]② live tool-approval HITL; posture-gated (single-user → ON), mirrors askQuestion
1010
+ // #151 车2(design/172 §3.3 D3):ToolApprovalCoordinator 窗长三元的安全余量。numEnv fail-loud 形,
1011
+ // 照邻居旋钮(approvalTimeoutSec 等)抄——坏形(非数字)在启动期炸,不静默折成 NaN。
1012
+ streamAskWindowMarginMs: numEnv("STREAM_ASK_WINDOW_MARGIN_MS", "10000"),
1013
+ // #151 车3 刀 3a(design/172 流内审批协议):五键。**总开关默认 false** ⇒ 全链逐字现行为(不发
1014
+ // approval_request、不落 ask 行、窗仍是 DEFAULT_APPROVAL_TTL_MS)。窗/帽四键与邻居旋钮同 numEnv
1015
+ // fail-loud 形——坏形在启动期炸,不静默折 NaN。语义与三问分析见 config-types.ts 的 StreamApprovalConfig。
1016
+ streamApproval: streamApprovalConfig(),
932
1017
  // [875]a 成文:0(缺省)= durable HITL 无限期等人,时间型 reapSuspended 不跑;file/memory lane 的回收
933
1018
  // 探针只在 DURABLE_APPROVAL=true 时注入(无 durable 的部署两只 suspended 回收器恒 NO-OP,parked 行的
934
1019
  // 恢复把手 = POST /v1/runs/:id/cancel,[868]①)。checkpoint 过期驱动的那只不受本旋钮门控。
@@ -1099,6 +1184,37 @@ function parseOrchestrationDomain(ctx) {
1099
1184
  }
1100
1185
  return boolEnv("LSP_HOST_ENABLED", true);
1101
1186
  })();
1187
+ // ── #157([2755] tag `server.exec-lane.silent-downgrade-to-host`,P 类车道选择)——执行车道的
1188
+ // fail-closed 校验。旧形:REMOTE_EXEC 点名了一条 lane 但必需 env 不全(或值拼错不在闭集里)时,下面
1189
+ // 三元链的合取逐臂失败、静默滑穿到尾部 ⇒ CONFIG_PROVIDER=local 落 host(worker 本机、无隔离)、否则落
1190
+ // in-process stub,且两种跑法的成功回执同形(status=completed,test [2753]§2 实测「在哪儿跑的」对外
1191
+ // 零信息量)。操作员配了沙箱却在真机上跑 = P 类判据表的样板案,必须拒启点名,不静默降级。
1192
+ // 语义边界:REMOTE_EXEC **未设**(或空串,env 管理器的显式「不选」形)不是降级,是文档化的部署姿态
1193
+ // (local ⇒ host 默认,fleet ⇒ stub),照旧;拒启只咬「点了名却配不齐/认不出」。缺失点名要**准**——
1194
+ // 只列缺的件,不列 lane 的全量需求(WORKFLOW_SIZE_GUIDELINE / MEMORY_SYNC_* 半配拒启同族先例)。
1195
+ {
1196
+ const lane = process.env.REMOTE_EXEC;
1197
+ if (lane !== undefined && lane !== "") {
1198
+ const REQUIRED_ENVS_BY_LANE = {
1199
+ e2b: ["E2B_API_KEY"],
1200
+ k8s: ["K8S_SANDBOX_IMAGE"],
1201
+ ssh: ["SSH_HOST", "SSH_USER", "SSH_PRIVATE_KEY"],
1202
+ adb: ["ADB_SERIAL"],
1203
+ host: [],
1204
+ "local-docker": ["DOCKER_IMAGE"],
1205
+ };
1206
+ const required = Object.hasOwn(REQUIRED_ENVS_BY_LANE, lane) ? REQUIRED_ENVS_BY_LANE[lane] : undefined;
1207
+ if (required === undefined) {
1208
+ throw new Error(`REMOTE_EXEC="${lane}" is not an execution lane — valid: e2b | k8s | ssh | adb | host | local-docker. ` +
1209
+ `An unrecognized value used to fall through to the host/in-process lane silently; refusing to start instead (#157).`);
1210
+ }
1211
+ const missing = required.filter((name) => !process.env[name]);
1212
+ if (missing.length > 0) {
1213
+ throw new Error(`REMOTE_EXEC=${lane} but required env ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} missing/empty — ` +
1214
+ `refusing to start rather than silently downgrading to the host/in-process lane (#157; unset REMOTE_EXEC if you meant no sandbox lane).`);
1215
+ }
1216
+ }
1217
+ }
1102
1218
  return {
1103
1219
  remoteExec: process.env.REMOTE_EXEC === "e2b" && process.env.E2B_API_KEY
1104
1220
  ? {
@@ -1375,8 +1491,8 @@ const MODEL_PLANE_GROUP_KEYS = [
1375
1491
  const APPROVAL_GROUP_KEYS = [
1376
1492
  "approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
1377
1493
  "approvalHmacKeys", "durableApproval", "directApprovalDoor", "directDoorActive", "resourceSuspend",
1378
- "resourceSuspendTtlSec", "askQuestionEnabled", "toolApprovalEnabled", "mcpElicitation", "sensitiveWritePatterns",
1379
- "manualModeShellGate",
1494
+ "resourceSuspendTtlSec", "askQuestionEnabled", "questionThrottle", "toolApprovalEnabled", "streamAskWindowMarginMs", "streamApproval", "mcpElicitation",
1495
+ "sensitiveWritePatterns", "manualModeShellGate",
1380
1496
  ];
1381
1497
  const MEMORY_GROUP_KEYS = [
1382
1498
  "memoryEngineEnabled", "memoryEngineDir", "memoryEngineRemoteLaneAllowed", "memoryEngineBackend", "memoryScope",
@@ -1459,6 +1575,11 @@ export function attachConfigGroups(flat) {
1459
1575
  return flat;
1460
1576
  }
1461
1577
  export function loadConfig() {
1578
+ // #157:落点清槽必须排在**第一条**语句 —— 比下面那组同类复位还早一格,因为 rejectRetiredEnvs 本身会抛,
1579
+ // 而一轮抛出的 loadConfig 绝不能把**上一轮**成功解析的落点留在槽里被下一次排空读成本轮事实
1580
+ // (交叉复审第二轮,2026-08-06)。下面三格同族收集器在这条最早拒启路径上仍有同形残留,那是既有面、
1581
+ // 且都按 warn 排空,不在本件射程内。
1582
+ EXEC_LANE_NOTICE = undefined;
1462
1583
  rejectRetiredEnvs(); // [2354] 退役 env 墓碑:在场即拒启带新名指引
1463
1584
  CONFIG_WARNINGS.length = 0; // repeated loadConfig() calls (test setup) must not accumulate stale warnings
1464
1585
  CONFIG_NOTICES.length = 0;
@@ -1528,6 +1649,18 @@ export function loadConfig() {
1528
1649
  throw new Error(`DIRECT_APPROVAL_DOOR=true but D-G anchors are missing: ${missing.join(", ")} — refusing to start a half-open direct door (design/80 D-G boot invariant)`);
1529
1650
  }
1530
1651
  }
1652
+ // 🔴 boot invariant (#157-①, silent-downgrade-batch-20260805 普查候选 1/2): the BFF multi-tenant durable form
1653
+ // (DURABLE_APPROVAL=true + REQUIRE_PRINCIPAL=true, direct door OFF) had NO invariant — the D-G one above only
1654
+ // binds when DIRECT_APPROVAL_DOOR=true. On that form the four READ surfaces in approvals-assistant.ts
1655
+ // (GET /v1/approvals, /stream, /v1/assistant/inbox, /tasks) scope by the bare isOperator, whose empty-list
1656
+ // "true-for-all" lets ANY authenticated tenant principal list/subscribe every other tenant's pending queue
1657
+ // (redacted tool-input previews + decide-binding fields) and inspect arbitrary owners via ?owner. The WRITE
1658
+ // surfaces (decide/preempt/resume/plan_review) already use explicitOperatorOk and were never exposed. The
1659
+ // single-tenant legacy semantic (REQUIRE_PRINCIPAL unset — the shared service token IS the operator boundary)
1660
+ // is deliberately preserved: only the multi-tenant form is a hole. Refuse to start it.
1661
+ if (dgDurable && requirePrincipal && dgOperators.length === 0) {
1662
+ throw new Error("DURABLE_APPROVAL=true + REQUIRE_PRINCIPAL=true but OPERATOR_PRINCIPALS is empty — on a multi-tenant worker the empty operator set makes isOperator() true-for-all, so every authenticated tenant could read every other tenant's pending-approval queue (GET /v1/approvals & siblings). Set OPERATOR_PRINCIPALS, or unset REQUIRE_PRINCIPAL if this is a single-tenant deployment (#157-① boot invariant)");
1663
+ }
1531
1664
  // 🔴 boot invariant (IMAGE-API-DESIGN.md §P2.4a): the bake door is build-host-RCE-capable, and isOperator([],p)
1532
1665
  // is true-for-all — so enabling bakes with an empty OPERATOR_PRINCIPALS = a WORLD-WRITABLE RCE door. Refuse to
1533
1666
  // start (never a half-open bake door). The explicit-operator gate in server.ts is the runtime twin of this.
@@ -1541,6 +1674,9 @@ export function loadConfig() {
1541
1674
  if (imageBakesEnabled && !Object.values(authTokens).includes(bakeRunnerPrincipal)) {
1542
1675
  throw new Error(`IMAGE_BAKES_ENABLED=true but no SERVICE_AUTH_TOKENS entry maps to the runner principal '${bakeRunnerPrincipal}' — the bake-runner authenticates by its credential (token-derived source), so a '<BAKE_RUNNER_TOKEN>=${bakeRunnerPrincipal}' SERVICE_AUTH_TOKENS entry is required or claim/ingest/heartbeat all 403 (IMAGE-API-DESIGN.md §P2.12 boot invariant)`);
1543
1676
  }
1677
+ // #157 F 类观测:执行车道落点记账放在这里 —— 九域解析与上面全部跨域不变量都过了之后,即「这一轮
1678
+ // 确实启得起来」才成立的位置(写在 REMOTE_EXEC 解析途中的话,后置拒启会留下一条没启起来的落点)。
1679
+ EXEC_LANE_NOTICE = buildExecLaneNotice(orchestration.remoteExec);
1544
1680
  // 平铺面**单点展开**九域产物(唯一存储处),再挂上九个组视图。组视图是对这同一个对象的取景器,
1545
1681
  // 不是第二份拷贝 —— 故 `config.limitsHttp.port === config.port` 恒成立,含热应用之后(见
1546
1682
  // attachConfigGroups / config-types.ts 分组段)。
@@ -15,6 +15,10 @@ export interface ElicitationFrame {
15
15
  mode?: "form";
16
16
  /** "elicitation_complete" only: how it resolved. */
17
17
  action?: McpElicitResponse["action"];
18
+ /** Server-signed emit timestamp (additive, [2805]§五→[2806]). These side-frames are SERVER-minted — they have
19
+ * no core-stream anchor, so no `eventId` (absence is honest, not an omission); this is the frame's own time
20
+ * coordinate instead, same posture as the stream-approval frames' `serverNowMs`. */
21
+ serverNowMs?: number;
18
22
  }
19
23
  /** The per-run context `onElicit` recovers via ALS. `emit` delivers a frame to the run's live stream;
20
24
  * `abortSignal` (the run's own cancel signal) lets a run-abort promptly cancel an elicitation parked awaiting a
@@ -27,6 +27,7 @@
27
27
  import { AsyncLocalStorage } from "node:async_hooks";
28
28
  import { uuidv7, delimitUntrusted } from "@sema-agent/core";
29
29
  import { redactSecrets, redactDeep } from "./trace/redact.js";
30
+ import { recordFailOpen } from "./observability/fail-open.js";
30
31
  /** Size bounds on the UNTRUSTED server-controlled payload (the throttle bounds COUNT, these bound SIZE — an
31
32
  * opted-in-but-hostile server otherwise amplifies a multi-MB prompt/schema into the durable log + SSE socket).
32
33
  * The message is a human-facing prompt (a few KB is ample); a requestedSchema over the cap is dropped (the form
@@ -182,11 +183,13 @@ export class ElicitationCoordinator {
182
183
  // cursor (an invisible prompt that then TTL-declines). On a successful emit, charge the per-run-tree budget; a
183
184
  // FAILED emit declines (nobody can answer) and must NOT burn maxTotal / the per-server interval.
184
185
  try {
185
- await ctx.emit({ type: "elicitation", elicitationId: id, mcpServerName: req.server, message, ...(requestedSchema !== undefined ? { requestedSchema } : {}), mode: "form" });
186
+ await ctx.emit({ type: "elicitation", elicitationId: id, mcpServerName: req.server, message, ...(requestedSchema !== undefined ? { requestedSchema } : {}), mode: "form", serverNowMs: this.now() });
186
187
  rc.total += 1;
187
188
  rc.lastByServer.set(req.server, t);
188
189
  }
189
190
  catch {
191
+ // #157:投递失败 ⇒ decline。方向已是 fail-closed(问不到人就是拒),缺的只是留痕。
192
+ recordFailOpen("server.elicitation.open-frame-undelivered", `elicitation=${id} server=${req.server}`);
190
193
  settle({ action: "decline" }); // inflight rolled back in settle
191
194
  }
192
195
  const answer = await answerP;
@@ -194,8 +197,9 @@ export class ElicitationCoordinator {
194
197
  // returning the answer to core (which replies to the MCP server on the still-open connection). Best-effort +
195
198
  // ordering-uncritical: the shell already has the answer via respond; a late/lost dismiss is cosmetic.
196
199
  void Promise.resolve()
197
- .then(() => ctx.emit({ type: "elicitation_complete", elicitationId: id, mcpServerName: req.server, action: answer.action }))
198
- .catch(() => undefined);
200
+ .then(() => ctx.emit({ type: "elicitation_complete", elicitationId: id, mcpServerName: req.server, action: answer.action, serverNowMs: this.now() }))
201
+ // #157:F 类兜底,与 question.ts 的孪生面同形同 tag 形(丢收尾渲染,不丢答案)
202
+ .catch(() => recordFailOpen("server.elicitation.complete-breadcrumb-dropped", `elicitation=${id}`));
199
203
  return answer;
200
204
  };
201
205
  /** `POST /v1/elicitations/:id/respond` — resolve a parked elicitation with the shell's answer. Owner-gated with a
@@ -0,0 +1,34 @@
1
+ export interface InfraCostRates {
2
+ toolCallMicroUsd: number;
3
+ sandboxSecMicroUsd: number;
4
+ egressGbMicroUsd: number;
5
+ }
6
+ export interface InfraUsage {
7
+ toolCalls: number;
8
+ sandboxWalltimeMs: number;
9
+ egressBytes: number;
10
+ }
11
+ export interface InfraCostBreakdown {
12
+ toolCallMicroUsd: number;
13
+ sandboxWalltimeMicroUsd: number;
14
+ egressMicroUsd: number;
15
+ totalMicroUsd: number;
16
+ }
17
+ export interface LlmCostBreakdown {
18
+ llmRootMicroUsd: number;
19
+ nestedSubagentMicroUsd: number;
20
+ memoryConsolidationMicroUsd: number;
21
+ compactionMicroUsd: number;
22
+ }
23
+ export interface SupervisorCostBreakdown {
24
+ llm: LlmCostBreakdown | null;
25
+ infra: InfraCostBreakdown;
26
+ totalMicroUsd: number;
27
+ }
28
+ export declare function infraCost(usage: InfraUsage, rates: InfraCostRates): InfraCostBreakdown;
29
+ export declare function composeSupervisorCost(llm: LlmCostBreakdown | null, llmTotalMicroUsd: number, infra: InfraCostBreakdown): SupervisorCostBreakdown;
30
+ export declare function hasInfraPricing(r: InfraCostRates): boolean;
31
+ export declare function infraUsageFromEvents(events: ReadonlyArray<{
32
+ type?: string;
33
+ }>, runDurationMs: number): InfraUsage;
34
+ //# sourceMappingURL=cost-taxonomy.d.ts.map
@@ -0,0 +1,26 @@
1
+ const nonNeg = (n) => (Number.isFinite(n) && n > 0 ? n : 0);
2
+ export function infraCost(usage, rates) {
3
+ const toolCallMicroUsd = nonNeg(usage.toolCalls) * nonNeg(rates.toolCallMicroUsd);
4
+ const sandboxWalltimeMicroUsd = (nonNeg(usage.sandboxWalltimeMs) / 1000) * nonNeg(rates.sandboxSecMicroUsd);
5
+ const egressMicroUsd = (nonNeg(usage.egressBytes) / 1_000_000_000) * nonNeg(rates.egressGbMicroUsd);
6
+ return {
7
+ toolCallMicroUsd,
8
+ sandboxWalltimeMicroUsd,
9
+ egressMicroUsd,
10
+ totalMicroUsd: toolCallMicroUsd + sandboxWalltimeMicroUsd + egressMicroUsd,
11
+ };
12
+ }
13
+ export function composeSupervisorCost(llm, llmTotalMicroUsd, infra) {
14
+ return { llm, infra, totalMicroUsd: nonNeg(llmTotalMicroUsd) + infra.totalMicroUsd };
15
+ }
16
+ export function hasInfraPricing(r) {
17
+ return r.toolCallMicroUsd > 0 || r.sandboxSecMicroUsd > 0 || r.egressGbMicroUsd > 0;
18
+ }
19
+ export function infraUsageFromEvents(events, runDurationMs) {
20
+ return {
21
+ toolCalls: events.reduce((n, e) => (e.type === "tool_start" ? n + 1 : n), 0),
22
+ sandboxWalltimeMs: nonNeg(runDurationMs),
23
+ egressBytes: 0,
24
+ };
25
+ }
26
+ //# sourceMappingURL=cost-taxonomy.js.map
@@ -706,6 +706,10 @@ export function createTaskHooks(config, ctx) {
706
706
  let currentInput = input;
707
707
  let rewritten = false;
708
708
  let asked;
709
+ // #157 fail-open 治理(P 类,tag `server.hooks.pretooluse-unknown-permission-word`):本轮见过
710
+ // 词表外的 `permissionDecision`。安全域闭集词表的 miss 不是"没意见"——钩子作者**表过态**,只是
711
+ // 用了我们不认识的词;把它折成放行等于替他改判。置位后禁止本轮铸 `action:"allow"`(见末尾折叠)。
712
+ let unknownDecision = false;
709
713
  const contexts = [];
710
714
  const deadlineAt = Date.now() + MAX_HOOK_EVENT_TOTAL_SECONDS * 1000; // 跨全部组的事件级预算
711
715
  // 逐条串行(deny 立即短路;updatedInput 串进后续条目 payload——确定性优先,CC 的并行聚合语义
@@ -771,6 +775,18 @@ export function createTaskHooks(config, ctx) {
771
775
  rewritten = true;
772
776
  }
773
777
  }
778
+ else if (decision !== "deny" && decision !== "ask") {
779
+ // 闭集 miss(拼错大小写的 deny / 自造词 / 非字符串 / null)。该条目的裁决**整条不采信**:
780
+ // 它的 updatedInput 也不进串(上面的 if 已排除),并置位禁 allow。CC 的 `allow`/`deny`/
781
+ // `ask` 三词是唯一词表;新词入表须同时进这里,否则它在本仓的语义就是"不采信"。
782
+ unknownDecision = true;
783
+ ctx.logger.warn("hook_permission_decision_unknown", {
784
+ event: "PreToolUse",
785
+ decision: clip(typeof decision === "string" ? decision : typeof decision, 200),
786
+ tool: toolName,
787
+ effect: "hook ruling not honored (closed word set is allow|deny|ask); this call falls back to the engine gate",
788
+ });
789
+ }
774
790
  }
775
791
  }
776
792
  }
@@ -785,6 +801,22 @@ export function createTaskHooks(config, ctx) {
785
801
  ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
786
802
  };
787
803
  }
804
+ if (unknownDecision) {
805
+ // #157:本轮有一条**不采信**的裁决 ⇒ 一律折 `ask`(人在环)。两条都不行,各有其害:
806
+ // · `allow` —— 显式放行短路,引擎门不再裁决,等于替钩子作者把 DENY 改判成放行;
807
+ // · `undefined`(无裁决)—— 看着中立,其实**不是** fail-closed(codex 复审 HIGH,2026-08-07):
808
+ // 引擎拿到无裁决就走普通门,而 bypassPermissions / acceptEdits 档下普通门就是放行,
809
+ // 钩子作者写的 DENY 照样跑。词表 miss 的语义是「保护型判词坏了」而不是「钩子没表态」。
810
+ // 已有改写则随 `ask` 投递:改写通常正是 redact/clamp,丢了它 approve 后执行的是【原始】args
811
+ // —— `ask` 是唯一既 fail-closed 又能带 updatedInput 的裁决形。不采信的那条自己的改写不在其中
812
+ // (上面的 if 已把它挡在串外),否则一个坏判词就是一条免审的 args 改写通道。
813
+ return {
814
+ action: "ask",
815
+ message: "a PreToolUse hook returned an unrecognized permissionDecision — approval required",
816
+ ...(rewritten ? { updatedInput: currentInput } : {}),
817
+ ...(contexts.length ? { additionalContext: contexts.join("\n") } : {}),
818
+ };
819
+ }
788
820
  if (rewritten || contexts.length > 0) {
789
821
  return {
790
822
  action: "allow",
@@ -2,6 +2,7 @@ import { DEFAULT_EFFORT_LEVELS } from "@sema-agent/core";
2
2
  import { cwdHonored } from "../../task-cwd.js";
3
3
  import { mcpInjectionHonored } from "../../task-mcp.js";
4
4
  import { sendJson, sendError } from "../send.js";
5
+ import { resolveStreamApprovalGate } from "../../tool-approval.js";
5
6
  export async function handleCapabilities(req, res, url, ctx) {
6
7
  const miss = { fell: false };
7
8
  await handleCapabilitiesBody(req, res, url, ctx, miss);
@@ -211,6 +212,19 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
211
212
  // exists, and only fail-closed denies without one ([819]⑤). The durable-approval leg (checkpoint park +
212
213
  // /decide) is independent of this.
213
214
  toolApproval: Boolean(deps.toolApproval),
215
+ // #151 车3 刀 3b(设计稿 §2.4):design/172 **流内审批协议**(`approval_request` 呈卡帧 + 开流
216
+ // preamble 对账基准 + durable 回决端点)。判据走**单一谓词** `resolveStreamApprovalGate` —— 与协调器
217
+ // 的 askStore 注入、与 `POST /v1/tasks/:taskId/asks/:askId/decision` 的 501 门同一个符号,所以
218
+ // 「这里说 true」⟺「协议真的在场」是结构成立的,壳不必 trial-by-帧(诚实缺席纪律,同上文各格)。
219
+ // 五个合取项里最容易被忽略的两条:**账必须是持久的**(local 的 InMemory 形重启即丢已接受的决议)
220
+ // 与 **park 设施必须在场**(缺席时窗到期没有降级目的地,结局是 core fail-closed deny —— 比现行
221
+ // 5min 活卡更差,所以协议诚实地不上场、活卡腿逐字保留)。
222
+ streamApproval: resolveStreamApprovalGate({
223
+ toolApprovalEnabled: Boolean(deps.toolApproval),
224
+ streamApprovalEnabled: deps.config.streamApproval?.enabled === true,
225
+ backend: deps.backend,
226
+ parkFacility: deps.checkpointStore !== undefined,
227
+ }).active,
214
228
  // [1469] POST /v1/side-query(core 1.361 Runner.sideQuery 包装):一次性 brain 路由问答,无 session
215
229
  // 副作用。恒可用(runner 自带)——探测位供壳判「引擎腿在」而非 trial-by-404。
216
230
  sideQuery: true,