@sema-agent/server 3.21.0 → 3.23.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 (55) hide show
  1. package/dist/approval-hmac.d.ts +9 -9
  2. package/dist/approval-hmac.js +0 -31
  3. package/dist/approval.js +8 -1
  4. package/dist/bake-runner/main.d.ts +2 -2
  5. package/dist/bake-runner/main.js +32 -9
  6. package/dist/boot/config-center.d.ts +3 -2
  7. package/dist/boot/resolve-spec.js +4 -4
  8. package/dist/boot/session-faces.js +3 -2
  9. package/dist/boot/workflow-orchestration.js +1 -1
  10. package/dist/budget.d.ts +2 -2
  11. package/dist/budget.js +11 -6
  12. package/dist/capabilities/center-plugins.js +1 -1
  13. package/dist/capabilities/skills.d.ts +12 -1
  14. package/dist/capabilities/skills.js +31 -6
  15. package/dist/config.d.ts +12 -0
  16. package/dist/config.js +17 -0
  17. package/dist/fleet/fleet-bus.js +4 -1
  18. package/dist/hooks/hook-llm.d.ts +2 -2
  19. package/dist/hooks/hook-llm.js +2 -2
  20. package/dist/hooks/hook-runner.d.ts +4 -0
  21. package/dist/hooks/hook-runner.js +4 -4
  22. package/dist/http/principal-gate.d.ts +7 -3
  23. package/dist/http/principal-gate.js +7 -5
  24. package/dist/http/route-ctx.d.ts +34 -25
  25. package/dist/http/routes/approvals-assistant.js +5 -5
  26. package/dist/http/routes/images.js +8 -8
  27. package/dist/http/routes/runs.js +6 -1
  28. package/dist/http/server.d.ts +2 -2
  29. package/dist/http/server.js +10 -6
  30. package/dist/key-resolver.d.ts +7 -1
  31. package/dist/key-resolver.js +0 -23
  32. package/dist/leader/wire.d.ts +19 -1
  33. package/dist/leader/wire.js +48 -18
  34. package/dist/lsp/e2b-bridge.js +14 -1
  35. package/dist/lsp/e2b-manager.d.ts +8 -4
  36. package/dist/lsp/e2b-manager.js +55 -23
  37. package/dist/main.js +2 -2
  38. package/dist/parked-decide.js +4 -1
  39. package/dist/plugins/file-run-store.js +5 -5
  40. package/dist/plugins/host-platform.d.ts +11 -24
  41. package/dist/plugins/host-platform.js +14 -0
  42. package/dist/plugins/memory-engine-tidb.js +16 -0
  43. package/dist/plugins/memory-run-store.js +5 -5
  44. package/dist/plugins/remote-env-adb.js +12 -5
  45. package/dist/plugins/remote-env-local-docker.js +3 -7
  46. package/dist/plugins/remote-env-ssh.js +17 -2
  47. package/dist/plugins/run-store-sql.js +12 -12
  48. package/dist/plugins/store-backend.d.ts +1 -1
  49. package/dist/plugins/store-backend.js +2 -2
  50. package/dist/plugins/tool-result-store-sql.d.ts +7 -1
  51. package/dist/plugins/tool-result-store-sql.js +9 -8
  52. package/dist/plugins/workflow-run-store-sql.d.ts +11 -6
  53. package/dist/plugins/workflow-run-store-sql.js +18 -8
  54. package/dist/session-sync.js +6 -3
  55. package/package.json +1 -1
@@ -61,7 +61,10 @@ export function gatedPrincipal(req, config) {
61
61
  }
62
62
  /** The EXPLICIT operator gate (IMAGE-API-DESIGN.md §P2.4a MUST-FIX) — NOT the bare `isOperator` (whose empty-list
63
63
  * "true-for-all" is a world-writable RCE door the instant OPERATOR_PRINCIPALS is empty). Requires a non-empty
64
- * operator set AND a present principal IN it. The same form the preempt/resume endpoints already use. */
64
+ * operator set AND a present principal IN it. The same form the preempt/resume endpoints already use.
65
+ * 🔴 SINGLE mint point (E1): a byte-identical twin `explicitOperator` used to live below (image-visibility lane)
66
+ * and two more hand-inlined copies sat in approvals-assistant — a future hardening of this judgment would have
67
+ * missed them. All call sites now consume THIS function; do not re-introduce a sibling. */
65
68
  export function explicitOperatorOk(principal, operatorPrincipals) {
66
69
  return operatorPrincipals.length > 0 && principal !== undefined && operatorPrincipals.includes(principal);
67
70
  }
