@sema-agent/server 7.22.0-rc.1 → 7.23.0-rc.1

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 (53) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/USAGE.md +6 -4
  4. package/deploy/sema-up/chart/values.yaml +1 -1
  5. package/dist/approval-card.d.ts +53 -0
  6. package/dist/approval-card.js +104 -1
  7. package/dist/bench/s1/run-firm.js +1 -1
  8. package/dist/boot/budget-tracing.d.ts +0 -1
  9. package/dist/boot/resolve-spec.js +3 -1
  10. package/dist/budget.d.ts +0 -11
  11. package/dist/budget.js +10 -22
  12. package/dist/capabilities/scenarios.js +14 -8
  13. package/dist/config.d.ts +1 -1
  14. package/dist/config.js +12 -1
  15. package/dist/fleet/fleet-bus.d.ts +8 -3
  16. package/dist/fleet/fleet-bus.js +10 -5
  17. package/dist/http/route-ctx.d.ts +11 -0
  18. package/dist/http/route-ctx.js +16 -1
  19. package/dist/http/routes/runs.d.ts +3 -0
  20. package/dist/http/routes/runs.js +28 -24
  21. package/dist/http/routes/side-query.js +4 -13
  22. package/dist/http/routes/tasks.js +15 -3
  23. package/dist/http/server.d.ts +4 -3
  24. package/dist/http/server.js +8 -4
  25. package/dist/http/wire-types.d.ts +3 -1
  26. package/dist/lsp/manager.d.ts +9 -0
  27. package/dist/lsp/manager.js +8 -26
  28. package/dist/main.js +4 -0
  29. package/dist/observability/fail-open.d.ts +4 -0
  30. package/dist/observability/fail-open.js +4 -0
  31. package/dist/observability/otel-exporter.js +7 -1
  32. package/dist/observability/secret-env-scrub.d.ts +53 -0
  33. package/dist/observability/secret-env-scrub.js +135 -0
  34. package/dist/plugins/image-bake-store-sql.d.ts +8 -1
  35. package/dist/plugins/image-bake-store-sql.js +8 -1
  36. package/dist/plugins/image-index-sql.d.ts +13 -3
  37. package/dist/plugins/image-index-sql.js +13 -3
  38. package/dist/plugins/permission-rule-store-sql.d.ts +9 -72
  39. package/dist/plugins/permission-rule-store-sql.js +36 -64
  40. package/dist/plugins/remote-env-host.js +10 -2
  41. package/dist/project-memory.js +7 -1
  42. package/dist/run-local.js +7 -0
  43. package/dist/runtime-governance.d.ts +1 -1
  44. package/dist/runtime-governance.js +12 -15
  45. package/dist/security.d.ts +3 -1
  46. package/dist/security.js +15 -7
  47. package/dist/task-settings.js +10 -5
  48. package/dist/tool-approval.d.ts +20 -1
  49. package/dist/tool-approval.js +4 -1
  50. package/dist/trace/core-keyset-guard.d.ts +1 -1
  51. package/dist/trace/project.d.ts +2 -0
  52. package/dist/trace/project.js +11 -0
  53. package/package.json +3 -3
@@ -19,6 +19,9 @@ export declare const RUN_CANCEL_RE: RegExp;
19
19
  export declare const RUN_STEER_RE: RegExp;
20
20
  export declare const RUN_DETACH_RE: RegExp;
21
21
  export declare const RUN_COMPACT_RE: RegExp;
22
+ /** PAIR-REVIEW F-10:runs 面 404 文案统一富形——a\* 形 id(background-agent registry 句柄,`a`+hex 短形)
23
+ * 是「结构性不在 run 店」的常客([1347]⑤ 记档裁量:不改路由,只给引导)。识别句法形即补引导;
24
+ * 非 a\* 形保通用富文案(cancel 腿 [888]① 同款)。 */
22
25
  export declare const ELICIT_RESPOND_RE: RegExp;
23
26
  export declare const QUESTION_RESPOND_RE: RegExp;
24
27
  export declare const TOOL_APPROVAL_RESPOND_RE: RegExp;
@@ -21,6 +21,7 @@ import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
21
21
  import { buildActiveRunConflict } from "../active-run-conflict.js";
22
22
  import { clearTurnActivity, readTurnActivityMs } from "../../turn-activity.js";
23
23
  import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
24
+ import { RUN_NOT_FOUND_MESSAGE, runNotFoundMessage } from "../route-ctx.js";
24
25
  // server.ts 侧的 routeLabel / isBillableSubmitPath 仍要用下面这些正则,故本模块导出(方向恒为 server.ts → routes/*)。
25
26
  export const RUN_ID_RE = /^\/v1\/runs\/([^/]+)(\/events)?$/;
26
27
  export const RUN_CANCEL_RE = /^\/v1\/runs\/([^/]+)\/cancel$/;
@@ -32,15 +33,8 @@ export const RUN_COMPACT_RE = /^\/v1\/runs\/([^/]+)\/compact$/;
32
33
  /** PAIR-REVIEW F-10:runs 面 404 文案统一富形——a\* 形 id(background-agent registry 句柄,`a`+hex 短形)
33
34
  * 是「结构性不在 run 店」的常客([1347]⑤ 记档裁量:不改路由,只给引导)。识别句法形即补引导;
34
35
  * 非 a\* 形保通用富文案(cancel 腿 [888]① 同款)。 */
