@sema-agent/server 7.2.0 → 7.3.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 (63) hide show
  1. package/README.md +2 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +6 -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 +167 -0
  11. package/dist/approval-reconciler.js +307 -0
  12. package/dist/boot/coordinators.d.ts +1 -0
  13. package/dist/boot/coordinators.js +36 -3
  14. package/dist/boot/lexical-path-env.d.ts +14 -0
  15. package/dist/boot/lexical-path-env.js +116 -0
  16. package/dist/boot/reapers.d.ts +34 -0
  17. package/dist/boot/reapers.js +198 -23
  18. package/dist/boot/resolve-spec.js +82 -30
  19. package/dist/config-types.d.ts +61 -1
  20. package/dist/config.d.ts +1 -0
  21. package/dist/config.js +130 -2
  22. package/dist/elicitation.d.ts +4 -0
  23. package/dist/elicitation.js +2 -2
  24. package/dist/http/routes/capabilities.js +14 -0
  25. package/dist/http/routes/runs.d.ts +1 -0
  26. package/dist/http/routes/runs.js +548 -16
  27. package/dist/http/routes/tasks.js +151 -9
  28. package/dist/http/server.d.ts +1 -1
  29. package/dist/http/server.js +114 -4
  30. package/dist/http/sse-log.d.ts +51 -0
  31. package/dist/http/sse-log.js +64 -0
  32. package/dist/http/wire-types.d.ts +20 -5
  33. package/dist/main.js +1 -1
  34. package/dist/plugins/approval-ask-store-memory.d.ts +38 -0
  35. package/dist/plugins/approval-ask-store-memory.js +299 -0
  36. package/dist/plugins/approval-ask-store-sql.d.ts +341 -0
  37. package/dist/plugins/approval-ask-store-sql.js +705 -0
  38. package/dist/plugins/background-agent-store-sql.js +20 -1
  39. package/dist/plugins/checkpoint-store-sql.d.ts +84 -9
  40. package/dist/plugins/checkpoint-store-sql.js +297 -16
  41. package/dist/plugins/local-checkpoint-store.d.ts +6 -5
  42. package/dist/plugins/local-checkpoint-store.js +4 -0
  43. package/dist/plugins/pg-pool.js +11 -0
  44. package/dist/plugins/store-backend.d.ts +18 -0
  45. package/dist/plugins/store-backend.js +10 -0
  46. package/dist/plugins/tidb-pool.js +27 -4
  47. package/dist/question.d.ts +3 -0
  48. package/dist/question.js +2 -2
  49. package/dist/runs.d.ts +16 -1
  50. package/dist/runs.js +61 -3
  51. package/dist/runtime-caps-resolver.d.ts +7 -1
  52. package/dist/runtime-caps-resolver.js +65 -3
  53. package/dist/spec-fields.d.ts +4 -0
  54. package/dist/spec-fields.js +6 -0
  55. package/dist/task-settings.d.ts +35 -15
  56. package/dist/task-settings.js +19 -5
  57. package/dist/tool-approval.d.ts +296 -3
  58. package/dist/tool-approval.js +1066 -50
  59. package/dist/trace/core-keyset-guard.d.ts +2 -2
  60. package/dist/trace/ledger-sink.js +14 -1
  61. package/dist/trace/project.d.ts +55 -0
  62. package/dist/trace/project.js +135 -0
  63. package/package.json +4 -3
@@ -8,6 +8,7 @@ import { FileRunStore } from "./file-run-store.js";
8
8
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
9
9
  import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
10
10
  import { type ApprovalExemptionStore } from "./approval-exemption-store.js";
11
+ import { type ApprovalAskStore as ApprovalAskStoreType } from "./approval-ask-store-sql.js";
11
12
  import { type SendFileLedger } from "./send-file-ledger.js";
12
13
  import { TiDBResumeAnchorStore, PgResumeAnchorStore } from "./resume-anchor-store-sql.js";
13
14
  import type { ServiceConfig } from "../config-types.js";
@@ -27,6 +28,9 @@ export type RunStore = TiDBRunStore | PgRunStore | FileRunStore;
27
28
  * FileResumeAnchorStore is the FILE-backed local twin (P0.5 variant-1; survives restart). */
28
29
  export type ResumeAnchorStore = TiDBResumeAnchorStore | PgResumeAnchorStore | FileResumeAnchorStore;
29
30
  export type { ApprovalExemptionStore } from "./approval-exemption-store.js";
31
+ /** #151(design/172 v3.1 §3.0-§3.2 流内审批协议持久层):`ApprovalAskStore` 接口 + 行/补丁类型。
32
+ * 车3 刀 3a 起 `StoreBackend.approvalAsk()` 已在(见接口体的那条注)。 */
33
+ export type { ApprovalAskStore, AskRow, NewAskRow, AskTransitionPatch, DecideAskInput, DecideResult, ExpireResult, BindGateInput, BatchRow, AskDecision } from "./approval-ask-store-sql.js";
30
34
  /** E6 SessionPolicyStore — core's interface PLUS the service `deleteBySession` (E21 purge; only the durable twins carry
31
35
  * it, core's InMemory omits it → optional). tidb/pg = durable twins; local = core's InMemorySessionPolicyStore (works
32
36
  * local — rules need no cloud-only infra). The shared env-gated equivalence suite keeps the twins byte-matched to InMemory. */