@@ -77,8 +80,7 @@ export function isOperator(principal, operatorPrincipals) {
77
80
  * yields `false` (no one is an operator). Use this where empty-operators-means-all would be a SECURITY hole rather
78
81
  * than a back-compat convenience: image VISIBILITY scoping (a tenant-scoped image must not leak to every caller
79
82
  * just because OPERATOR_PRINCIPALS is unset). Adversarial-review HIGH: `isOperator([], p)=true` made
80
- * `latestPublished(..., {operator:true})` bypass tenant visibility for any caller on an operator-less deployment. */
81
- export function explicitOperator(principal, operatorPrincipals) {
82
- return operatorPrincipals.length > 0 && principal !== undefined && operatorPrincipals.includes(principal);
83
- }
83
+ * `latestPublished(..., {operator:true})` bypass tenant visibility for any caller on an operator-less deployment.
84
+ * (The byte-identical `explicitOperator` twin that used to live here was folded into `explicitOperatorOk` above —
85
+ * one judgment, one mint point.) */
84
86
  //# sourceMappingURL=principal-gate.js.map
@@ -75,6 +75,38 @@ export interface RouteRequestState {
75
75
  /** 凭证派生的调用方系统身份(`source`)。handle() 的鉴权门是**唯一**赋值点,域模块只读。 */
76
76
  source: string | null;
77
77
  }
78
+ /** B4①(命名化):`prepareSpec` 的返回形(tasks.ts/runs.ts 两域共同消费的属性面)——原为 6 成员内联匿名对象。
79
+ * 命名后好处不只是过 B4 门:两个消费域现在都能对同一个符号做 `import type` 标注,而不是各自重推断结构。 */
80
+ export interface PreparedTaskSubmission {
81
+ spec: TaskSpec;
82
+ auth?: RequestAuth;
83
+ verify?: {
84
+ maxRounds: number;
85
+ costCeilingMicroUsd?: number;
86
+ };
87
+ cascade?: boolean;
88
+ jobId?: string;
89
+ body: TaskRequestBody;
90
+ }
91
+ /** B4①(命名化):`driveResumeIntoRunLog` 的入参形(resume 家族共用)——原为 9 成员内联匿名对象。 */
92
+ export interface DriveResumeArgs {
93
+ token: CheckpointToken;
94
+ sessionId: string;
95
+ principal: string | undefined;
96
+ fleetScope: string;
97
+ taskConfig: Omit<TaskSpec, "objective" | "sessionId">;
98
+ resumeObjective: string;
99
+ outcome: ResumeOutcome;
100
+ verifyRounds: {
101
+ maxRounds: number;
102
+ costCeilingMicroUsd?: number;
103
+ } | undefined;
104
+ /** codex M2: side-effects that must land AFTER the markResuming CAS is WON (decide accepted, sibling races
105
+ * lost — never fires on the 409/404 paths) and BEFORE the model leg drives (so the resumed leg's own next
106
+ * ask sees them — the decide leg's exemption grant). Contract: must not throw (callers swallow internally);
107
+ * awaited so the ordering guarantee is real, and a store write here is ms-scale vs the model leg. */
108
+ onResumeCommitted?: () => Promise<void>;
109
+ }
78
110
  /** 提交/续跑「腿」= 跨域共享的**有状态**长流程(不像 {@link RouteHelpers} 那样只依赖 deps/config:它们要写
79
111
  * run log、发 fleet 帧、走 markResuming CAS、驱动模型)。A9 上批的停点就在这里——tasks/runs 两域共用
80
112
  * `prepareSpec`,approvals/assistant/notify-wake 三域共用 resume 家族,把它们跟着任一域搬都会造出
@@ -82,17 +114,7 @@ export interface RouteRequestState {
82
114
  * 装进这一格,域模块经 `ctx.legs.*` 调用。类型化 ⇒ 腿的形漂了是编译红,不是运行时静默 404。 */
83
115
  export interface RouteLegs {
84
116
  /** POST /v1/tasks · /v1/tasks/stream · /v1/runs 的共同前段:读体 → 校验 → resolveSpec。null = 已应答(400/…)。 */
85
- prepareSpec(req: IncomingMessage, res: ServerResponse): Promise<{
86
- spec: TaskSpec;
87
- auth?: RequestAuth;
88
- verify?: {
89
- maxRounds: number;
90
- costCeilingMicroUsd?: number;
91
- };
92
- cascade?: boolean;
93
- jobId?: string;
94
- body: TaskRequestBody;
95
- } | null>;
117
+ prepareSpec(req: IncomingMessage, res: ServerResponse): Promise<PreparedTaskSubmission | null>;
96
118
  /** 同步腿的终局记账(计费/配额/指标),tasks 域用。 */
97
119
  finalizeTaskResult(result: TaskResult, principal: string | undefined, objective: string, sessionId: string | undefined): void;
98
120
  /** approvals 决策腿:session → pending checkpoint → markResuming CAS → 驱动续跑。 */
@@ -101,20 +123,7 @@ export interface RouteLegs {
101
123
  body: object;
102
124
  }>;
103
125
  /** 全 resume 家族的共同下半场(lease admission + 写 run log + fleet 发布)。 */
104
- driveResumeIntoRunLog(args: {
105
- token: CheckpointToken;
106
- sessionId: string;
107
- principal: string | undefined;
108
- fleetScope: string;
109
- taskConfig: Omit<TaskSpec, "objective" | "sessionId">;
110
- resumeObjective: string;
111
- outcome: ResumeOutcome;
112
- verifyRounds: {
113
- maxRounds: number;
114
- costCeilingMicroUsd?: number;
115
- } | undefined;
116
- onResumeCommitted?: () => Promise<void>;
117
- }): Promise<{
126
+ driveResumeIntoRunLog(args: DriveResumeArgs): Promise<{
118
127
  status: number;
119
128
  body: object;
120
129
  }>;
@@ -92,7 +92,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
92
92
  return;
93
93
  }
94
94
  const operators = deps.config.operatorPrincipals;
95
- const isExplicitOperator = operators.length > 0 && principal !== undefined && operators.includes(principal);
95
+ const isExplicitOperator = explicitOperatorOk(principal, operators); // E1: single mint point (principal-gate)
96
96
  if (!isExplicitOperator) {
97
97
  // 🔴 fail-CLOSED owner gate (both reviewers, 2026-07-13): mirror the sibling owner-gated routes
98
98
  // (session policy/delete) — ownerOf REQUIRED (absent ⇒ 501, never open), a store error PROPAGATES
@@ -292,7 +292,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
292
292
  // + the cancel/decide precedent). NOT the bare isOperator (its empty-list "true-for-all" would let any
293
293
  // caller preempt anyone — same trap the /decide owner-gate avoids).
294
294
  const operators = deps.config.operatorPrincipals;
295
- const explicitOperator = operators.length > 0 && principal !== undefined && operators.includes(principal);
295
+ const explicitOperator = explicitOperatorOk(principal, operators); // E1: single mint point (principal-gate)
296
296
  if (!explicitOperator && run.owner !== null && run.owner !== principal) {
297
297
  sendError(res, 404, "not_found.run", "task not found");
298
298
  return;
@@ -349,7 +349,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
349
349
  return;
350
350
  }
351
351
  const operators = deps.config.operatorPrincipals;
352
- const explicitOperator = operators.length > 0 && principal !== undefined && operators.includes(principal);
352
+ const explicitOperator = explicitOperatorOk(principal, operators); // E1: single mint point (principal-gate)
353
353
  if (!explicitOperator && run.owner !== null && run.owner !== principal) {
354
354
  sendError(res, 404, "not_found.run", "task not found");
355
355
  return;
@@ -436,7 +436,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
436
436
  deciderPrincipal = proof.principal;
437
437
  }
438
438
  const operators = deps.config.operatorPrincipals;
439
- const explicitOperator = operators.length > 0 && deciderPrincipal !== undefined && operators.includes(deciderPrincipal);
439
+ const explicitOperator = explicitOperatorOk(deciderPrincipal, operators); // E1: single mint point (principal-gate)
440
440
  if (!explicitOperator && run.owner !== null && run.owner !== deciderPrincipal) {
441
441
  sendError(res, 404, "not_found.run", "task not found");
442
442
  return;
@@ -579,7 +579,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
579
579
  // credential sessionId+boundInputHash (from a log/screenshot/ticket). A non-owner-non-operator gets 404
580
580
  // (no existence oracle — parity with runOwnerOk + the cancel P0 precedent), never 403.
581
581
  const operators = deps.config.operatorPrincipals;
582
- const explicitOperator = operators.length > 0 && deciderPrincipal !== undefined && operators.includes(deciderPrincipal);
582
+ const explicitOperator = explicitOperatorOk(deciderPrincipal, operators); // E1: single mint point (principal-gate)
583
583
  if (!explicitOperator) {
584
584
  const cpScope = await cs.peekPendingScope(sessionId);
585
585
  // "_" = the anonymous-submit scope SENTINEL (main.ts writes auth?.principal ?? "_" at suspend) — treat it
@@ -5,7 +5,7 @@ import { manifestToIndexEntry } from "../../images/manifest.js";
5
5
  import { validateBake, buildBakeArgv } from "../../images/bake-validate.js";
6
6
  import { streamSseLog } from "../sse-log.js";
7
7
  import { sendJson, sendError } from "../send.js";
8
- import { headerStr, safeEqual, gatedPrincipal, explicitOperatorOk, explicitOperator } from "../principal-gate.js";
8
+ import { headerStr, safeEqual, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
9
9
  import { scopedIdempotencyKey } from "../idempotency.js";
10
10
  export function createImagesLocal(deps) {
11
11
  // Per-principal bake-submission rate guard (IMAGE-API-DESIGN.md §P2.4c — fills the register DoS gap the images
@@ -86,7 +86,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
86
86
  // Identity resolved EXACTLY as P1 (direct-door JWT-without-cnf.bnd, else principalFrom).
87
87
  const principal = gatedPrincipal(req, deps.config); // direct-door safe — identity-only proof; single source of truth (see gatedPrincipal)
88
88
  const operators = deps.config.operatorPrincipals;
89
- const explicitOperator = explicitOperatorOk(principal, operators);
89
+ const isExplicitOperator = explicitOperatorOk(principal, operators);
90
90
  const cfg = deps.config.imageBakes;
91
91
  // 🔴 H1: the bake-runner identity is DERIVED from its bearer CREDENTIAL (the token-derived `source`,
92
92
  // resolved above from SERVICE_AUTH_TOKENS), NOT a self-asserted x-agent-principal/JWT the runner never
@@ -96,7 +96,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
96
96
  const isRunner = source !== undefined && source !== null && source === cfg.runnerPrincipal;
97
97
  // POST /v1/images/bakes — operator-only submit. EXPLICIT operator gate (NOT the bare isOperator; §P2.4a).
98
98
  if (req.method === "POST" && url === "/v1/images/bakes") {
99
- if (!explicitOperator) {
99
+ if (!isExplicitOperator) {
100
100
  sendError(res, 403, "auth.operator_only", "operator only");
101
101
  return;
102
102
  }
@@ -198,7 +198,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
198
198
  // GET /v1/images/bakes/:bakeId/events — resumable SSE (the live "baking…" view), run-trace-SSE-shaped (§P2.2).
199
199
  const evMatch = req.method === "GET" ? BAKE_EVENTS_RE.exec(url) : null;
200
200
  if (evMatch) {
201
- if (!explicitOperator) {
201
+ if (!isExplicitOperator) {
202
202
  sendError(res, 403, "auth.operator_only", "operator only");
203
203
  return;
204
204
  }
@@ -214,7 +214,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
214
214
  // POST /v1/images/bakes/:bakeId/cancel — cooperative cancel (durable flag; the runner kills the pgid, §P2.11).
215
215
  const cancelMatch = req.method === "POST" ? BAKE_CANCEL_RE.exec(url) : null;
216
216
  if (cancelMatch) {
217
- if (!explicitOperator) {
217
+ if (!isExplicitOperator) {
218
218
  sendError(res, 403, "auth.operator_only", "operator only");
219
219
  return;
220
220
  }
@@ -337,7 +337,7 @@ async function handleImagesBody(req, res, url, ctx, miss) {
337
337
  // the `/events|/cancel|/claim|/ingest` sub-actions (two-segment) win first.
338
338
  const idMatch = req.method === "GET" ? BAKE_ID_RE.exec(url) : null;
339
339
  if (idMatch) {
340
- if (!explicitOperator) {
340
+ if (!isExplicitOperator) {
341
341
  sendError(res, 403, "auth.operator_only", "operator only");
342
342
  return;
343
343
  }
@@ -362,10 +362,10 @@ async function handleImagesBody(req, res, url, ctx, miss) {
362
362
  if (deps.imageIndex && url.startsWith("/v1/images")) {
363
363
  const idx = deps.imageIndex;
364
364
  const principal = gatedPrincipal(req, deps.config); // direct-door safe — identity-only proof; single source of truth (see gatedPrincipal)
365
- // explicitOperator (NOT isOperator): for image VISIBILITY, an empty OPERATOR_PRINCIPALS must NOT make every
365
+ // explicitOperatorOk (NOT isOperator): for image VISIBILITY, an empty OPERATOR_PRINCIPALS must NOT make every
366
366
  // caller an operator (that would leak tenant-scoped images). Adversarial-review HIGH — same fix as the P0.5
367
367
  // per-task resolve in main.ts. (Approval operator gates below keep isOperator's legacy empty=all boundary.)
368
- const operator = explicitOperator(principal, deps.config.operatorPrincipals);
368
+ const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
369
369
  const viewer = { operator, tenantId: principal ?? null };
370
370
  const q = new URL(req.url ?? "", "http://x").searchParams;
371
371
  // GET /v1/images — the paginated catalog. ?profile=&status=&capability=&latest=true&limit=&cursor=
@@ -1134,7 +1134,12 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1134
1134
  const parkArbiterUnreachable = details?.error === "park_arbiter_unreachable";
1135
1135
  const parkResumeWon = details?.error === "park_resume_won";
1136
1136
  const stillParked = details?.error === "parked_pending_approval" || details?.status === "parked";
1137
- if (stopVerb && details?.error !== undefined && (details.status === "running" || stillParked || parkResumeWon || parkArbiterUnreachable)) {
1137
+ // v3.1 批2(统检第二波 high):判别方向翻转——旧形是 (status,error) 的**失败白名单**(status==="running"
1138
+ // || 三个具名码),core 新增失败码且行状态不在 {running,parked} 时整个分支被跳过落 200,与上面注释自陈
1139
+ // 的不变量(kill 没落地绝不读成功)正相悖(B5 联合非穷尽的 default-放行形;not_local/park 族/arbiter
1140
+ // 三次历史新增全靠人追认)。新形:stop 动词的 error **在场即失败**(not_found 已在上方早退),具名臂
1141
+ // 保留各自 errorCode,未知码落 stop.not_landed 兜底 409——新失败码默认可见,不默认成功。
1142
+ if (stopVerb && details?.error !== undefined) {
1138
1143
  // 1.250:durable 回落臂(core stopTask agentStore 分支)对他实例 running 行应答 error="not_local"
1139
1144
  // (没有 kill 被尝试)——与「kill 尝试了没落地」(stop.not_landed)是不同的失败形,各给各的
1140
1145
  // errorCode(同 409 家族,additive)。
@@ -31,8 +31,8 @@ import { cascadeConfig } from "./run-meta.js";
31
31
  export { cascadeConfig };
32
32
  import { coarseStatusForState, errorCodeForExit } from "./routes/images.js";
33
33
  export { coarseStatusForState, errorCodeForExit };
34
- import { explicitOperatorOk, isOperator, explicitOperator } from "./principal-gate.js";
35
- export { explicitOperatorOk, isOperator, explicitOperator };
34
+ import { explicitOperatorOk, isOperator } from "./principal-gate.js";
35
+ export { explicitOperatorOk, isOperator };
36
36
  /** Per-request authorization context resolved before building the spec (identity, owned session). */
37
37
  export interface RequestAuth {
38
38
  principal?: string;
@@ -48,8 +48,8 @@ export { coarseStatusForState, errorCodeForExit };
48
48
  // design/158 A9:装配器缝——发送器/身份门下沉到 http/ 叶子模块,routes/* 与 server.ts 共用同一实现
49
49
  // (routes/* 绝不可值 import server.ts:那条边会闭合运行时环,见 test/module-cycle-gate.test.ts)。
50
50
  import { sendJson, sendError, httpErrorCode, msg } from "./send.js";
51
- import { authorized, systemFor, gatedPrincipal, explicitOperatorOk, isOperator, explicitOperator } from "./principal-gate.js";
52
- export { explicitOperatorOk, isOperator, explicitOperator };
51
+ import { authorized, systemFor, gatedPrincipal, explicitOperatorOk, isOperator } from "./principal-gate.js";
52
+ export { explicitOperatorOk, isOperator };
53
53
  /** 分组入参 → 平铺视图(createHttpServer 的第一件事)。一组都没给 ⇒ 原样返回(存量调用零开销、零形变)。 */
54
54
  export function flattenServiceDeps(deps) {
55
55
  const { stores, coordinators, seams, observability, governance, deployment, knobs, ...flat } = deps;
@@ -1671,16 +1671,20 @@ export function createHttpServer(rawDeps) {
1671
1671
  if (!rs || !taskId)
1672
1672
  return;
1673
1673
  const t = e.type;
1674
+ // C2/C5(v3.1 批2):写失败留痕——同函数 onTaskNotification 的 park-enqueue 早按 2026-07-11
1675
+ // 对抗评审补了 warn(false park 指纹),这三处同构 fire-and-forget 此前裸吞:子代 tool 生命周期
1676
+ // 事件从 durable log 永久消失且零信号。结构上仍不能 await(同步回调),留痕不改调用形。
1677
+ const warnAppend = (kind) => (err) => deps.logger?.warn?.("forward_event_append_failed", { type: kind, taskId, err: err instanceof Error ? err.message : String(err) });
1674
1678
  if (t === "task_progress") {
1675
- void append("task_progress", taskProgressEventData(e)).catch(() => undefined);
1679
+ void append("task_progress", taskProgressEventData(e)).catch(warnAppend(t));
1676
1680
  }
1677
1681
  else if (t === "tool_start") {
1678
1682
  // C1 (core 1.219): a delegated child's forwarded tool lifecycle — durable via the SAME shared
1679
1683
  // whitelist+redact builders as the top stream (parity with the bg leg; deltas not persisted per-chunk).
1680
- void append("tool_start", toolStartEventData(e)).catch(() => undefined);
1684
+ void append("tool_start", toolStartEventData(e)).catch(warnAppend(t));
1681
1685
  }
1682
1686
  else if (t === "tool_end") {
1683
- void append("tool_end", toolEndEventData(e)).catch(() => undefined);
1687
+ void append("tool_end", toolEndEventData(e)).catch(warnAppend(t));
1684
1688
  }
1685
1689
  },
1686
1690
  // background-completion observer — flip the child's fleet row +
@@ -2559,7 +2563,7 @@ function cors(res, req, origins, principalHeader) {
2559
2563
  // 后五项都是**真被 handler 读**的自定义头:`idempotency-key`(tasks/runs/images)、
2560
2564
  // `x-detach-on-disconnect`(tasks 断连不中止)、direct-door 决策证明三件套(approvals /decide 的
2561
2565
  // crypto 绑定腿 —— 缺了它,direct-door worker 上的 web 审批面在浏览器里根本用不了)。
2562
- res.setHeader("access-control-allow-headers", `content-type, authorization, last-event-id, ${principalHeader}, idempotency-key, x-detach-on-disconnect, x-approval-principal-token, x-approval-mac, x-approval-mac-kid`);
2566
+ res.setHeader("access-control-allow-headers", `content-type, authorization, last-event-id, ${principalHeader}, idempotency-key, x-detach-on-disconnect, x-approval-principal-token, x-approval-mac, x-approval-mac-kid, if-none-match`);
2563
2567
  // 🔴 expose-headers 此前**整个缺席** ⇒ fetch 类客户端读不到 `X-Task-Id`(routes/tasks.ts 经 sseHeaders
2564
2568
  // 下发的 durable rewind handle)。tasks.ts 的 G15 meta 帧注释把这归因为「EventSource 与部分代理读不到
2565
2569
  // 响应头」—— 对 EventSource 成立,但对 fetch 客户端真因是这里缺 expose,meta 帧只是绕过。
@@ -23,7 +23,13 @@ import { type SealedKeyPoison } from "./sealed-key.js";
23
23
  * construction in applyEffective (a sealed model never lands in modelApiKeyEnv); the ordering here is
24
24
  * belt-and-suspenders. 🔴 Plaintext values are live secrets: memory-only, never log them.
25
25
  */
26
- export declare function createKeyResolver(modelApiKeyEnv: Record<string, string>, env?: NodeJS.ProcessEnv, modelApiKeys?: Record<string, string | SealedKeyPoison>): ((model: Model) => Promise<{
26
+ /** The ONE field the per-model key plane keys on. The resolver's parameter is deliberately this minimal
27
+ * named ref (not the full core `Model`): full models are structural supersets and pass unchanged, while
28
+ * callers that never materialize a full Model (hook-llm resolves keys for roster entries by catalog name)
29
+ * can mint `{ name }` legally instead of forcing it through an `as never` escape hatch (B1). Contravariance
30
+ * keeps the resolver assignable wherever `(model: Model) => …` is expected. */
31
+ export type ModelKeyRef = Pick<Model, "name">;
32
+ export declare function createKeyResolver(modelApiKeyEnv: Record<string, string>, env?: NodeJS.ProcessEnv, modelApiKeys?: Record<string, string | SealedKeyPoison>): ((model: ModelKeyRef) => Promise<{
27
33
  apiKey: string;
28
34
  } | undefined>) | undefined;
29
35
  /**
@@ -1,27 +1,4 @@
1
1
  import { isSealedKeyPoison, SealedKeyPoisonedError } from "./sealed-key.js";
2
- /**
3
- * Build a per-model API-key resolver for `TaskSpec.getApiKeyAndHeaders` from the catalog's
4
- * name → env-var-NAME map (sema-registry `apiKeyEnv`). core threads this per brain call — and per cascade
5
- * rung — so each model/rung authenticates with its OWN upstream account/key.
6
- *
7
- * Additive by construction: a model absent from the map — or whose env var is unset — returns
8
- * `undefined`, so core falls back to the brain's construction-time gateway key (today's behavior, no
9
- * regression). When no per-model keys are configured at all this returns `undefined`, leaving the spec
10
- * field unset so core's default path is byte-for-byte unchanged.
11
- *
12
- * Only the api KEY is per-model; the base URL stays brain-owned (design/15) — core's seam carries
13
- * `{ apiKey, headers? }`, not a baseUrl. Same-account-rotation could round-robin inside this function.
14
- *
15
- * `modelApiKeys` (sealed-box custody) carries per-model entries unsealed from `ModelEntry.sealedApiKey` —
16
- * resolved FIRST (sealed outranks the env-NAME reference, the registry-core mutual-exclusion ruling). Each
17
- * entry is either the PLAINTEXT key (successful unseal) or a `SealedKeyPoison` marker (unseal FAILED at
18
- * apply time): a poisoned model THROWS `SealedKeyPoisonedError` on every resolve — task-level fail-loud —
19
- * because returning `undefined` here means "use the gateway key", and a broken sealed key must NEVER
20
- * silently burn the shared gateway account (the core invariant of the poison-pill fix; a poisoned model
21
- * skips its env reference too, same mutual-exclusion as a healthy sealed key). The maps are disjoint by
22
- * construction in applyEffective (a sealed model never lands in modelApiKeyEnv); the ordering here is
23
- * belt-and-suspenders. 🔴 Plaintext values are live secrets: memory-only, never log them.
24
- */
25
2
  export function createKeyResolver(modelApiKeyEnv, env = process.env, modelApiKeys = {}) {
26
3
  if (Object.keys(modelApiKeyEnv).length === 0 && Object.keys(modelApiKeys).length === 0)
27
4
  return undefined;
@@ -115,7 +115,6 @@ export declare function stageWorkerEnv(env: WireEnv, opts: {
115
115
  uploadScriptPath: string;
116
116
  uploadScript: string;
117
117
  }): Promise<void>;
118
- /** Build the `runLeader(body)` the HTTP endpoint invokes. Constructs real per-task deps + runs the leader. */
119
118
  /**
120
119
  * Per-leader-run resource bounds derived from env (2026-06-14). Exported for unit tests. The worker sandbox
121
120
  * lifetime (BUG2), the worker's diff-upload presigned-URL TTL (BUG1), and the worker spec's triple bound
@@ -140,6 +139,25 @@ export declare function leaderResourceConfig(env?: NodeJS.ProcessEnv): {
140
139
  maxSuspends: number;
141
140
  };
142
141
  };
142
+ /** Repair/conflict/replan-loop knobs derived from env + optional `LeaderWireConfig` fallbacks (`repairRounds`/
143
+ * `conflictRounds` land in cfg when the caller wires them programmatically instead of via env). Split out of
144
+ * `createLeaderRunner`'s closure so it's a plain, unit-testable data constructor. */
145
+ export interface LeaderLoopConfig {
146
+ repairRounds: number;
147
+ repairBudgetUsd: number;
148
+ conflictRounds: number;
149
+ repairLoopOn: boolean;
150
+ measureGatesOn: boolean;
151
+ repairLoopAttempts: number;
152
+ oracleFlakyK: number;
153
+ /** replan-lite (design/68 §6) fan-out spend cap from LEADER_BUDGET_USD. Absent = replan-lite's own default
154
+ * (unset env stays absent — existing contract untouched; see leaderLoopConfig doc below). */
155
+ replanBudgetUsd?: number;
156
+ }
157
+ export declare function leaderLoopConfig(cfg: {
158
+ repairRounds?: number;
159
+ conflictRounds?: number;
160
+ }, env?: NodeJS.ProcessEnv): LeaderLoopConfig;
143
161
  export declare function createLeaderRunner(cfg: LeaderWireConfig): (body: LeaderRequestBody) => Promise<LeaderResult>;
144
162
  export {};
145
163
  //# sourceMappingURL=wire.d.ts.map
@@ -98,6 +98,18 @@ export async function stageWorkerEnv(env, opts) {
98
98
  throw new Error(`upload-script staging failed: ${wr.error.message}`);
99
99
  }
100
100
  /** Build the `runLeader(body)` the HTTP endpoint invokes. Constructs real per-task deps + runs the leader. */
101
+ // 🔴 review-batch(重构池②,2026-08-01):`parseNumOrFail` alone still lets a NEGATIVE value through — a negative
102
+ // number is JS-truthy, so every `parseNumOrFail(...) || default` chain in this file passed it straight through
103
+ // instead of falling back (LEADER_WORKER_MAX_TURNS=-5 ⇒ workerMaxTurns=-5, unchanged by any later `|| default`).
104
+ // This is the one extra domain bound (A2: illegal input INCLUDING negatives must fail loud) layered on the same
105
+ // shared parse primitive — not a second parser. `undefined` (unset) stays NaN (`NaN < 0` is false) so the
106
+ // existing `|| default` fallback chains are untouched; only "non-numeric" and "negative" newly throw.
107
+ function parseNumOrFailNonNegative(name, raw) {
108
+ const n = parseNumOrFail(name, raw);
109
+ if (n < 0)
110
+ throw new Error(`env ${name}=${n} must not be negative`);
111
+ return n;
112
+ }
101
113
  /**
102
114
  * Per-leader-run resource bounds derived from env (2026-06-14). Exported for unit tests. The worker sandbox
103
115
  * lifetime (BUG2), the worker's diff-upload presigned-URL TTL (BUG1), and the worker spec's triple bound
@@ -111,13 +123,13 @@ export async function stageWorkerEnv(env, opts) {
111
123
  // ⇒ diff 上传预签名 URL 的暴露窗口一起放大。改走与 numEnv 同源的 parseNumOrFail:非法值当场报错。
112
124
  // 刻意**零行为变更**:`|| default` 保留,所以未设与显式 0 的既有语义一个没动。
113
125
  export function leaderResourceConfig(env = process.env) {
114
- const leaderTimeoutMs = Math.max(600_000, Math.floor(parseNumOrFail("LEADER_TIMEOUT_MS", env.LEADER_TIMEOUT_MS) || 0) || 86_400_000); // default 24h (was 10min)
115
- const workerMaxTurns = Math.floor(parseNumOrFail("LEADER_WORKER_MAX_TURNS", env.LEADER_WORKER_MAX_TURNS) || 0) || 10_000;
116
- const workerBudgetUsd = (parseNumOrFail("LEADER_WORKER_BUDGET_USD", env.LEADER_WORKER_BUDGET_USD) || 0) || 100;
126
+ const leaderTimeoutMs = Math.max(600_000, Math.floor(parseNumOrFailNonNegative("LEADER_TIMEOUT_MS", env.LEADER_TIMEOUT_MS) || 0) || 86_400_000); // default 24h (was 10min)
127
+ const workerMaxTurns = Math.floor(parseNumOrFailNonNegative("LEADER_WORKER_MAX_TURNS", env.LEADER_WORKER_MAX_TURNS) || 0) || 10_000;
128
+ const workerBudgetUsd = (parseNumOrFailNonNegative("LEADER_WORKER_BUDGET_USD", env.LEADER_WORKER_BUDGET_USD) || 0) || 100;
117
129
  const resourceSuspendOn = String(env.LEADER_RESOURCE_SUSPEND ?? "").toLowerCase() === "true";
118
130
  // Per-slice window: a fraction of the total so a worker suspends+resumes several times across its budget
119
131
  // (default = total/4, min $1). Smaller window ⇒ more, shorter slices ⇒ progress saved more often.
120
- const sliceMaxCostUsd = (parseNumOrFail("LEADER_WORKER_SLICE_BUDGET_USD", env.LEADER_WORKER_SLICE_BUDGET_USD) || 0) || Math.max(1, workerBudgetUsd / 4);
132
+ const sliceMaxCostUsd = (parseNumOrFailNonNegative("LEADER_WORKER_SLICE_BUDGET_USD", env.LEADER_WORKER_SLICE_BUDGET_USD) || 0) || Math.max(1, workerBudgetUsd / 4);
121
133
  return {
122
134
  leaderTimeoutMs,
123
135
  workerMaxTurns,
@@ -135,24 +147,41 @@ export function leaderResourceConfig(env = process.env) {
135
147
  : {}),
136
148
  };
137
149
  }
138
- export function createLeaderRunner(cfg) {
139
- const W = cfg.workspace ?? "/home/user";
140
- const repo = `${W}/repo`;
141
- const ident = cfg.git ?? { name: "leader", email: "leader@local" };
142
- const { leaderTimeoutMs, workerLimits, presignTtlSec, resourceSuspend: resourceCfg } = leaderResourceConfig();
143
- const repairRounds = Math.max(0, Math.floor(Number(process.env.LEADER_REPAIR_ROUNDS ?? cfg.repairRounds ?? 0)) || 0);
144
- const repairBudgetUsd = Number(process.env.LEADER_REPAIR_BUDGET_USD ?? "4") || 4;
145
- const conflictRounds = Math.max(0, Math.floor(Number(process.env.LEADER_CONFLICT_ROUNDS ?? cfg.conflictRounds ?? 0)) || 0);
150
+ // 🔴 review-batch(重构池②,2026-08-01): every knob below used to be a bare `Number(process.env.X ?? default)`
151
+ // with ZERO fail-loud guard — worst case was `LEADER_BUDGET_USD="20usd"` silently becoming `budgetUsd: NaN`,
152
+ // which makes the replan-lite budget gate's `spent > budgetUsd` comparison permanently `false` (the whole
153
+ // gate goes dark, not "loose" `NaN` compares false in every direction). Converged onto the same
154
+ // `parseNumOrFailNonNegative` primitive leaderResourceConfig uses: non-numeric and negative now throw at
155
+ // startup; `undefined`/explicit-`0` fallback semantics are byte-identical to before.
156
+ export function leaderLoopConfig(cfg, env = process.env) {
157
+ const repairRounds = Math.max(0, Math.floor(env.LEADER_REPAIR_ROUNDS !== undefined
158
+ ? parseNumOrFailNonNegative("LEADER_REPAIR_ROUNDS", env.LEADER_REPAIR_ROUNDS)
159
+ : (cfg.repairRounds ?? 0)) || 0);
160
+ const repairBudgetUsd = parseNumOrFailNonNegative("LEADER_REPAIR_BUDGET_USD", env.LEADER_REPAIR_BUDGET_USD ?? "4") || 4;
161
+ const conflictRounds = Math.max(0, Math.floor(env.LEADER_CONFLICT_ROUNDS !== undefined
162
+ ? parseNumOrFailNonNegative("LEADER_CONFLICT_ROUNDS", env.LEADER_CONFLICT_ROUNDS)
163
+ : (cfg.conflictRounds ?? 0)) || 0);
146
164
  // ── LEADER-REPAIRLOOP-INTEGRATION §10 env knobs (FRESH hunk, §10.8 — mirrors LEADER_REPAIR_ROUNDS above) ──
147
165
  // Every flag default-OFF so the whole feature is inert until flipped on a canary (the default path is
148
166
  // byte-identical to today). `LEADER_REPAIR_LOOP` is the MASTER (gates the single-agent `runRepairLoop` dep);
149
167
  // `LEADER_MEASURE_GATES` gates the merge push-hold-on-signal + measure-drives-repair (§10.3/§10.4);
150
168
  // `LEADER_REPAIR_LOOP_ATTEMPTS` is the in-loop attempt ceiling (validated 2-3, default 2 — design/78 §5);
151
169
  // `LEADER_ORACLE_FLAKY_K` is the flaky-settle re-isolation count (default 2 — §10.1 / repair-oracle.ts).
152
- const repairLoopOn = String(process.env.LEADER_REPAIR_LOOP ?? "").toLowerCase() === "true";
153
- const measureGatesOn = String(process.env.LEADER_MEASURE_GATES ?? "").toLowerCase() === "true";
154
- const repairLoopAttempts = Math.min(3, Math.max(2, Math.floor(Number(process.env.LEADER_REPAIR_LOOP_ATTEMPTS ?? "2")) || 2));
155
- const oracleFlakyK = Math.max(1, Math.floor(Number(process.env.LEADER_ORACLE_FLAKY_K ?? "2")) || 2);
170
+ const repairLoopOn = String(env.LEADER_REPAIR_LOOP ?? "").toLowerCase() === "true";
171
+ const measureGatesOn = String(env.LEADER_MEASURE_GATES ?? "").toLowerCase() === "true";
172
+ const repairLoopAttempts = Math.min(3, Math.max(2, Math.floor(parseNumOrFailNonNegative("LEADER_REPAIR_LOOP_ATTEMPTS", env.LEADER_REPAIR_LOOP_ATTEMPTS ?? "2")) || 2));
173
+ const oracleFlakyK = Math.max(1, Math.floor(parseNumOrFailNonNegative("LEADER_ORACLE_FLAKY_K", env.LEADER_ORACLE_FLAKY_K ?? "2")) || 2);
174
+ return {
175
+ repairRounds, repairBudgetUsd, conflictRounds, repairLoopOn, measureGatesOn, repairLoopAttempts, oracleFlakyK,
176
+ ...(env.LEADER_BUDGET_USD ? { replanBudgetUsd: parseNumOrFailNonNegative("LEADER_BUDGET_USD", env.LEADER_BUDGET_USD) } : {}),
177
+ };
178
+ }
179
+ export function createLeaderRunner(cfg) {
180
+ const W = cfg.workspace ?? "/home/user";
181
+ const repo = `${W}/repo`;
182
+ const ident = cfg.git ?? { name: "leader", email: "leader@local" };
183
+ const { leaderTimeoutMs, workerLimits, presignTtlSec, resourceSuspend: resourceCfg } = leaderResourceConfig();
184
+ const { repairRounds, repairBudgetUsd, conflictRounds, repairLoopOn, measureGatesOn, repairLoopAttempts, oracleFlakyK, replanBudgetUsd, } = leaderLoopConfig({ repairRounds: cfg.repairRounds, conflictRounds: cfg.conflictRounds });
156
185
  // worker resource bounds (limits/maxCostUsd) + presign ttl are derived in leaderResourceConfig(), above.
157
186
  // Bounded integration-repair (search 2026-06-14): when the merged tree compiles-clean-but-fails, run a strong
158
187
  // agent WITH HANDS in the live merged sandbox to fix the cross-worker integration, then mergeBranches re-runs
@@ -590,8 +619,9 @@ export function createLeaderRunner(cfg) {
590
619
  // found live: task02b run5, porting workers + collapse-solo all 'run aborted' at the 600s default while
591
620
  // mid-gradle; the per-worker presign ttl below tracks the same knob).
592
621
  timeoutMs: leaderTimeoutMs, maxConcurrency: 4, // Semaphore cap; the planner emits ≤6 sub-tasks
593
- // replan-lite (design/68 §6) always on in the wired leader; LEADER_BUDGET_USD bounds the fan-out spend.
594
- replan: { ...(process.env.LEADER_BUDGET_USD ? { budgetUsd: Number(process.env.LEADER_BUDGET_USD) } : {}) },
622
+ // replan-lite (design/68 §6) always on in the wired leader; LEADER_BUDGET_USD bounds the fan-out spend
623
+ // (parsed + fail-loud-guarded in leaderLoopConfig(), above see LEADER_BUDGET_USD review-batch note).
624
+ replan: { ...(replanBudgetUsd !== undefined ? { budgetUsd: replanBudgetUsd } : {}) },
595
625
  // LEADER-REPAIRLOOP-INTEGRATION §10.3 — the merge push-hold-on-signal (OFF unless LEADER_MEASURE_GATES). It
596
626
  // needs NO extra sandbox: `strongOracleSeeded` keys on whether a hidden held-out oracle was injected into the
597
627
  // integration sandbox (`injectOracles` runs `body.oracleFiles`), and the §10.3 hold combines it with the
@@ -26,7 +26,16 @@ const TOKEN = process.env.LSP_TOKEN || '';
26
26
  const MAX = 50 * 1024 * 1024;
27
27
  const HEARTBEAT_MS = Number(process.env.LSP_HEARTBEAT_MS || 30000);
28
28
  const wss = new WebSocketServer({ host: '0.0.0.0', port: PORT });
29
+ // A zero-listener 'error' on ANY EventEmitter throws and crashes the process (Node default). Without this, a
30
+ // bind failure (e.g. EADDRINUSE — a second bridge start racing the same port) took down the WHOLE bridge
31
+ // process, killing every language's LSP session in the sandbox, not just the one that failed to start (HRD-LSP-4).
32
+ // Exit loud + explicit instead of an uncaught-exception dump so the failure is at least attributable.
33
+ wss.on('error', (err) => { console.error('lsp-bridge wss error', err && err.message); process.exit(1); });
29
34
  wss.on('connection', (ws, req) => {
35
+ // Same zero-listener 'error' hazard as wss above, but per-connection: registered FIRST, before anything else
36
+ // touches this socket. Close (don't crash) — the heartbeat reaper's own ws.terminate() below is a routine
37
+ // source of a socket 'error' on an already-half-open connection (HRD-LSP-4).
38
+ ws.on('error', () => { try { ws.terminate(); } catch {} });
30
39
  const q = ((req && req.url) || '').split('?')[1] || '';
31
40
  if (TOKEN && new URLSearchParams(q).get('token') !== TOKEN) { ws.close(); return; } // auth (council #4)
32
41
  ws.isAlive = true;
@@ -37,7 +46,11 @@ wss.on('connection', (ws, req) => {
37
46
  const decode = makeFrameDecoder(MAX);
38
47
  child.stdout.on('data', (d) => { for (const f of decode(d)) ws.send(f); });
39
48
  ws.on('message', (data) => { const p = Buffer.from(data.toString(), 'utf8'); child.stdin.write('Content-Length: ' + p.length + '\r\n\r\n'); child.stdin.write(p); });
40
- ws.on('close', () => child.kill());
49
+ // pid guard: the child 'error' handler above routes spawn FAILURES (missing binary — pid never assigned)
50
+ // through ws.close() → this 'close' handler. Killing a child whose pid was never assigned can signal the
51
+ // CALLER's process group instead of the (nonexistent) child (HRD-LSP-4; same hazard class as bake-runner's
52
+ // spawner, src/bake-runner/main.ts).
53
+ ws.on('close', () => { if (child.pid !== undefined) child.kill(); });
41
54
  child.on('exit', () => { try { ws.close(); } catch {} });
42
55
  });
43
56
  // Reap half-open connections: the E2B proxy kills a WS at ~60-75s and the kill can be SILENT on THIS side too —
@@ -8,9 +8,11 @@ export interface E2bLspOptions {
8
8
  attempts: number;
9
9
  delayMs: number;
10
10
  };
11
- /** Bridge auth token override. The manager pins ONE token per env+language: on a RE-open (transport heal
12
- * after the E2B proxy idle-killed the WS) the original bridge process is still listening with the FIRST
13
- * token a fresh random token would be rejected and the reconnect would always fail. */
11
+ /** Bridge auth token override. The manager pins ONE token per env+PORT the bridge RESOURCE, not the
12
+ * language (`portForLanguage` maps several languages, e.g. typescript/javascript, onto the SAME port/process
13
+ * — see `createE2bLspManager`). On a RE-open (transport heal after the E2B proxy idle-killed the WS) the
14
+ * original bridge process is still listening with the FIRST token — a fresh random token would be rejected
15
+ * and the reconnect would always fail. */
14
16
  token?: string;
15
17
  /** WS scheme for the bridge URL. E2B's `getHost` returns a public TLS proxy host → `wss` (default);
16
18
  * the k8s adapter returns a cluster-internal `podIP:port` → plain `ws` (no TLS on the pod network;
@@ -19,7 +21,9 @@ export interface E2bLspOptions {
19
21
  /** Observability hook (open/heal/degrade events) — prod wires the service logger. */
20
22
  log?: (event: string, fields: Record<string, unknown>) => void;
21
23
  }
22
- /** Start (+ install on a non-baked template) the language server + bridge inside THIS env's sandbox, then connect. */
24
+ /** Start (+ install on a non-baked template) the language server + bridge inside THIS env's sandbox, then connect.
25
+ * Every call starts (or re-starts) the bridge unconditionally — the one production caller is
26
+ * `createE2bLspManager`, which de-dupes per env+port before ever reaching here (see its doc). */
23
27
  export declare function openE2bLspTransport(language: string, env: LspCapableEnv, opts?: E2bLspOptions): Promise<LspTransport | undefined>;
24
28
  /** The single shared `LspServerManager` for the deployment (`RunnerDeps.lspManager`). */
25
29
  export declare function createE2bLspManager(opts?: E2bLspOptions): E2bLspManager;