35
- /** B7(design/158):文案逐字不动,只把「体」换成「文案」——两个调用点改走 `sendError(…, "not_found.run", …)`,
36
- * 机器码由发送点携带(errorCode 恒在),这样错误码门不必为「体来自 helper」的站点开豁免。 */
37
- function runNotFoundMessage(id) {
38
- // [1434]:前缀=域判别子既成契约——wa\*(workflow agent 观测行,1.356)与 a\* 同族放宽。
39
- const aStar = /^(?:a|wa)[0-9a-f]{4,}$/i.test(id);
40
- return aStar
41
- ? `run not found — "${id}" looks like an agent handle (a*/wa* domain), which is never in the run store; use the TaskOutput tool (or the workflow journal / subagents surface) to read it, and the run's durable taskId (X-Task-Id) for this endpoint`
42
- : "run not found — the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)";
43
- }
36
+ // #262:runNotFoundMessage 与 RUN_NOT_FOUND_MESSAGE 搬 route-ctx 叶子(server.ts 的 owner 门也要同串,
37
+ // 两文件共享必须走叶子防环)——unknown not-yours 两臂自此单一文案源,oracle 封口理由见叶子头注。
44
38
  export const ELICIT_RESPOND_RE = /^\/v1\/elicitations\/([^/]+)\/respond$/;
45
39
  // §4④: the shell answers the agent's AskUserQuestion here. Same live-only + same-replica shape as elicit.
46
40
  export const QUESTION_RESPOND_RE = /^\/v1\/questions\/([^/]+)\/respond$/;
@@ -140,12 +134,12 @@ async function subagentReadGate(req, res, deps, runId) {
140
134
  const trusted = explicitOperatorOk(principal, deps.config.operatorPrincipals);
141
135
  const run = await deps.runStore.getRun(runId);
142
136
  if (!run || (!trusted && run.owner !== null && run.owner !== principal)) {
143
- sendError(res, 404, "not_found.run", "run not found");
137
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE); // #262:与 unknown 臂同串,oracle 封口
144
138
  return null;
145
139
  }
146
140
  const callerSession = new URL(req.url ?? "", "http://x").searchParams.get("session");
147
141
  if (!trusted && run.sessionId && callerSession !== run.sessionId) {
148
- sendError(res, 404, "not_found.run", "run not found");
142
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE); // #262:与 unknown 臂同串,oracle 封口
149
143
  return null;
150
144
  }
151
145
  return { run, trusted };
@@ -495,7 +489,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
495
489
  if (!run) {
496
490
  // [888]① 文案:这个 face 覆盖本部署 run store 里的全部 run(bg/durable + interactive sync 腿都建行、
497
491
  // 都注册快路)——404 只剩「id 属于别的进程/别的 store」一类(如另起的 server 进程配 in-memory store)。
498
- sendError(res, 404, "not_found.run", "run not found — the id belongs to no run in this deployment's run store (a run from another server process, or an in-memory store that did not survive a restart, is not visible here)");
492
+ sendError(res, 404, "not_found.run", runNotFoundMessage(taskId)); // #262:硬编码同串归 helper
499
493
  return;
500
494
  }
501
495
  if (!runOwnerOk(req, res, run.owner))
@@ -776,9 +770,9 @@ async function handleRunsBody(req, res, url, ctx, miss) {
776
770
  // Owner-gate (parity with preempt): an EXPLICIT operator may steer any tenant's run (supervisor intervention);
777
771
  // a normal caller only its own. 404 (not 403) for a non-owner — no existence oracle.
778
772
  if (!trusted && run.owner !== null && run.owner !== principal) {
779
- sendError(res, 404, "not_found.run", "run not found");
773
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
780
774
  return;
781
- }
775
+ } // #262 同串
782
776
  if (!runSessionAcceptOk(req, res, run, "run.steer"))
783
777
  return; // [1499] accept-phase
784
778
  // Durable-park helper: land the steer on this session's PENDING checkpoint. The checkpoint table — not the run
@@ -833,10 +827,15 @@ async function handleRunsBody(req, res, url, ctx, miss) {
833
827
  const sendParked = (status) => sendJson(res, 202, { taskId, status, delivery: "queued", messageId, ...(priority ? { priority } : {}), note: "steer parked on the checkpoint — injected when the run resumes" });
834
828
  const sendNotRunning = (error) => sendError(res, 409, "steering.not_running", error);
835
829
  // (a) Same-replica live path: the run is streaming here → inject now (drained at the next turn boundary).
830
+ // core 5.35.0(#257/#259 收口,[4022] G1):live 腿把同一个 `steerInputId` 接到 `TaskStream.steer` 的
831
+ // `inputId` 上 —— 此前只有 parked 腿去重,「202 丢在路上、客户端照常重试」打在 live 腿上就是二次注入
832
+ // (parked/live 两腿一条 ingress 键,自本版起 replay-safe 等强;live 判重窗口=流内存活期,契约成文)。
833
+ // 同 key 同载荷重放 = 幂等 no-op(照常 200,core 零二次注入零二次 human_input 帧);同 key 异载荷 =
834
+ // `steering.duplicate_input_id` → 409(与 parked 腿 sendDuplicateKey 同码同文案族)。
836
835
  const live = steerableRuns.get(taskId);
837
836
  if (live) {
838
837
  try {
839
- await live.steer(text, { trusted });
838
+ await live.steer(text, { trusted, ...(steerInputId !== undefined ? { inputId: steerInputId } : {}) });
840
839
  sendJson(res, 200, { taskId, status: "running", delivery: "applied", messageId, ...(priority ? { priority } : {}) });
841
840
  return;
842
841
  }
@@ -846,6 +845,11 @@ async function handleRunsBody(req, res, url, ctx, miss) {
846
845
  sendError(res, 422, "steering.invalid_content", e instanceof Error ? e.message : "invalid steering content");
847
846
  return;
848
847
  }
848
+ if (code === "steering.duplicate_input_id") {
849
+ parkDuplicateKey = e instanceof Error ? e.message : undefined;
850
+ sendDuplicateKey();
851
+ return;
852
+ }
849
853
  if (code !== "steering.not_running")
850
854
  throw e; // unexpected → outer handler 500
851
855
  // Raced: the stream finished between the map lookup and steer(). It may have durably SUSPENDED in the SAME
@@ -1039,16 +1043,16 @@ async function handleRunsBody(req, res, url, ctx, miss) {
1039
1043
  const taskId = compactMatch[1];
1040
1044
  const run = await deps.runStore.getRun(taskId);
1041
1045
  if (!run) {
1042
- sendError(res, 404, "not_found.run", "run not found");
1046
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
1043
1047
  return;
1044
- }
1048
+ } // #262 同串
1045
1049
  // Owner-gate (parity with steer/preempt): an explicit operator may compact any tenant's run; a normal caller
1046
1050
  // only its own. 404 (not 403) for a non-owner — no existence oracle.
1047
1051
  const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
1048
1052
  if (!operator && run.owner !== null && run.owner !== principal) {
1049
- sendError(res, 404, "not_found.run", "run not found");
1053
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
1050
1054
  return;
1051
- }
1055
+ } // #262 同串
1052
1056
  if (!runSessionAcceptOk(req, res, run, "run.compact"))
1053
1057
  return; // [1499] accept-phase
1054
1058
  // core 1.293:compact(opts) 收 { instructions?, signal? }。instructions=壳 /compact 的定向摘要指令,
@@ -1164,16 +1168,16 @@ async function handleRunsBody(req, res, url, ctx, miss) {
1164
1168
  }
1165
1169
  const run = await deps.runStore.getRun(taskId);
1166
1170
  if (!run) {
1167
- sendError(res, 404, "not_found.run", "run not found");
1171
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
1168
1172
  return;
1169
- }
1173
+ } // #262 同串
1170
1174
  // Owner-gate (parity with steer/compact): an explicit operator may detach on any tenant's run; a normal