@@ -107,6 +111,20 @@ export interface StoreBackend {
107
111
  /** Per-session per-toolName approval exemption ("本会话不再询问") — an approval MEMORY the F4
108
112
  * ask-gate consults after deny/neverAuto. REQUIRED on all backends (works local, like resumeAnchor). */
109
113
  approvalExemption(): ApprovalExemptionStore;
114
+ /** #151(design/172 §3.0-§3.2)流内审批协议持久层:每个流内工具调用一行的 ask 状态机 + 批行。
115
+ * REQUIRED on all backends — tidb/pg = SQL 双方言 twin;local = {@link InMemoryApprovalAskStore}。
116
+ *
117
+ * 🔴 local 形的代价必须在这里说清楚(而不是留给读者自己发现):InMemory 的 STREAM_PENDING/DECIDED/
118
+ * PARKING 全在两个进程内 Map 里 ⇒ **重启同时丢重放基准、丢已接受的决议、丢审计**——「一次真实的人类
119
+ * 批准凭空消失」和「对账便利丢失」不是一个量级。因此协议在 local 车道**默认不上场**:能力面
120
+ * (`streamApproval`)的判据带一条「ask 账必须是持久的」合取项,local 不报 true(诚实缺席,同
121
+ * `park 设施缺席 ⇒ 协议不上场` 的姿势)。要在 local 真上协议,先落 File 形(现成模子 =
122
+ * {@link FileApprovalExemptionStore}:core `AppendLog`/`readJsonlRecords`,内存索引 + 追加日志即可
123
+ * 满足单实例 boot-lock 下的 CAS),那时这里换成 File twin、能力面自然翻真。
124
+ *
125
+ * 无 backend 的 env-only worker 压根没有 `StoreBackend` ⇒ 协调器拿到 undefined askStore ⇒ 车2 的
126
+ * D1 逐字现行为(store 缺席 = 现行 `tool_approval` 活卡腿一字不变)。 */
127
+ approvalAsk(): ApprovalAskStoreType;
110
128
  /** SendUserFile scope↔object ledger (multi-tenant list/revoke handle; the hashed key segment hides the
111
129
  * mapping from URLs). REQUIRED on all backends (works local — one JSONL, like approvalExemption). */
112
130
  sendFileLedger(): SendFileLedger;
@@ -18,6 +18,8 @@ import { LocalSessionStore } from "./local-session-store.js";
18
18
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
19
19
  import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
20
20
  import { TiDBApprovalExemptionStore, PgApprovalExemptionStore, FileApprovalExemptionStore } from "./approval-exemption-store.js";
21
+ import { TiDBApprovalAskStore, PgApprovalAskStore } from "./approval-ask-store-sql.js";
22
+ import { InMemoryApprovalAskStore } from "./approval-ask-store-memory.js";
21
23
  import { TiDBSendFileLedger, PgSendFileLedger, FileSendFileLedger } from "./send-file-ledger.js";
22
24
  import { TiDBResumeAnchorStore, PgResumeAnchorStore } from "./resume-anchor-store-sql.js";
23
25
  import { TiDBSessionPolicyStore, PgSessionPolicyStore } from "./session-policy-store-sql.js";
@@ -94,6 +96,7 @@ class TiDBBackend {
94
96
  run() { return new TiDBRunStore(this.pool); }
95
97
  resumeAnchor() { return new TiDBResumeAnchorStore(this.pool); }
96
98
  approvalExemption() { return new TiDBApprovalExemptionStore(this.pool); }
99
+ approvalAsk() { return new TiDBApprovalAskStore(this.pool); } // #151 流内审批协议持久层(无状态 store,每次新建同兄弟)
97
100
  sendFileLedger() { return new TiDBSendFileLedger(this.pool); }
98
101
  sessionPolicy() { return new TiDBSessionPolicyStore(this.pool); }
99
102
  fileSnapshot() { return new TiDBFileSnapshotStore(this.pool, snapshotBlobBackend(this.config, "tidb", this.pool), snapshotBoundsFromConfig(this.config)); }
@@ -127,6 +130,7 @@ class PgBackend {
127
130
  run() { return new PgRunStore(this.pool); }
128
131
  resumeAnchor() { return new PgResumeAnchorStore(this.pool); }
129
132
  approvalExemption() { return new PgApprovalExemptionStore(this.pool); }
133
+ approvalAsk() { return new PgApprovalAskStore(this.pool); } // #151 流内审批协议持久层(SQL twin)
130
134
  sendFileLedger() { return new PgSendFileLedger(this.pool); }
131
135
  sessionPolicy() { return new PgSessionPolicyStore(this.pool); }
132
136
  fileSnapshot() { return new PgFileSnapshotStore(this.pool, snapshotBlobBackend(this.config, "pg", this.pool), snapshotBoundsFromConfig(this.config)); }
@@ -178,6 +182,8 @@ class LocalBackend {
178
182
  sessionStore;
179
183
  resumeAnchorStore;
180
184
  approvalExemptionStore;
185
+ /** #151 —— 进程内易失(见 approvalAsk() 的注);File 形落地前它就是 local 车道的占位。 */
186
+ approvalAskStore = new InMemoryApprovalAskStore();
181
187
  sendFileLedgerStore;
182
188
  // SVC-2: FILE-backed durable journal (was core's InMemory — a TOC restart silently resolved
183
189
  // resumeFromRunId to an empty journal → full live re-run). Now restart-durable on one box; crash-safe JSONL,
@@ -223,6 +229,10 @@ class LocalBackend {
223
229
  run() { return this.runStore; }
224
230
  resumeAnchor() { return this.resumeAnchorStore; } // singleton — state lives in the instance (mirrors run()/session())
225
231
  approvalExemption() { return this.approvalExemptionStore; } // singleton — same posture
232
+ /** #151 流内审批协议持久层的 local 形。**必须**是 singleton:状态全在实例的两个 Map 里,每次
233
+ * new 一个就等于每个消费者各拿一份互不可见的账(而 SQL twin 天然共享一张表)。易失代价 +
234
+ * 「local 车道默认不上协议」的裁定见 StoreBackend.approvalAsk() 的接口注。 */
235
+ approvalAsk() { return this.approvalAskStore; }
226
236
  sendFileLedger() { return this.sendFileLedgerStore; } // singleton — append fd lives in the instance
227
237
  /** Durable HITL / plan-review parking on the LOCAL lane. Lazy singleton (the inner core store holds
228
238
  * an append fd). taskId join ← FileRunStore; E21 owner guard ← LocalSessionStore.ownerOf (same root). */
@@ -1,4 +1,5 @@
1
1
  import mysql from "mysql2/promise";
2
+ import { TIDB_APPROVAL_ASK_STATEMENTS } from "./approval-ask-store-sql.js";
2
3
  /**
3
4
  * Shared TiDB (MySQL wire-compatible) connection pool for the durable Session center (L1).
4
5
  * Owned by `main.ts`; closed on shutdown.
@@ -244,11 +245,28 @@ export const SCHEMA_STATEMENTS = [
244
245
  -- D-1 TOCTOU binding guard is UNREACHABLE by the portal without it — it can then only do the unbound legacy
245
246
  -- fallback). NULL on PRE-D-1 rows ⇒ that unbound fallback.
246
247
  bound_input_hash VARCHAR(190) NULL,
247
- -- pending_steer (design/80 D-A, durable steering): the parked steer ({text,trusted} JSON) set by
248
- -- setPendingSteer on a STILL-PENDING checkpoint (last-writer-wins, CAS on status='pending'), surfaced via
249
- -- get() as state.pendingSteer so core injects it on resume. NULL when no steer is parked.
250
- -- Never touches status/resolve (design/80 inv #4).
248
+ -- pending_steer (design/80 D-A, durable steering):**队列化之前**的单座列,存 {text,trusted}
249
+ -- core 5.14.0(#147 BREAKING)之后本服务**只读不写**它:存量行的那一座由 core readPendingSteerQueue
250
+ -- 折成 member 0,零迁移。NULL = 没有旧座。Never touches status/resolve (design/80 inv #4)。
251
251
  pending_steer TEXT NULL,
252
+ -- pending_steer_queue:core 5.14.0(#147)的**有界有序队列**(PendingSteerEntry[] 的 JSON)。
253
+ -- 为什么队列另起一列、而不是把信封写回 pending_steer(codex 复审 2026-08-06 F3,采纳):滚动升级/
254
+ -- 回滚窗里旧副本仍在跑,它对 pending_steer 是**无条件覆盖**写 —— 队列若躺在同一列,旧副本接的下一条
255
+ -- steer 会把 5.14 排好的整队悄悄抹掉,正是 #147 要消灭的那种静默丢失换了个位置复发。分列之后:
256
+ -- 旧副本写它的单座 ⇒ 落 pending_steer,新读侧把它折成 member 0,**两边都活着**;旧副本读不到队列
257
+ -- 是 core 自己已披露的回滚代价(CHANGELOG:rolling back across the queue event loses steers),
258
+ -- 那半无法在店面消除,但**写侧互相清零**这半可以,且代价只是一个可空列。
259
+ -- 容量:core 的 PENDING_STEER_QUEUE_BYTE_BUDGET_BYTES = 48000 字节是对 {pendingSteer,queue} 整信封的
260
+ -- 硬上界(越界 fail-loud steering.queue_full,绝不驱逐)⇒ 本列恒小于它,MySQL TEXT 的 65535 字节
261
+ -- 够用,**不升 LONGTEXT**(core 常量注里点名「under a MySQL TEXT column with UTF-8 headroom」)。
262
+ pending_steer_queue TEXT NULL,
263
+ -- pending_steer_rev:pending_steer_queue 列**自己**的乐观锁计数(与 resolve/reopen 的 rev 是两个轴,
264
+ -- 刻意不复用)。队列化之后 setPendingSteer 从「一条无条件 UPDATE」变成「读-改-写」,中间那道窗必须
265
+ -- 有 CAS 守着,否则两个并发 steer 各读到同一份队列、后写者把前者的条目整个盖掉。
266
+ -- 🔴 为什么不拿队列列的旧值当守卫:本表没有显式 COLLATE,库默认多为 *_ci —— 字符串 <=> 在大小写
267
+ -- 不敏感的排序规则下会把两份只差字母大小写的 JSON 判等,CAS 假命中 = 丢一条指令。整数列没有这个面。
268
+ -- **绝不**用它做 resolve 的 OCC:bump 它不影响 core 的 rev 语义。
269
+ pending_steer_rev BIGINT NOT NULL DEFAULT 0,
252
270
  -- risk_descriptor (design/80 riskDescriptor inbox): core's INERT CheckpointGate.riskDescriptor ({severity
253
271
  -- 1-5, axes, toolName, redacted summary, touchedPaths}), stamped at put() so listPending can surface it +
254
272
  -- triage-sort the supervisor inbox by severity DESC — WITHOUT parsing the checkpoint blob per row.
@@ -602,6 +620,11 @@ export const SCHEMA_STATEMENTS = [
602
620
  pool VARCHAR(32) NOT NULL,
603
621
  PRIMARY KEY (pool)
604
622
  )`,
623
+ // #151(design/172)流内审批协议持久层的两张表。真源 = approval-ask-store-sql.ts 的
624
+ // `TIDB_APPROVAL_ASK_STATEMENTS`(那里与 PG twin 并排,方言差异一眼可对);展开进这个数组是为了让它们
625
+ // 跟着中央 `ensureSchema` 在 **named-lock 的那条 conn** 上建 —— 把 pool 级 ensure 函数塞进执行循环
626
+ // 会绕开 DDL 串行化。挂进来同时也是「这张表算生产 schema」的声明(基线导出按定义只收录被执行的语句)。
627
+ ...TIDB_APPROVAL_ASK_STATEMENTS,
605
628
  ];
606
629
  /** Named MySQL/TiDB advisory lock that serializes the whole `ensureSchema` DDL run (see below). */
607
630
  export const ENSURE_SCHEMA_LOCK = "sema_ensure_schema";
@@ -11,6 +11,9 @@ export interface QuestionFrame {
11
11
  /** "question_complete" only: whether the human answered (`answered`) or the ask was released unanswered
12
12
  * (`unanswered` — ttl/abort → the model got the headless default). Cosmetic dialog-dismiss. */
13
13
  outcome?: "answered" | "unanswered";
14
+ /** Server-signed emit timestamp (additive, [2805]§五→[2806]). Server-minted side-frame ⇒ no core-stream
15
+ * `eventId` to carry (absence is honest); this is the frame's own time coordinate. */
16
+ serverNowMs?: number;
14
17
  }
15
18
  /** The per-run context `onQuestion` recovers via ALS. `emit` delivers a frame to the run's live stream; `abortSignal`
16
19
  * (the run's own cancel signal) releases a question parked awaiting a human when the run aborts. */
package/dist/question.js CHANGED
@@ -158,7 +158,7 @@ export class QuestionCoordinator {
158
158
  // fire-and-forget append can commit out of seq order and be skipped by the monotonic events-tail cursor). A FAILED
159
159
  // emit headless-defaults (nobody can answer) and must NOT burn the per-run total.
160
160
  try {
161
- await ctx.emit({ type: "question", questionId: id, questions });
161
+ await ctx.emit({ type: "question", questionId: id, questions, serverNowMs: Date.now() });
162
162
  rc.total += 1;
163
163
  }
164
164
  catch {
@@ -168,7 +168,7 @@ export class QuestionCoordinator {
168
168
  // Completion breadcrumb (dialog dismiss) — FIRE-AND-FORGET so a slow durable append can never delay returning the
169
169
  // answer to core. Best-effort + ordering-uncritical (the shell already has the answer via respond).
170
170
  void Promise.resolve()
171
- .then(() => ctx.emit({ type: "question_complete", questionId: id, outcome: lastOutcome }))
171
+ .then(() => ctx.emit({ type: "question_complete", questionId: id, outcome: lastOutcome, serverNowMs: Date.now() }))
172
172
  .catch(() => undefined);
173
173
  return answer;
174
174
  };
package/dist/runs.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { Metrics } from "./observability/metrics.js";
5
5
  import type { ModelUsageTracker, PromptManifestTracker } from "./budget.js";
6
6
  import type { ElicitationCoordinator } from "./elicitation.js";
7
7
  import type { QuestionCoordinator } from "./question.js";
8
+ import { type ToolApprovalCoordinator } from "./tool-approval.js";
8
9
  import { type FleetRunPublisher } from "./fleet/fleet-bus.js";
9
10
  import { type WorkflowCompletionInbox } from "./orchestration/workflow-completion-inbox.js";
10
11
  import type { VerifyRoundsSpec } from "./http/verify-rounds.js";
@@ -250,7 +251,21 @@ completionDiagLog?: (msg: string, meta?: Record<string, unknown>) => void,
250
251
  sendUserFile?: SendUserFileEmitter,
251
252
  /** [998]② the shared prompt-manifest accumulator (tracer records at prepare; this leg drains the pending
252
253
  * record into a durable `prompt_assembled` event — turns/stream read it). undefined ⇒ not wired. */
253
- promptManifests?: PromptManifestTracker): Promise<void>;
254
+ promptManifests?: PromptManifestTracker,
255
+ /**
256
+ * #151 车3 刀 3b:**bg 腿的流内审批 ctx**(design/172 §4.3(b))。今天为止这条腿连 approval ALS 都没有
257
+ * ——`deps.onAsk` 拿不到 ctx ⇒ 恒 `"unavailable"` ⇒ durable park,一张卡都不产。接上之后,bg run 的
258
+ * 权限 ask 走本腿的 **durable events tail**(`GET /v1/runs/:id/events`,唯一带 SSE `id:` 的面)。
259
+ *
260
+ * `streamApprovalOn` = 协议上场判据(`resolveStreamApprovalGate`,由路由层求值后传进来 —— 本函数是纯
261
+ * 执行腿,不该自己读 backend/config)。为假 ⇒ 只接既有四键 ctx(呈卡口/腿轴都不接),本腿字节逐字不变。
262
+ */
263
+ approval?: {
264
+ coordinator: ToolApprovalCoordinator;
265
+ streamApprovalOn: boolean;
266
+ windowMs: number;
267
+ windowMarginMs: number;
268
+ }): Promise<void>;
254
269
  /** How often a running instance refreshes its run's updated_at (liveness, independent of events).
255
270
  * Must stay strictly below `runStaleSec` (asserted at startup) or the reaper would race live runs. */
256
271
  export declare const HEARTBEAT_MS = 30000;
package/dist/runs.js CHANGED
@@ -3,6 +3,7 @@ import { withPrincipal } from "./observability/principal-context.js";
3
3
  import { redactSecrets } from "./trace/redact.js";
4
4
  import { taskNotificationEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "./trace/project.js";
5
5
  import { createLedgerSink } from "./trace/ledger-sink.js";
6
+ import { createApprovalCardEmitter, resolveApprovalLeg } from "./tool-approval.js";
6
7
  import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fleet-bus.js";
7
8
  import { defaultSubagentTailBus, projectTailFrame } from "./fleet/subagent-tail-bus.js";
8
9
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "./orchestration/workflow-completion-inbox.js";
@@ -331,7 +332,16 @@ completionDiagLog,
331
332
  sendUserFile,
332
333
  /** [998]② the shared prompt-manifest accumulator (tracer records at prepare; this leg drains the pending
333
334
  * record into a durable `prompt_assembled` event — turns/stream read it). undefined ⇒ not wired. */
334
- promptManifests) {
335
+ promptManifests,
336
+ /**
337
+ * #151 车3 刀 3b:**bg 腿的流内审批 ctx**(design/172 §4.3(b))。今天为止这条腿连 approval ALS 都没有
338
+ * ——`deps.onAsk` 拿不到 ctx ⇒ 恒 `"unavailable"` ⇒ durable park,一张卡都不产。接上之后,bg run 的
339
+ * 权限 ask 走本腿的 **durable events tail**(`GET /v1/runs/:id/events`,唯一带 SSE `id:` 的面)。
340
+ *
341
+ * `streamApprovalOn` = 协议上场判据(`resolveStreamApprovalGate`,由路由层求值后传进来 —— 本函数是纯
342
+ * 执行腿,不该自己读 backend/config)。为假 ⇒ 只接既有四键 ctx(呈卡口/腿轴都不接),本腿字节逐字不变。
343
+ */
344
+ approval) {
335
345
  const startedAt = Date.now();
336
346
  metrics?.addGauge("runs_active", 1);
337
347
  fleetPublisher?.onStart(); // MF-Fleet: the row goes live (running) the moment the background run starts
@@ -372,6 +382,21 @@ promptManifests) {
372
382
  // The owning principal is this run's owner — thread it as the single-DB-fleet null-safe owner guard on every
373
383
  // run-store mutation/poll below (the run row was created with owner = principal ?? null).
374
384
  const owner = principal ?? null;
385
+ // #151 车3 刀 3b(§7.3 + §8.3):本腿的审批装配裁定走**三腿共用**的那一份判据。
386
+ // 🔴 codex 交叉复审 F3(2026-08-06 真 finding)的修:此前本腿**无条件**包 ALS,只把新协议的口与轴挂在
387
+ // `streamApprovalOn` 后面 —— 于是 ①开关关时本腿的行为变了(此前它根本没有 approval ctx,每只 ask 恒
388
+ // `"unavailable"` ⇒ 恒 park;包上之后变成活卡并等满窗,A-1 当场破),②窗=0 在本腿上不生效(照样落行、
389
+ // 注册资源、发帧,再等一个 0ms 的表)。现在两件都由下面这一个裁定统一管。
390
+ // 取值点 = **本腿开始执行的时刻** = `runInBackground` 进入(路由层 `void runInBackground(...)` 之后
391
+ // 立刻),不是请求到达时刻(§3.3:按任务创建至今算会系统性算错)。
392
+ const approvalLeg = resolveApprovalLeg({
393
+ streamApprovalOn: approval?.streamApprovalOn === true,
394
+ windowMs: approval?.windowMs ?? 0,
395
+ windowMarginMs: approval?.windowMarginMs ?? 0,
396
+ ...(typeof spec.limits?.maxWalltimeMs === "number" ? { legWalltimeMs: spec.limits.maxWalltimeMs } : {}),
397
+ nowMonotonicMs: performance.now(),
398
+ });
399
+ const legDeadlineMonotonic = approvalLeg.legDeadlineMonotonic;
375
400
  const heartbeat = setInterval(() => {
376
401
  // 鲁棒性批5 A5(2026-08-05):与下面 cancel/preempt 两条兄弟同族——此前裸吞错,store 持续故障期间本 run
377
402
  // 的心跳每拍静默落空、运维零信号,直到某个副本的 reapStale 把它误判死亡(心跳失败正是那个误判的前兆)。
@@ -476,6 +501,15 @@ promptManifests) {
476
501
  const { type, ...rest } = frame;
477
502
  return append(type, rest);
478
503
  };
504
+ // #151 车3 刀 3b(design/172 §4.3(b)):同款 durable-append emit,服务**审批**帧 —— 既有
505
+ // `tool_approval`/`tool_approval_complete` 走它,新协议的 `approval_request` 也走它(同一条投递面、
506
+ // 两种帧,§4.3(a) 的「不新增出口」在这条腿上的落实)。`type` 是 SSE 事件名,其余是 data。
507
+ // 撤卡帧(`approval_revoke`)**不接**:它是 live-only 语义(不进账本 —— reaper/收敛器那类调用点没有
508
+ // seq 可分配),而本腿的唯一投递面就是账本;丢帧的结构补偿是重连 preamble 的全量对账基准。
509
+ const approvalEmit = (frame) => {
510
+ const { type, ...rest } = frame;
511
+ return append(type, rest);
512
+ };
479
513
  try {
480
514
  // P1 ①② follow-on (core): drain this session's async-workflow completion inbox at the START of the
481
515
  // background leg (before ANY branch — verify/cascade/plain-stream all get it; parity with the sync leg's
@@ -742,10 +776,34 @@ promptManifests) {
742
776
  const withQuestion = () => question
743
777
  ? question.runWithContext({ taskId, owner: elicitOwner ?? owner, emit: questionEmit, abortSignal: cancelCtrl.signal }, withElicit)
744
778
  : withElicit();
779
+ // #151 车3 刀 3b:审批 ALS —— 本腿**首次**拿到卡面(此前 `deps.onAsk` 在这条腿上恒无 ctx ⇒
780
+ // `"unavailable"` ⇒ 恒 park)。嵌套序同样无关(各自独立 ALS)。
781
+ // 🔴 F3 修:包不包 ALS 由 `approvalLeg` 决定,不再是「协调器在场就包」。
782
+ // · `active === false`(协议没上场)⇒ 不包 ⇒ `deps.onAsk` 找不到 ctx ⇒ `"unavailable"` ⇒ core
783
+ // durable park —— 这正是本腿在协议之前的逐字行为(A-1)。
784
+ // · `windowZero`(运维关窗 / 贴 deadline)⇒ 同样不包。本腿没有 per-task `spec.onAsk` 装配点,
785
+ // 「不包 ALS」就是 sync 腿那个 immediate-unavailable 闭包在这条腿上的**等价形**(§8.2 已亲证:
786
+ // `approverUnavailable === true` 与「onAsk 缺席」在 park 结局上逐字等价)。
787
+ const withApproval = () => approval && approvalLeg.active && !approvalLeg.windowZero
788
+ ? approval.coordinator.runWithContext({
789
+ taskId,
790
+ owner: elicitOwner ?? owner,
791
+ emit: approvalEmit,
792
+ abortSignal: cancelCtrl.signal,
793
+ ...(spec.sessionId ? { sessionId: spec.sessionId } : {}),
794
+ // 同一条 durable tail(§4.3(b))。经共用的投递口工厂:账本 append 失败 ⇒ 如实算「这一路没
795
+ // 接下」(本腿只有这一个 sink,于是=整条没送到),协调器据此走 park 而不是挂到窗到期(R2-1)。
796
+ emitCard: createApprovalCardEmitter({ appendDurable: (f) => approvalEmit(f) }),
797
+ // bg run 的这条腿恒是**首腿**(park→resume 走的是 server.ts 的 resume 腿,那条自己带
798
+ // token 摘要);空串是首腿的真值,不是「未知」。
799
+ legKey: "",
800
+ ...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}),
801
+ }, withQuestion)
802
+ : withQuestion();
745
803
  // SendUserFile 帧与 question 帧同投递面(durable events tail,web 渲染文件卡);嵌套序无关(ALS 互不相交)。
746
804
  return sendUserFile
747
- ? sendUserFile.runWithContext({ taskId, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); } }, withQuestion)
748
- : withQuestion();
805
+ ? sendUserFile.runWithContext({ taskId, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); } }, withApproval)
806
+ : withApproval();
749
807
  });
750
808
  // Cancelled mid-stream and the generator ended WITHOUT a terminal event (abort can end the stream cleanly
751
809
  // rather than throwing) — finalize cancelled here so the run isn't left "running" for the reaper to catch.
@@ -30,6 +30,9 @@ import type { RuntimeCaps } from "@sema-agent/core";
30
30
  import type { EntitlementRuntimeCaps } from "@sema-agent/registry-core";
31
31
  import { fetchPrincipalCaps, type ExecutionRuling } from "./config-center/facade.js";
32
32
  import type { ScenarioRuling } from "./capabilities/scenarios.js";
33
+ import { type Logger } from "./observability/logger.js";
34
+ /** test-only: reset the once-per-process trace so each test observes a fresh "first hit". */
35
+ export declare function resetExecutionLaneDefaultTraceForTest(): void;
33
36
  export interface EntitlementsResolverOpts {
34
37
  baseUrl: string;
35
38
  token: string;
@@ -99,8 +102,11 @@ export interface PrincipalEntitlementsClient {
99
102
  * allowlist + this worker's lane (machine code on the wire, prose stays with the shell/web).
100
103
  * No ruling / required=false / caps blip ⇒ pass (fail-open + audit). `allowedLanes` is an
101
104
  * OPEN domain — unknown names simply never match this worker's lane.
105
+ *
106
+ * `logger` (optional, defaults to this module's `defaultLogger`) is an injection seam purely for the
107
+ * once-per-process default trace below (test observability) — it does not change enforcement.
102
108
  */
103
- export declare function gateExecutionLane(ruling: ExecutionRuling | undefined, ownLane: string): void;
109
+ export declare function gateExecutionLane(ruling: ExecutionRuling | undefined, ownLane: string, logger?: Logger): void;
104
110
  /**
105
111
  * Build the per-principal caps client: `resolveRuntimeCaps` matches core's seam signature
106
112
  * `(principal) => Promise<RuntimeCaps | undefined>`; `scenarioRuling` rides the SAME fetch/cache/in-flight
@@ -2,6 +2,48 @@ import { fetchPrincipalCaps } from "./config-center/facade.js";
2
2
  import { HttpError } from "./security.js";
3
3
  import { createLogger } from "./observability/logger.js";
4
4
  const defaultLogger = createLogger();
5
+ /** #157 / [2762]§五(sema-comms `audits/failopen-governance-176.md` §7 "已知在办 1 条"):module-scope,
6
+ * once-per-process record of which {@link gateExecutionLane} fail-open DEFAULT form has already been traced.
7
+ * Two forms are counted SEPARATELY (both currently resolve to the same "pass" default, but they are different
8
+ * facts about the world — a caller must be able to tell "we never got a ruling for this principal" apart from
9
+ * "center ruled, and explicitly said not-required"):
10
+ * - `"no_ruling"` — `ruling === undefined` (no principal / center absent-or-blip / older center missing the key).
11
+ * - `"required_false"` — a ruling WAS resolved and it explicitly says `required: false`.
12
+ * Each form logs ONCE per process at first hit (a debug line — this is an expected, non-alarming default, not a
13
+ * warning); every subsequent hit of the SAME form only increments the counter (no repeat log line — this is a
14
+ * per-task gate, so without the throttle a busy deployment would log once per task forever). Pure record-keeping:
15
+ * never changes {@link gateExecutionLane}'s pass/deny outcome. */
16
+ const executionLaneDefaultTrace = new Map();
17
+ /** test-only: reset the once-per-process trace so each test observes a fresh "first hit". */
18
+ export function resetExecutionLaneDefaultTraceForTest() {
19
+ executionLaneDefaultTrace.clear();
20
+ }
21
+ function traceExecutionLaneDefault(form, ownLane, logger) {
22
+ const hitCount = (executionLaneDefaultTrace.get(form) ?? 0) + 1;
23
+ executionLaneDefaultTrace.set(form, hitCount);
24
+ if (hitCount > 1)
25
+ return; // subsequent hits: silently counted only (see map above), no repeat log line
26
+ try {
27
+ // codex 交叉复审第二轮真发现(2026-08-05):a throwing `logger.debug` must NEVER turn this pure
28
+ // observability step into gateExecutionLane itself throwing — that would silently convert a fail-open
29
+ // PASS into an uncaught exception, the exact inversion this module's whole design fights elsewhere
30
+ // (see the FAIL-CLOSED/FAIL-OPEN posture doc above). The hit-count above already advanced, so a retry
31
+ // of the SAME form stays silent rather than storming — the trade-off is "lose one trace line", not
32
+ // "lose one task admission".
33
+ logger.debug("execution_lane_default_applied", {
34
+ dimension: "execution_lane",
35
+ form,
36
+ ownLane,
37
+ default: "pass",
38
+ note: form === "no_ruling"
39
+ ? "no execution-lane ruling was ever resolved for this principal (no principal / center absent-or-blip / older center without the key)"
40
+ : "center resolved a ruling and explicitly set required=false",
41
+ });
42
+ }
43
+ catch {
44
+ // tracing must never affect the fail-open admission outcome — see comment above.
45
+ }
46
+ }
5
47
  /** Map center's `EntitlementRuntimeCaps` → the subset core's engine enforces. `allowUltracode` (shell/UX) is
6
48
  * dropped on purpose (not a core primitive); `allowWorkflows` / `forceDurableGate` / `allowFork` are the three the
7
49
  * engine enforces. Returns undefined when no enforceable cap is set (= no restriction). FAIL-CLOSED defense-in-depth:
@@ -60,10 +102,19 @@ export function scopedTokenNeedsWorker(token, worker) {
60
102
  * allowlist + this worker's lane (machine code on the wire, prose stays with the shell/web).
61
103
  * No ruling / required=false / caps blip ⇒ pass (fail-open + audit). `allowedLanes` is an
62
104
  * OPEN domain — unknown names simply never match this worker's lane.
105
+ *
106
+ * `logger` (optional, defaults to this module's `defaultLogger`) is an injection seam purely for the
107
+ * once-per-process default trace below (test observability) — it does not change enforcement.
63
108
  */
64
- export function gateExecutionLane(ruling, ownLane) {
65
- if (!ruling?.required)
109
+ export function gateExecutionLane(ruling, ownLane, logger = defaultLogger) {
110
+ if (!ruling) {
111
+ traceExecutionLaneDefault("no_ruling", ownLane, logger);
66
112
  return;
113
+ }
114
+ if (!ruling.required) {
115
+ traceExecutionLaneDefault("required_false", ownLane, logger);
116
+ return;
117
+ }
67
118
  if (!ruling.allowedLanes.includes(ownLane)) {
68
119
  throw new HttpError(403, `this worker's execution lane "${ownLane}" is not permitted for this principal (execution policy)`, {
69
120
  code: "execution_lane_not_allowed",
@@ -149,7 +200,18 @@ export function createPrincipalEntitlementsClient(opts) {
149
200
  return entry;
150
201
  }
151
202
  catch (err) {
152
- const e = err instanceof Error ? err : new Error(String(err));
203
+ // codex 交叉复审第三轮真发现(2026-08-05):a poison rejection reason (throwing `Symbol.toPrimitive`/
204
+ // `toString`) must not make THIS normalization line itself throw — this catch block exists to
205
+ // GUARANTEE the documented FAIL-CLOSED degrade below runs; if normalizing `err` throws, that
206
+ // guarantee is exactly what breaks (resolveRuntimeCaps/scenarioRuling/executionRuling would reject
207
+ // instead of resolving to their fail-closed/fail-open defaults on a degraded center response).
208
+ let e;
209
+ try {
210
+ e = err instanceof Error ? err : new Error(String(err));
211
+ }
212
+ catch {
213
+ e = new Error("center fetch failed with an unstringifiable rejection reason");
214
+ }
153
215
  // Caps face FAIL CLOSED: deny the OPTIONAL amplification caps (workflows + fork); never force the durable
154
216
  // gate on error. Scenario face FAIL OPEN: `scenario` stays undefined (no governance — never pin users to
155
217
  // the default scenario on a center blip; deliberate posture split, see PrincipalEntitlementsClient). Cache the
@@ -85,6 +85,10 @@ export declare function toolNameListFromBody(raw: unknown): string[] | undefined
85
85
  * ⇒ 无租户门(deferTools 同姿势);全委托树继承由 core 管。缺省不挂键(引擎缺省 simple,byte-compat)。
86
86
  * DEFENSIVE(resume 重放不过 HTTP 400 门):非法值 ⇒ undefined;提交面另有 fail-loud 400(邻居姿势)。 */
87
87
  export declare function promptProfileFromBody(raw: unknown): "simple" | "classic" | undefined;
88
+ /** [2856]②(core 5.14.0 `TaskSpec.toolMaterializeStrategy`):deferred 工具激活后的 schema 供给策略。
89
+ * 与 {@link promptProfileFromBody} 同姿势的**防御性归一**——提交面的 fail-loud 400 在 HTTP 门,这里
90
+ * 只负责 resume 重放存量 body 时把不认识的值静默 DROP(回落 core 的 env/缺省链),而不是把它带上 spec。 */
91
+ export declare function toolMaterializeStrategyFromBody(raw: unknown): "static" | "swap" | undefined;
88
92
  /** [854]④ — 每键的运营方上限旋钮(env,缺省不设=不封顶)。 */
89
93
  export interface TaskLimitCaps {
90
94
  /** TASK_TIMEOUT_MAX_SEC(env 名不动,仍按**秒**配置;core 5.8.0 起封顶对象是 limits.maxWalltimeMs,
@@ -351,6 +351,12 @@ export function toolNameListFromBody(raw) {
351
351
  export function promptProfileFromBody(raw) {
352
352
  return raw === "simple" || raw === "classic" ? raw : undefined;
353
353
  }
354
+ /** [2856]②(core 5.14.0 `TaskSpec.toolMaterializeStrategy`):deferred 工具激活后的 schema 供给策略。
355
+ * 与 {@link promptProfileFromBody} 同姿势的**防御性归一**——提交面的 fail-loud 400 在 HTTP 门,这里
356
+ * 只负责 resume 重放存量 body 时把不认识的值静默 DROP(回落 core 的 env/缺省链),而不是把它带上 spec。 */
357
+ export function toolMaterializeStrategyFromBody(raw) {
358
+ return raw === "static" || raw === "swap" ? raw : undefined;
359
+ }
354
360
  /** core 5.8.0 时限重构的退役键(fresh-submit 在 HTTP 门 400 点名;resume 重放在 normalize 层 DROP)。 */
355
361
  const RETIRED_LIMIT_KEYS = ["timeoutSec", "deadlineNudge", "callCapByDeadline", "gracefulFinalize"];
356
362
  /** 在场的退役键(normalizeLimits/resolveTaskLimits 共用判据源:留痕事件的 droppedKeys 字段)。 */
@@ -55,6 +55,10 @@ export type SettingsPermissionMode = "default" | "acceptEdits" | "plan" | "bypas
55
55
  * would FORCE the manual ask gate onto a caller that explicitly asked for less asking — with the gate live, the old
56
56
  * coercion would have been a behavior change for them, incl. a headless-deny regression for bypass callers). An
57
57
  * UNKNOWN string still coerces to `default` (fail-safe: the most-asking mode).
58
+ * #157-②: fresh submits can no longer REACH the unknown-word arm — the HTTP gate 400s a non-five-mode value
59
+ * (server.ts, sibling of promptProfile) and the wire type is a closed enum. This lenient fold stays for the one
60
+ * population the gate deliberately does not cover: RESUME replay of a persisted pre-gate body (outputStyle/model
61
+ * two-layer posture — bricking a resume over a value that was legal at submit time is worse than the quiet fold).
58
62
  */
59
63
  export declare function coercePermissionMode(raw: unknown): SettingsPermissionMode | undefined;
60
64
  /** L2 ultracode (design/111): the effective `thinking` level given the explicit `reasoningEffort` and the ultracode
@@ -143,31 +147,47 @@ export declare function hasConstitutionAnchors(text: unknown): boolean;
143
147
  * (REPLACE_ALL_EXCLUDED strips role.append) → append-less true. */
144
148
  export declare function providerDropsAppend(provider: PromptProvider | undefined, userSystemPrompt: string | undefined): boolean;
145
149
  export declare function acceptAppendSystemPrompt(v: unknown, warn?: (detail: string) => void, packDropsAppend?: boolean): string | undefined;
146
- /** [1248]②/codex F2 — what the workflow ask leg needs from the deployment, on EVERY lane (unlike
147
- * {@link FsWriteGateWiring}, which is host-lane-only because it adjudicates against a real fs; the workflow
148
- * gate is name-keyed and fs-independent). */
150
+ /** [1248]②/codex F2 — what the workflow ask leg needs from the deployment, on EVERY lane. It is name-keyed and
151
+ * fs-independent, so it never had the lane split {@link FsWriteGateWiring} carried (that split is itself gone
152
+ * since #156 — the sandbox lanes now wire a lexical-path gate instead of no gate). */
149
153
  export interface WorkflowGateWiring {
150
154
  /** The session "don't ask again" probe (approvalExemptionStore.has, canonical toolName key) — same store and
151
155
  * key space as the fs-write gate's probe; one remember="session" grant serves both. */
152
156
  isExempt?: (toolName: string) => boolean | Promise<boolean>;
153
157
  }
154
- /** [816]/[820]① — everything the mode-derived fs-write ask gate needs from the deployment, provided by main.ts
155
- * ONLY on a HOST-SEMANTICS lane (`REMOTE_EXEC` unset or "host"). Absent ⇒ no gate is derived (the pre-[816]
156
- * behavior). WHY lane-gated: core's `createFsWriteGatePolicy` canonicalizes every target/dir through the given
157
- * `env` (real `exists`/`canonicalPath`/`readLink` fs access read in dist, 1.290 fs-write-gate-policy.js), so the
158
- * env's fs MUST be the fs the hand tools write. On e2b/k8s/ssh/adb/local-docker the tools run OFF this box and the
159
- * per-task sandbox env is minted inside core (executionEnvFactory) AFTER spec build a worker-local env here would
160
- * adjudicate against the WRONG fs (symlink/exists answers from the worker host). Honest absence over a wrong gate
161
- * (same honest-facts axis as envFacts.scratchpadDir). */
158
+ /** [816]/[820]① — everything the mode-derived fs-write ask gate needs from the deployment, provided by
159
+ * `resolve-spec.ts`. Absent ⇒ no gate is derived (the pre-[816] behavior).
160
+ *
161
+ * WHY the `env` matters: core's `createFsWriteGatePolicy` canonicalizes every target/dir through the given `env`
162
+ * (real `exists`/`canonicalPath`/`readLink` fs access read in dist, fs-write-gate-policy.js), so on a
163
+ * HOST-SEMANTICS lane (`REMOTE_EXEC` unset or "host") the env MUST be the fs the hand tools write.
164
+ *
165
+ * #156 the sandbox lanes (e2b/k8s/ssh/adb/local-docker) now wire a gate TOO, but a different-shaped one: the
166
+ * tools run OFF this box and the per-task sandbox env is minted inside core (executionEnvFactory) AFTER spec
167
+ * build, so a worker-local env would adjudicate against the WRONG fs (a wrong ALLOW is worse than no gate — that
168
+ * is why [816] left those lanes gate-less). The transitional shape instead supplies a `LexicalPathExecutionEnv`
169
+ * (`src/boot/lexical-path-env.ts`): NO fs is consulted at all, absolute paths are judged by lexical
170
+ * normalization and everything else fails closed to `ask`. Its residual surface (symlink form) and the endgame
171
+ * seam (core [2751] read-only env face on HookToolContext) are documented on that module. */
162
172
  export interface FsWriteGateWiring {
163
- /** The env whose fs the gate canonicalizes against — the worker host env on the host lane. */
173
+ /** The env the gate canonicalizes against — the worker host env on the host lane, the fs-less
174
+ * `LexicalPathExecutionEnv` on the sandbox lanes (#156). */
164
175
  env: ExecutionEnv;
165
- /** The task's working directory: the factory's `rootPath` (relative-path base) AND the acceptEdits accept domain. */
166
- cwd: string;
176
+ /** The task's working directory: the factory's `rootPath` (relative-path base) AND the acceptEdits accept domain.
177
+ * ABSENT on the sandbox lanes (#156): the sandbox cwd is not knowable at spec time and inventing one would mint
178
+ * a bogus auto-allow domain. With it absent the gate gets no `rootPath` and no `acceptDirs`, so relative paths
179
+ * fail to resolve (⇒ ask) and `acceptEdits` degrades to the `default` arm — the same fail-safe direction as the
180
+ * host lane's never-created sentinel dir (resolve-spec 修5). */
181
+ cwd?: string;
167
182
  /** The session scratchpad dir (envFacts.scratchpadDir, [820]③) — writes there are always auto-allowed. */
168
183
  scratchpadDir?: string;
169
184
  /** [841]① / core 1.294 exemption seam: the session "don't ask again" probe (approvalExemptionStore.has),
170
- * consulted by the gate RIGHT BEFORE it would ask (after the exempt/accept dir layering — dist 亲读:true ⇒
185
+ * called by core as `(canonicalToolName, canonicalPath)`. The host arm ignores the second argument (the key
186
+ * comes from a real-fs canonicalize and the grant is deliberately name-keyed); the sandbox arm (#156) uses it
187
+ * to confine the exemption to POSIX-absolute canonical keys — core's `canonicalizeTarget` short-circuits
188
+ * backslash-UNC forms to `ok:true, key=raw` WITHOUT consulting the env, and an unconfined name-keyed grant
189
+ * would turn those unresolvable forms into an allow (codex review 2026-08-05).
190
+ * The probe is consulted by the gate RIGHT BEFORE it would ask (after the exempt/accept dir layering — dist 亲读:true ⇒
171
191
  * allow with decisionReason "rule", a THROWN probe ⇒ not exempt = fail-closed to ask). Same canonical
172
192
  * toolName key space as the ask-policy layer's exemption probe — ONE grant serves both layers; the gate-level
173
193
  * check is what makes "allow all session" bite SAME-TURN for the parent and inherited child tasks (the ask
@@ -48,6 +48,10 @@ import { parseHooksConfig } from "./hooks/hook-runner.js";
48
48
  * would FORCE the manual ask gate onto a caller that explicitly asked for less asking — with the gate live, the old
49
49
  * coercion would have been a behavior change for them, incl. a headless-deny regression for bypass callers). An
50
50
  * UNKNOWN string still coerces to `default` (fail-safe: the most-asking mode).
51
+ * #157-②: fresh submits can no longer REACH the unknown-word arm — the HTTP gate 400s a non-five-mode value
52
+ * (server.ts, sibling of promptProfile) and the wire type is a closed enum. This lenient fold stays for the one
53
+ * population the gate deliberately does not cover: RESUME replay of a persisted pre-gate body (outputStyle/model
54
+ * two-layer posture — bricking a resume over a value that was legal at submit time is worse than the quiet fold).
51
55
  */
52
56
  export function coercePermissionMode(raw) {
53
57
  if (raw === "plan" || raw === "acceptEdits" || raw === "default" || raw === "bypassPermissions" || raw === "auto")
@@ -139,7 +143,12 @@ export function parseTaskSettings(raw) {
139
143
  deferred.push("env"); // present but malformed/empty → report
140
144
  const out = {};
141
145
  const perms = s.permissions;
142
- if (perms !== null && typeof perms === "object") {
146
+ // F1(#157 复审留裁裁修):出现但形错(数组/标量)——本层是 RESUME 重放也走的宽容层,不 throw,但必
147
+ // 留痕 deferred(镜像 env/hooks malformed 姿势;fresh submit 已被 http 门 400 拦,这里护的是存量行)。
148
+ if (perms !== undefined && perms !== null && (typeof perms !== "object" || Array.isArray(perms))) {
149
+ deferred.push("permissions");
150
+ }
151
+ if (perms !== null && typeof perms === "object" && !Array.isArray(perms)) {
143
152
  const p = perms;
144
153
  const allow = cleanStringArray(p.allow);
145
154
  const deny = cleanStringArray(p.deny);
@@ -386,7 +395,9 @@ export function deriveSettingsPolicy(settings, gate, workflowGate) {
386
395
  policies.push(createAskListPolicy(ask));
387
396
  // The mode-derived fs-write ask gate (core 1.290 factory — real shape read in dist: {env, rootPath?, acceptDirs?,
388
397
  // exemptDirs?, defaultWrite}, gates Write/Edit/NotebookEdit, exempt→accept→default layering, canonicalize inside
389
- // the given env, only ever allow/ask). undefined off the host lane / without wiring (see FsWriteGateWiring).
398
+ // the given env, only ever allow/ask). undefined without wiring (see FsWriteGateWiring). #156: the sandbox lanes
399
+ // supply wiring too — same factory, a lexical (fs-less) env and no cwd; the shape difference lives entirely in
400
+ // what `resolve-spec.ts` puts in the wiring, not in a second code path here.
390
401
  // ③ (core 1.295): the sensitive-path DENY policy (patterns from the wiring — config SENSITIVE_WRITE_PATTERNS,
391
402
  // default = core's RECOMMENDED set) composes into the SAME fold, deny-wins — core semantics deny/"safety", so
392
403
  // neither the gate's own allow legs (exemptDirs/acceptDirs/isExempt session exemption) nor a settings allow can
@@ -395,10 +406,13 @@ export function deriveSettingsPolicy(settings, gate, workflowGate) {
395
406
  const fsWriteGate = (acceptCwd) => {
396
407
  if (!gate)
397
408
  return undefined;
409
+ // #156: `cwd` is optional (absent on the sandbox lanes). Omitting `rootPath` makes core leave a relative
410
+ // target relative → its lexical env cannot resolve it → ask; omitting `acceptDirs` means acceptEdits mints
411
+ // no auto-allow domain at all. Both are the intended fail-safe, NOT a degraded copy of the host arm.
398
412
  const gatePolicy = createFsWriteGatePolicy({
399
413
  env: gate.env,
400
- rootPath: gate.cwd,
401
- ...(acceptCwd ? { acceptDirs: [gate.cwd] } : {}),
414
+ ...(gate.cwd !== undefined ? { rootPath: gate.cwd } : {}),
415
+ ...(acceptCwd && gate.cwd !== undefined ? { acceptDirs: [gate.cwd] } : {}),
402
416
  ...(gate.scratchpadDir ? { exemptDirs: [gate.scratchpadDir] } : {}),
403
417
  ...(gate.isExempt ? { isExempt: gate.isExempt } : {}), // 1.294 session-exemption probe (see FsWriteGateWiring)
404
418
  defaultWrite: "ask",
@@ -409,7 +423,7 @@ export function deriveSettingsPolicy(settings, gate, workflowGate) {
409
423
  // 亲读。回归锚保留在 test/task-settings.test.ts(NotebookEdit deny + 双向诱饵),现在锁的是 core 行为经
410
424
  // 我方折叠——core 若回退,锚变红。
411
425
  return gate.sensitivePatterns && gate.sensitivePatterns.length > 0
412
- ? combinePolicies(createSensitivePathPolicy({ env: gate.env, patterns: gate.sensitivePatterns, rootPath: gate.cwd }), gatePolicy)
426
+ ? combinePolicies(createSensitivePathPolicy({ env: gate.env, patterns: gate.sensitivePatterns, ...(gate.cwd !== undefined ? { rootPath: gate.cwd } : {}) }), gatePolicy)
413
427
  : gatePolicy;
414
428
  };
415
429
  const compose = (...extra) => {