1171
1175
  // caller only its own. 404 (not 403) for a non-owner — no existence oracle.
1172
1176
  const operator = explicitOperatorOk(principal, deps.config.operatorPrincipals);
1173
1177
  if (!operator && run.owner !== null && run.owner !== principal) {
1174
- sendError(res, 404, "not_found.run", "run not found");
1178
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE);
1175
1179
  return;
1176
- }
1180
+ } // #262 同串
1177
1181
  if (!runSessionAcceptOk(req, res, run, "run.detach"))
1178
1182
  return; // [1499] accept-phase
1179
1183
  const live = steerableRuns.get(taskId);
@@ -1518,7 +1522,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1518
1522
  // Owner-gate via the parent run row (the child inherits the parent's tenancy — core threads the parent's
1519
1523
  // principal to the child, design/62). 404 (not 403) for a non-owner — no existence oracle.
1520
1524
  if (!run || (!trusted && run.owner !== null && run.owner !== principal)) {
1521
- sendError(res, 404, "not_found.run", "run not found");
1525
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE); // #262 同串
1522
1526
  return;
1523
1527
  }
1524
1528
  if (!runSessionAcceptOk(req, res, run, `subagent.${verb}`))
@@ -1,7 +1,6 @@
1
1
  import { expandTiers, isThinkingLevel } from "@sema-agent/core";
2
2
  import { HttpError } from "../../security.js";
3
3
  import { isModelAllowlisted } from "../../model-select.js";
4
- import { cacheFamilyOfMirror } from "../../budget.js";
5
4
  import { redactSecrets } from "../../trace/redact.js";
6
5
  import { sendJson, sendError, httpErrorCode, msg } from "../send.js";
7
6
  import { gatedPrincipal } from "../principal-gate.js";
@@ -162,24 +161,16 @@ async function handleSideQueryBody(req, res, url, ctx, miss) {
162
161
  signal: ac.signal,
163
162
  };
164
163
  try {
165
- // codex R6:catalog 快照必须取在 brain 调用**之前**(三字段值拷贝,防热刷新原位替换)——请求在
166
- // 版本 A 下发、版本 B 下完账会把 A 产生的 cache 用量按 B 的 family 折算(同 id 换族=配额轴少收)。
167
- const catalogSnapshot = Object.values(deps.runner.agentCatalog?.models ?? {}).map((m) => ({ id: m?.id, api: m?.api, params: m?.params?.promptCacheFamily ? { promptCacheFamily: m.params.promptCacheFamily } : undefined }));
168
164
  const result = await deps.runner.sideQuery(spec);
169
165
  // 记账(codex R2 high→R3 收敛):走与 createTracer 同源装配的四路 sink seam(costQuota+指标+
170
166
  // fleetUsage 批报+fleetLease 本地扣减)——sideQuery 不发 brain.call,tracer 链不经过这里。
171
167
  // usageMissing/无 cost 面=0 记账,诚实缺席。
168
+ // #257:曾经这里在请求前拍 catalog 快照、按 Model 判 cache family 传给记账口(codex R4-R6 三轮
169
+ // 打磨的判定链)——整链随 #257 退役:core 在 side-query 出口已把 `usage.input` 归一为 uncached 量
170
+ // (每个 provider 族,side-query.d.ts 成文),折算恒 input+cacheRead+cacheWrite,family 不再是记账输入。
172
171
  const rr = result;
173
172
  const modelId = typeof rr.model === "string" ? rr.model : "unknown";
174
- // cache family 取自路由身份在 catalog 里的 Model(codex R4:Anthropic prompt tokens 在 input
175
- // 之外——family 决定配额轴折算)。codex R5:result.model=**Model.id**,而 agentCatalog.models
176
- // 按 catalog ref(池名)做键——必须按 id 扫值域,不能按键取(registry 车道 name≠id,键取恒 miss
177
- // →OpenAI 族全走保守臂多收)。同 id 多条且 family 相斥=当 unknown(保守臂);查不到=undefined。
178
- // codex R6:扫的是**请求前快照**(见上),完账口径钉在发起时的模型态。
179
- const byId = catalogSnapshot.filter((m) => m.id === modelId);
180
- const families = new Set(byId.map((m) => cacheFamilyOfMirror(m)));
181
- const family = families.size === 1 ? [...families][0] : undefined;
182
- deps.sideQueryAccounting?.(principal, { model: modelId, ...(family ? { family } : {}), usage: rr.usage });
173
+ deps.sideQueryAccounting?.(principal, { model: modelId, usage: rr.usage });
183
174
  deps.metrics?.inc?.("side_query_total", { result: "ok" });
184
175
  if (!res.writableEnded)
185
176
  sendJson(res, 200, result);
@@ -63,6 +63,13 @@ function sendSubmitResult(res, resp) {
63
63
  }
64
64
  sendJson(res, resp.status, resp.body);
65
65
  }
66
+ /**
67
+ * [3979] §2 发现性指路:`POST /v1/tasks` 收 verify/cascade 不拒、多尝试语义照常执行,但该同步形
68
+ * **没有事件通道**(契约 §4a-sext 成文;#253 的裁定=不是缺座位,是端点形状)。test 黑盒撞到
69
+ * 「200 却零事件回放」证明成文≠可发现,所以带 verify/cascade 的 200 体附本句(不改行为)。
70
+ * 注入点在 runSyncSubmitLeg 的三处 200 return(idem 缓存体自带 ⇒ peek 重放腿同形,不必二处注入)。
71
+ */
72
+ const MULTI_ATTEMPT_NO_EVENT_CHANNEL_NOTICE = "verify / cascade ran with their normal multi-attempt semantics, but the synchronous /v1/tasks endpoint has no event channel — no status/progress frames exist to replay on this shape. For observable events submit via POST /v1/runs and read GET /v1/runs/:id/events.";
66
73
  export async function handleTasks(req, res, url, ctx) {
67
74
  const miss = { fell: false };
68
75
  await handleTasksBody(req, res, url, ctx, miss);
@@ -1267,6 +1274,11 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1267
1274
  // Idempotency-Key dedup (center blocker: exactly-once billing) — concurrent + retried same-key submits
1268
1275
  // share one run; the whole run+record is wrapped so metrics/cost are counted exactly once on a replay.
1269
1276
  // ── B3 ── 同上:原文此处是同步腿的 **128 行匿名 async**,提为具名闭包。
1277
+ // [3979]:带 verify/cascade 的 200 体附事件通道指路;plain 形缺席不铸键(负控钉在 audit-fixes-http)。
1278
+ // 谓词包在**全部三处** 200 return 上而不只终局形:今天 verify/cascade 腿的 park 被 core 映射成
1279
+ // failed(nested suspend)走终局,park 两形只可能 plain 抵达——但那是 core 的映射语义,不是本腿
1280
+ // 可假设的不变式;包全三处让「core 让组合腿真 park」的未来变更不产生一个漏 notice 的形。
1281
+ const withDiscoveryNotice = (body) => prepared.verify || prepared.cascade ? { ...body, notice: MULTI_ATTEMPT_NO_EVENT_CHANNEL_NOTICE } : body;
1270
1282
  const runSyncSubmitLeg = async () => {
1271
1283
  // Durable F4/ask on the sync path — same two things as the stream path (audit functional gap):
1272
1284
  // 1. createRun a task_active claim at SUBMIT (+ heartbeat) so reapCtx's task_active guard protects
@@ -1394,7 +1406,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1394
1406
  // The submit-time row — guaranteed when runStore is present (a lost claim was 409'd before running).
1395
1407
  if (durableTaskId)
1396
1408
  await deps.runStore.setSuspended(durableTaskId);
1397
- return { status: 200, body: { taskId: durableTaskId, sessionId: prepared.spec.sessionId, status: "suspended" } };
1409
+ return { status: 200, body: withDiscoveryNotice({ taskId: durableTaskId, sessionId: prepared.spec.sessionId, status: "suspended" }) };
1398
1410
  }
1399
1411
  // design/80 D-B: a review pause (plan_review / dry_run_review) on the sync path — PARK it like a suspend
1400
1412
  // (keep task_active) + return a pollable taskId. For a plan_review GATE the /v1/assistant/tasks/:id/plan_review
@@ -1403,7 +1415,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1403
1415
  if (result.status === "needs_review" && deps.checkpointStore && deps.runStore && prepared.spec.sessionId) {
1404
1416
  if (durableTaskId)
1405
1417
  await deps.runStore.setNeedsReview(durableTaskId);
1406
- return { status: 200, body: { taskId: durableTaskId, sessionId: prepared.spec.sessionId, status: "needs_review" } };
1418
+ return { status: 200, body: withDiscoveryNotice({ taskId: durableTaskId, sessionId: prepared.spec.sessionId, status: "needs_review" }) };
1407
1419
  }
1408
1420
  // Not suspended → release the submit-time claim so the session unlocks + the ctx becomes reapable.
1409
1421
  if (durableTaskId && deps.runStore)
@@ -1415,7 +1427,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1415
1427
  // A verify/cascade NESTED suspend is mapped by core to status:"failed" while still carrying the
1416
1428
  // capability token (the genuine top-level suspend is parked above and never reaches here). Strip it so
1417
1429
  // the resume credential never goes in the response body OR the idempotency cache.
1418
- return { status: 200, body: stripCheckpointToken(result) };
1430
+ return { status: 200, body: withDiscoveryNotice(stripCheckpointToken(result)) };
1419
1431
  };
1420
1432
  const resp = await idemCache.run(idemKey, runSyncSubmitLeg, (r) => r.status < 400); // cache only success-range outcomes (a completed/failed BILLED result, replay to avoid double-bill); never a session-active 409 or a resume-at 4xx (billed nothing — a retry must re-check)
1421
1433
  sendSubmitResult(res, resp); // 体里有 retryAfterSec ⇒ 标准 retry-after 头同值(见 sendSubmitResult 顶注)
@@ -39,9 +39,10 @@ export interface RequestAuth {
39
39
  principal?: string;
40
40
  sessionId: string;
41
41
  memoryScope?: string;
42
- /** 142-S4:the SHAPE-VALIDATED `body.projectId`(authorizer 的 PROJECT_ID_REGEX 门通过后回传)。
43
- * main.ts 拿它查 config.projects 的 defaultScopes(memory.scopes 种子)。resume 腿(checkpoint 重建
44
- * auth)缺席 — main.ts 回落持久化 body.projectId(原始提交已验形)。 */
42
+ /** 142-S4:the SHAPE-VALIDATED **且已归一(小写)** 的 `body.projectId`(authorizer 的 core
43
+ * `PROJECT_ID_REGEX` 门通过后折小写回传;A-033.1/#256 宽读严写)。main.ts 拿它查 config.projects 的
44
+ * defaultScopes(memory.scopes 种子)。resume 腿(checkpoint 重建 auth)缺席 — 回落持久化的
45
+ * body.projectId(原始提交已验形),那条回落腿自己再折一次小写,两腿查同一个键。 */
45
46
  resolvedProjectId?: string;
46
47
  }
47
48
  /**
@@ -1,7 +1,7 @@
1
1
  import http from "node:http";
2
2
  import { once } from "node:events";
3
3
  import { createHash } from "node:crypto";
4
- import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow, GLOBAL_USAGE_KEY, usageRetryAfterMs } from "@sema-agent/core";
4
+ import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow, GLOBAL_USAGE_KEY, usageRetryAfterMs, RUN_WORKFLOW_TOOL_NAME } from "@sema-agent/core";
5
5
  import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
6
6
  import { isModelAllowlisted, matchCatalogModel } from "../model-select.js";
7
7
  import {} from "../config-center/facade.js";
@@ -33,6 +33,7 @@ import { streamSseLog } from "./sse-log.js";
33
33
  // `clampVerifyRounds` used to be re-exported from here for the tests; that re-export was deleted (8566348) and the
34
34
  // import sat on as a dead name — tests import it from `verify-rounds.js` directly, which is the single owner.
35
35
  import { verifyRoundsFromBody } from "./verify-rounds.js";
36
+ import { RUN_NOT_FOUND_MESSAGE } from "./route-ctx.js";
36
37
  import { handleCapabilities } from "./routes/capabilities.js";
37
38
  import { handleObservability } from "./routes/observability.js";
38
39
  import { handleDiagnostics } from "./routes/diagnostics.js";
@@ -1560,8 +1561,11 @@ export function createHttpServer(rawDeps) {
1560
1561
  // posture as the hand-tools union above (a per-principal allowWorkflows=false revoked between suspend and
1561
1562
  // approve slips this pre-check; core's own resume path then fails loudly — the pre-check is a UX guard,
1562
1563
  // not the authority).
1564
+ // A-033.1(#256):名字从 core 的 `RUN_WORKFLOW_TOOL_NAME` 取,不再手抄 —— 手抄的名字在 core 改名后
1565
+ // 与实挂名分家,这一格会把**每一次**已批准的 workflow 赎回打成 422(pendingTool 是实挂名,集合里
1566
+ // 却是旧名),而 task-settings 那三处同族手抄的后果更重(ask/deny 门整条静默不触发)。
1563
1567
  if (spec.selfOrchestration === true && deps.config.selfOrchestrationEnabled) {
1564
- availableTools.add("Workflow");
1568
+ availableTools.add(RUN_WORKFLOW_TOOL_NAME);
1565
1569
  }
1566
1570
  if (decision === "approve" && pendingToolCanon && !availableTools.has(pendingToolCanon)) {
1567
1571
  return {
@@ -2954,7 +2958,7 @@ export function createHttpServer(rawDeps) {
2954
2958
  // 生命周期消亡后可整删);带具名 principal 的请求对哨兵行仍走下方 404 无 oracle 形。
2955
2959
  const legacyShellOwner = !deps.config.requirePrincipal && owner === "anon:shell-live" && (principal === undefined || principal === null);
2956
2960
  if (!legacyShellOwner && owner !== null && principal !== owner) {
2957
- sendError(res, 404, "not_found.run", "run not found"); // don't reveal existence to non-owners
2961
+ sendError(res, 404, "not_found.run", RUN_NOT_FOUND_MESSAGE); // #262:与 unknown 臂同串——「don't reveal existence」的意图此前被长短文差打穿
2958
2962
  return false;
2959
2963
  }
2960
2964
  return true;
@@ -2973,7 +2977,7 @@ export function createHttpServer(rawDeps) {
2973
2977
  * those owner gates is a deliberate [1498]⑥ decision, not this phase's. A session-unbound run has nothing
2974
2978
  * to assert against. */
2975
2979
  const sessionAssertAbsentWarned = new Set();
2976
- function runSessionAcceptOk(req, res, run, face, notFoundError = "run not found") {
2980
+ function runSessionAcceptOk(req, res, run, face, notFoundError = RUN_NOT_FOUND_MESSAGE) {
2977
2981
  if (!run.sessionId)
2978
2982
  return true;
2979
2983
  const asserted = new URL(req.url ?? "", "http://x").searchParams.get("session");
@@ -297,7 +297,9 @@ export interface TaskRequestBody {
297
297
  /** 142-S4 projectId 线程化(design/142 §2):这个 run 归属的项目(center 登记簿键,generic lowercase
298
298
  * UUID — core S1 `resolveProjectId` marker 或 S3 无仓 mint 的产物;客户端只透传,零铸造权威)。是「哪个
299
299
  * 项目」的选择器,不是身份/capability — scope 的 tenant 段永远来自 verified principal。SHAPE 门在
300
- * authorizer(PROJECT_ID_REGEX,不合形状=422 typed `invalid_project_id`)。多租户 DB 记忆面下它把派生
300
+ * authorizer(core `PROJECT_ID_REGEX`,不合形状=422 typed `invalid_project_id`)。**大小写不敏感收、
301
+ * 归一小写用**(A-033.1/#256:词法与 core 的格式单源对齐,带 /i;收下后折小写再派生 scope/查表 ——
302
+ * 同一个 id 的两种大小写写法落同一只记忆盘)。多租户 DB 记忆面下它把派生
301
303
  * scope 从 user 盘切到项目盘(`proj:<tenant>/<projectId>`)并以 config.projects[projectId].defaultScopes
302
304
  * 作 memory.scopes 种子;单用户部署忽略派生(memoryScopeFor 不变),仅 defaultScopes 种子生效。
303
305
  * Rides the persisted body onto resume legs(与 memoryWrite 同姿势)。 */
@@ -66,6 +66,15 @@ export interface E2bLspManagerOptions {
66
66
  inc(name: string, labels?: Record<string, string>): void;
67
67
  };
68
68
  }
69
+ /**
70
+ * §DESIGN-V2 F1/F8/F9 —— 本文件曾**手抄**一份 core 的 `settleOnAbort`(注释自陈 core「un-exported」,并写下
71
+ * 「一旦 core 导出即 find-and-delete」的计划)。A-033.1(#256)执行了那句话:core 现已从包根导出它
72
+ * (`index.d.ts:27`,来自 `core/lsp.js`),手抄件遂删除,本文件的两处调用直接消费 core 的实现。
73
+ *
74
+ * 它的语义(读点在 `open` 的两处调用):给**这一个**调用方一个自己的 settle —— 调用方的 `signal` 让它拿到
75
+ * `onAbort()` 的返回,而**共享作业本身不受打扰**(别的调用方可能还等着它)。铁律仍在:绝不能把 signal 穿进
76
+ * 作业里(F9:共享的 open/heal 链不接受 per-caller signal,见 `open` 的文档)。
77
+ */
69
78
  export declare class E2bLspManager implements LspServerManager {
70
79
  /** Per-sandbox session cache: env (weak) → language → the (self-contained, see `startJob`) shared job. */
71
80
  private readonly perEnv;
@@ -11,7 +11,7 @@
11
11
  * (§DESIGN-V2 F7: "reason 词表=模型可见面", short + stable — changing a word IS a behavior change).
12
12
  */
13
13
  import path from "node:path";
14
- import { TransportLspSession, LspDiagnosticsRegistry } from "@sema-agent/core";
14
+ import { TransportLspSession, LspDiagnosticsRegistry, settleOnAbort } from "@sema-agent/core";
15
15
  /** Duck-type check: is this ExecutionEnv an E2B adapter with the LSP surface? */
16
16
  export function isLspCapable(env) {
17
17
  const e = env;
@@ -36,32 +36,14 @@ const DEFAULT_EXT_TO_LANG = {
36
36
  ".rs": "rust",
37
37
  };
38
38
  /**
39
- * §DESIGN-V2 F1/F8/F9: literal-aligned with core's (un-exported, `dist/core/lsp.js`) `settleOnAbort` — same
40
- * signature as the PUBLIC `core/lsp.d.ts:47` declaration, so a future dedup (once core exports it) is a
41
- * find-and-delete, not a rewrite. Bounds how long *this one caller* waits for a shared job; it must NEVER be
42
- * threaded into the job itself (F9: the shared open/heal chain accepts no per-caller signal — see `open`'s
43
- * doc). `job` is assumed never to reject (every failure path here resolves to `undefined`); the `.catch` on
44
- * the already-aborted branch is defensive parity with core's version, not a behavior this file relies on.
39
+ * §DESIGN-V2 F1/F8/F9 —— 本文件曾**手抄**一份 core `settleOnAbort`(注释自陈 core「un-exported」,并写下
40
+ * 「一旦 core 导出即 find-and-delete」的计划)。A-033.1(#256)执行了那句话:core 现已从包根导出它
41
+ * (`index.d.ts:27`,来自 `core/lsp.js`),手抄件遂删除,本文件的两处调用直接消费 core 的实现。
42
+ *
43
+ * 它的语义(读点在 `open` 的两处调用):给**这一个**调用方一个自己的 settle —— 调用方的 `signal` 让它拿到
44
+ * `onAbort()` 的返回,而**共享作业本身不受打扰**(别的调用方可能还等着它)。铁律仍在:绝不能把 signal 穿进
45
+ * 作业里(F9:共享的 open/heal 链不接受 per-caller signal,见 `open` 的文档)。
45
46
  */
46
- function settleOnAbort(job, signal, onAbort) {
47
- if (!signal)
48
- return job;
49
- if (signal.aborted) {
50
- void job.catch(() => undefined);
51
- return Promise.resolve(onAbort());
52
- }
53
- return new Promise((resolve, reject) => {
54
- const onAbortEvent = () => resolve(onAbort());
55
- signal.addEventListener("abort", onAbortEvent, { once: true });
56
- job.then((v) => {
57
- signal.removeEventListener("abort", onAbortEvent);
58
- resolve(v);
59
- }, (e) => {
60
- signal.removeEventListener("abort", onAbortEvent);
61
- reject(e);
62
- });
63
- });
64
- }
65
47
  export class E2bLspManager {
66
48
  /** Per-sandbox session cache: env (weak) → language → the (self-contained, see `startJob`) shared job. */
67
49
  perEnv = new WeakMap();
package/dist/main.js CHANGED
@@ -24,6 +24,7 @@ import { createToolTracer, createPermissionDeniedMeter } from "./observability/t
24
24
  import { createRegistryJwtVerifier } from "./auth-bridge.js";
25
25
  import { createMetrics } from "./observability/metrics.js";
26
26
  import { installFailOpenRecorder } from "./observability/fail-open.js";
27
+ import { installSecretEnvScrubRecorder } from "./observability/secret-env-scrub.js";
27
28
  import { setRedactionObserver, redactSecrets } from "./trace/redact.js";
28
29
  import { RateLimiter } from "./observability/rate-limit.js";
29
30
  import { createHttpServer, explicitOperatorOk } from "./http/server.js";
@@ -118,6 +119,9 @@ async function main() {
118
119
  // 排在这里而不是更晚:兜底臂遍布装配链,越早接上,越少的 fail-open 只剩探针行没有结构化痕迹。
119
120
  // 装配前已累计的计数与已喊过的 tag 由 install 继承,不重置也不重喊。
120
121
  installFailOpenRecorder({ logger, metrics });
122
+ // A-033.3(#256):同一处接上密钥形 env 剥除的留痕面(core `scrubSecretEnv` 的 findings 收集器半场)。
123
+ // 同因排在这里:两条剥除腿(host shell 继承 env / project-memory 的 git 子进程)都在没有观测句柄的深处。
124
+ installSecretEnvScrubRecorder({ logger, metrics });
121
125
  // S20 (SILENT-FALLBACK P1) + [792]④ boot notices: config parsing runs before the logger exists — drain both
122
126
  // pre-logger collectors via the shared helper (F10: run-local uses the same one, so entrypoints can't drift).
123
127
  logConfigDiagnostics(logger);
@@ -77,6 +77,10 @@ export declare const FAIL_OPEN_TAGS: {
77
77
  readonly cls: "F";
78
78
  readonly note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。";
79
79
  };
80
+ readonly "server.otel.export-failed": {
81
+ readonly cls: "F";
82
+ readonly note: "#193 车5(staleness-sweep P1-3):OTLP 周期导出失败(HTTP 非 2xx 或 fetch 拒绝)⇒ 丢弃本轮快照继续服务。导出本就 best-effort(collector 打嗝绝不影响 serving,方向不改),但此前唯一留痕是装配层 onError 的一条 warn——**metrics 轴零信号**,而 USAGE 力荐的 Prometheus-only 部署恰好只看 metrics ⇒ collector 持续宕机对运维完全不可见(遥测腿自盲)。放行的最坏后果 = 一段时间的指标断供;计数让「断供正在发生」本身成为一条指标。";
83
+ };
80
84
  };
81
85
  /** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
82
86
  export type FailOpenTag = keyof typeof FAIL_OPEN_TAGS;
@@ -100,6 +100,10 @@ export const FAIL_OPEN_TAGS = {
100
100
  cls: "F",
101
101
  note: "#213 陈旧 claim 接管:claim 已 rename 进 tmp/ 隔离名(接管本体已完成、正确性不受影响),但隔离件 rmSync 失败留在 tmp。放行的最坏后果 = tmp 里多一个死文件(不在 activeDir,hydrate 永不把它当 claim 复活);故 F 类。必须留痕:反复删不掉 = tmp 权限/fs 病灶,静默吞掉会让 tmp 无声膨胀。",
102
102
  },
103
+ "server.otel.export-failed": {
104
+ cls: "F",
105
+ note: "#193 车5(staleness-sweep P1-3):OTLP 周期导出失败(HTTP 非 2xx 或 fetch 拒绝)⇒ 丢弃本轮快照继续服务。导出本就 best-effort(collector 打嗝绝不影响 serving,方向不改),但此前唯一留痕是装配层 onError 的一条 warn——**metrics 轴零信号**,而 USAGE 力荐的 Prometheus-only 部署恰好只看 metrics ⇒ collector 持续宕机对运维完全不可见(遥测腿自盲)。放行的最坏后果 = 一段时间的指标断供;计数让「断供正在发生」本身成为一条指标。",
106
+ },
103
107
  };
104
108
  /**
105
109
  * 断流丢帧该记哪个 tag —— **按帧型分类**,纯函数(与写流的那条闭包解耦,才单测得动)。
@@ -1,3 +1,4 @@
1
+ import { recordFailOpen } from "./fail-open.js";
1
2
  const CUMULATIVE = 2; // OTel AggregationTemporality.CUMULATIVE
2
3
  /** uint64 nanos as a string (OTLP/JSON encodes 64-bit ints as strings; ms×1e6 overflows Number). */
3
4
  const nano = (ms) => (BigInt(ms) * 1000000n).toString();
@@ -82,10 +83,15 @@ export function startOtlpExporter(metrics, opts) {
82
83
  body: JSON.stringify(payload),
83
84
  signal: AbortSignal.timeout(Math.max(1_000, opts.intervalMs)),
84
85
  });
85
- if (!res.ok)
86
+ // #193 车5(P1-3):两条失败臂都要过 fail-open 计数——onError 只到日志轴,而 Prometheus-only
87
+ // 部署只看 metrics,此前 collector 持续宕机在 metrics 轴零信号(遥测腿自盲)。
88
+ if (!res.ok) {
89
+ recordFailOpen("server.otel.export-failed", `http_${res.status}`);
86
90
  opts.onError?.(new Error(`OTLP export HTTP ${res.status}`));
91
+ }
87
92
  }
88
93
  catch (err) {
94
+ recordFailOpen("server.otel.export-failed", String(err));
89
95
  opts.onError?.(err); // export is best-effort — a collector hiccup must never affect serving
90
96
  }
91
97
  finally {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A-033.3(#256)—— **密钥形 env 剥除的留痕面**(core D1 `scrubSecretEnv` 的可观测半场)。
3
+ *
4
+ * ## 它解决的问题
5
+ * `scrubSecretEnv` 是本仓两条 shell/子进程腿的**安全默认**:模型可驱动的 shell(`inheritEnv:"scrub"`)与
6
+ * git 子进程(钩子/凭证助手)拿到的 env 里,`*_KEY`/`*_TOKEN`/`*_SECRET`/`*_PASSWORD`… 一律先丢掉。
7
+ * 两处都以**单参形**调用,于是「这一次到底丢了哪些 key、按哪条规则(suffix-rule / exact-name)、置信度多少」
8
+ * **零留痕** —— 部署方既证明不了保护生效过,也发现不了它悄悄不生效了(词表漂移 / 一个新的密钥命名法从
9
+ * 规则的缝里穿过去 / 某条腿被改成 `inheritEnv:"all"`)。core 早就把收集口开好了(`findings?:
10
+ * SecretEnvFinding[]`,d.ts 原话:the VALUE is never carried — this record is meant to be loggable),
11
+ * 缺的只是本仓这半场。本模块就是那半场,**不是**一个新的执法面:它一个字节也不改变剥除行为。
12
+ *
13
+ * ## 形(照 `fail-open.ts` 的仓内先例)
14
+ * 1. **计数(逐次)** —— `secret_env_scrubbed_total{site,kind,confidence}`;
15
+ * 2. **结构化披露(每 site×key 一次)** —— 首次在某条腿上丢掉某个 key 时一条 `info`,之后同 key 只走计数。
16
+ * 不是「每 site 一次」:一个**新**的密钥形 key 后来才出现(部署加了一个凭证)时,那才是最该看见的一行。
17
+ * 值永不进任何一件(core 的 finding 本身就不携带值)。
18
+ *
19
+ * ## 为什么是进程级自由函数而不是注入
20
+ * 与 `recordFailOpen` 同因:两个调用点都在**没有 logger/metrics 句柄**的深处
21
+ * (`plugins/remote-env-host.ts` 的 `mergeEnv` 全文零观测通道;`project-memory.ts` 的 `git()` 是个自由函数)。
22
+ * 装配层用 {@link installSecretEnvScrubRecorder} 把句柄接上,装配之前命中的次数不会丢(计数照常累加,
23
+ * install 时一次性补进 metrics),披露行则在下一次命中时补发(未成功披露的 key 不进 `disclosed` 集)。
24
+ */
25
+ import { type SecretEnvFinding } from "@sema-agent/core";
26
+ import type { Logger } from "./logger.js";
27
+ import type { Metrics } from "./metrics.js";
28
+ /** 登记过的剥除现场。闭集:未登记的 site 传不进 {@link recordSecretEnvScrub}(编译期拒,同 FAIL_OPEN_TAGS 纪律)。 */
29
+ export declare const SECRET_ENV_SCRUB_SITES: {
30
+ readonly "server.remote-env-host.inherit-scrub": "模型可驱动的 host shell 继承 env(`inheritEnv:\"scrub\"` 默认腿)——这条腿上的一次泄漏等于把编排器的脑钥/仓库令牌交给模型选的命令";
31
+ readonly "server.project-memory.git-subprocess": "project-memory 读仓叙事时的 git 子进程 env —— 仓内的钩子/凭证助手会以本进程身份运行";
32
+ };
33
+ /** 词表键推导的闭集类型。 */
34
+ export type SecretEnvScrubSite = keyof typeof SECRET_ENV_SCRUB_SITES;
35
+ interface Sink {
36
+ logger?: Logger;
37
+ metrics?: Metrics;
38
+ }
39
+ /**
40
+ * 记一次剥除。**总不抛** —— 观测本身绝不能变成故障(同 `recordFailOpen` / reapers 的判据)。
41
+ * `findings` 为空(这一次没丢任何 key)时什么也不做:那不是事件,是常态。
42
+ */
43
+ export declare function recordSecretEnvScrub(site: SecretEnvScrubSite, findings: readonly SecretEnvFinding[]): void;
44
+ /** 装配层把 logger/metrics 接上(main.ts,与 `installFailOpenRecorder` 同一处)。装配前累计的次数在此补进 metrics。 */
45
+ export declare function installSecretEnvScrubRecorder(deps: Sink): void;
46
+ /** 已留驻的「现场×键」条数(测试/诊断读面)。恒 ≤ {@link DISCLOSED_CAP}。 */
47
+ export declare function secretEnvScrubDisclosedSize(): number;
48
+ /** 本进程各桶的累计次数(测试/诊断读面)。 */
49
+ export declare function secretEnvScrubCounts(): ReadonlyMap<string, number>;
50
+ /** 测试用重置(进程级状态在同一个 vitest worker 里跨用例存活)。 */
51
+ export declare function resetSecretEnvScrubRecorderForTest(deps?: Sink): void;
52
+ export {};
53
+ //# sourceMappingURL=secret-env-scrub.d.ts.map