@sema-agent/server 7.76.1 → 7.76.2

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 (42) hide show
  1. package/USAGE.md +47 -21
  2. package/dist/boot/engine-lease.d.ts +89 -11
  3. package/dist/boot/engine-lease.js +26 -11
  4. package/dist/boot/exit-last.d.ts +24 -0
  5. package/dist/boot/exit-last.js +46 -0
  6. package/dist/boot/shutdown.js +28 -4
  7. package/dist/config-catalog.js +3 -2
  8. package/dist/config-types.d.ts +23 -11
  9. package/dist/config.js +8 -8
  10. package/dist/http/principal-gate.d.ts +24 -1
  11. package/dist/http/principal-gate.js +16 -1
  12. package/dist/http/routes/a2a-serve.js +2 -2
  13. package/dist/http/routes/admin-config-refresh.js +2 -2
  14. package/dist/http/routes/admin-drain.js +2 -2
  15. package/dist/http/routes/admin-memory-optout.js +2 -2
  16. package/dist/http/routes/adoption.js +2 -2
  17. package/dist/http/routes/approvals-assistant.js +4 -4
  18. package/dist/http/routes/attachments.js +2 -2
  19. package/dist/http/routes/config-catalog.js +2 -2
  20. package/dist/http/routes/devices.js +2 -2
  21. package/dist/http/routes/fleet.js +2 -2
  22. package/dist/http/routes/leader.js +2 -2
  23. package/dist/http/routes/memory-bundle.js +2 -2
  24. package/dist/http/routes/memory-compliance.js +2 -2
  25. package/dist/http/routes/memory-consolidation.js +2 -2
  26. package/dist/http/routes/memory-origin.js +2 -2
  27. package/dist/http/routes/memory-policy.js +2 -2
  28. package/dist/http/routes/notify-wake.js +3 -3
  29. package/dist/http/routes/observability.js +2 -2
  30. package/dist/http/routes/retention-ops.js +2 -2
  31. package/dist/http/routes/run-memory-capture.js +2 -2
  32. package/dist/http/routes/runs.js +13 -13
  33. package/dist/http/routes/sessions.js +7 -7
  34. package/dist/http/routes/side-query.js +2 -2
  35. package/dist/http/routes/workflows.js +3 -3
  36. package/dist/http/server.js +2 -2
  37. package/dist/parent-watch.d.ts +32 -18
  38. package/dist/parent-watch.js +12 -10
  39. package/dist/tool-approval.js +2 -2
  40. package/dist/trace/redact.d.ts +23 -1
  41. package/dist/trace/redact.js +45 -11
  42. package/package.json +1 -1
@@ -5,7 +5,7 @@ import { windowMessages, truncateMessageBlobs } from "../../audit.js";
5
5
  import { redactSecrets } from "../../trace/redact.js";
6
6
  import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, MAX_SETTINGS_PERMISSION_RULES, MAX_SETTINGS_ENV_VARS, MAX_SETTINGS_ENV_KEY_CHARS, MAX_SETTINGS_ENV_VALUE_CHARS } from "../../task-settings.js";
7
7
  import { sendJson, sendError, sseHeaders } from "../send.js";
8
- import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
8
+ import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
9
9
  const MCP_STATUS_TTL_MS = 5_000;
10
10
  const MCP_MATERIALIZE_TIMEOUT_MS = 10_000;
11
11
  const A2A_STATUS_TTL_MS = 5_000;
@@ -36,7 +36,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
36
36
  if (im && req.method === "GET") {
37
37
  const principal = gatedPrincipal(req, deps.config);
38
38
  if (deps.config.requirePrincipal && !principal) {
39
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
39
+ sendPrincipalRefusal(res, req, deps.config);
40
40
  return;
41
41
  }
42
42
  sendJson(res, 200, {
@@ -52,7 +52,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
52
52
  if (stm && req.method === "GET") {
53
53
  const principal = gatedPrincipal(req, deps.config);
54
54
  if (deps.config.requirePrincipal && !principal) {
55
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
55
+ sendPrincipalRefusal(res, req, deps.config);
56
56
  return;
57
57
  }
58
58
  sendJson(res, 200, {
@@ -72,7 +72,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
72
72
  const mm = /^\/v1\/sessions\/([^/]+)\/mcp$/.exec(url);
73
73
  if (mm && req.method === "GET") {
74
74
  if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
75
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
75
+ sendPrincipalRefusal(res, req, deps.config);
76
76
  return;
77
77
  }
78
78
  const specs = mcpForScenario(deps.config.mcpServers, deps.config.defaultScenario);
@@ -122,7 +122,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
122
122
  const am = /^\/v1\/sessions\/([^/]+)\/a2a$/.exec(url);
123
123
  if (am && req.method === "GET") {
124
124
  if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
125
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
125
+ sendPrincipalRefusal(res, req, deps.config);
126
126
  return;
127
127
  }
128
128
  const specs = a2aForScenario(deps.config.a2aPeers, deps.config.defaultScenario);
@@ -420,7 +420,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
420
420
  return;
421
421
  }
422
422
  if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
423
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
423
+ sendPrincipalRefusal(res, req, deps.config);
424
424
  return;
425
425
  }
426
426
  if (!deps.sessionAudit) {
@@ -480,7 +480,7 @@ async function handleSessionsBody(req, res, url, ctx, miss) {
480
480
  return;
481
481
  }
482
482
  if (deps.config.requirePrincipal && !gatedPrincipal(req, deps.config)) {
483
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
483
+ sendPrincipalRefusal(res, req, deps.config);
484
484
  return;
485
485
  }
486
486
  if (!deps.sessionAudit) {
@@ -3,7 +3,7 @@ import { HttpError } from "../../security.js";
3
3
  import { isModelAllowlisted } from "../../model-select.js";
4
4
  import { redactSecrets } from "../../trace/redact.js";
5
5
  import { sendJson, sendError, httpErrorCode, msg } from "../send.js";
6
- import { gatedPrincipal } from "../principal-gate.js";
6
+ import { gatedPrincipal, sendPrincipalRefusal } from "../principal-gate.js";
7
7
  const CORE_PREFLIGHT_VALIDATION_PREFIXES = ["Unknown model ref ", "No model for role "];
8
8
  export function isCorePreflightValidationError(message) {
9
9
  return CORE_PREFLIGHT_VALIDATION_PREFIXES.some((prefix) => message.startsWith(prefix));
@@ -43,7 +43,7 @@ async function handleSideQueryBody(req, res, url, ctx, miss) {
43
43
  return;
44
44
  const principal = gatedPrincipal(req, deps.config);
45
45
  if (deps.config.requirePrincipal && !principal) {
46
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
46
+ sendPrincipalRefusal(res, req, deps.config);
47
47
  return;
48
48
  }
49
49
  let body;
@@ -9,7 +9,7 @@ import { redactErrorMessage, redactHead } from "../../observability/run-terminal
9
9
  const JOURNAL_PROJECTION_MEMO_CAP = 1024;
10
10
  const journalProjectionMemo = new Map();
11
11
  import { sendJson, sendError, sseHeaders } from "../send.js";
12
- import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
12
+ import { gatedPrincipal, explicitOperatorOk, sendPrincipalRefusal } from "../principal-gate.js";
13
13
  const WORKFLOWS_RE = /^\/v1\/workflows(?:\/([^/]+)(\/stream|\/journal)?)?$/;
14
14
  const WORKFLOW_AGENT_STEER_RE = /^\/v1\/workflows\/([^/]+)\/agents\/([^/]+)\/steer$/;
15
15
  export async function handleWorkflows(req, res, url, ctx) {
@@ -29,7 +29,7 @@ async function handleWorkflowsReadBody(req, res, url, ctx, miss) {
29
29
  if (wfMatch) {
30
30
  const principal = gatedPrincipal(req, deps.config);
31
31
  if (deps.config.requirePrincipal && !principal) {
32
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
32
+ sendPrincipalRefusal(res, req, deps.config);
33
33
  return;
34
34
  }
35
35
  if (!deps.workflowRunStore) {
@@ -163,7 +163,7 @@ async function handleWorkflowAgentSteerBody(req, res, url, ctx, miss) {
163
163
  }
164
164
  const principal = gatedPrincipal(req, deps.config);
165
165
  if (deps.config.requirePrincipal && principal === undefined) {
166
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
166
+ sendPrincipalRefusal(res, req, deps.config);
167
167
  return;
168
168
  }
169
169
  const wfRunId = safeDecode(wfSteerMatch[1]);
@@ -85,7 +85,7 @@ export { cascadeConfig };
85
85
  import { handleImages, createImagesLocal, coarseStatusForState, errorCodeForExit } from "./routes/images.js";
86
86
  export { coarseStatusForState, errorCodeForExit };
87
87
  import { sendJson, sendError, httpErrorCode, msg, isSseResponse } from "./send.js";
88
- import { authorized, systemFor, gatedPrincipal, explicitOperatorOk, isOperator } from "./principal-gate.js";
88
+ import { authorized, systemFor, gatedPrincipal, explicitOperatorOk, isOperator, sendPrincipalRefusal } from "./principal-gate.js";
89
89
  import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
90
90
  import { ASK_USER_QUESTION_TOOL_NAME } from "../approval-content-kind.js";
91
91
  export { explicitOperatorOk, isOperator };
@@ -2331,7 +2331,7 @@ export function createHttpServer(rawDeps) {
2331
2331
  function runOwnerOk(req, res, owner, notFoundForm = "run") {
2332
2332
  const principal = gatedPrincipal(req, deps.config);
2333
2333
  if (deps.config.requirePrincipal && !principal) {
2334
- sendError(res, 401, "auth.principal_required", `missing principal header '${deps.config.principalHeader}'`);
2334
+ sendPrincipalRefusal(res, req, deps.config);
2335
2335
  return false;
2336
2336
  }
2337
2337
  const legacyShellOwner = !deps.config.requirePrincipal && owner === "anon:shell-live" && (principal === undefined || principal === null);
@@ -9,23 +9,31 @@
9
9
  * (core `FileStorageBackend` 的 pidfile,失效判定是 `process.kill(pid,0)` ⇒ **活孤儿是合法持锁人**),
10
10
  * 于是此后每一只新壳起自己的引擎都撞锁 fatal「another instance (pid N) owns this data dir」—— 用户看到
11
11
  * 的是「壳崩过一次以后就再也起不来了」。持锁 pid 不存在时 core 已自动剪陈旧锁,缺的只有「父死、子活」
12
- * 这一形。所以引擎给自己装一只**自查**腿:定期问一句"生我的那个还在吗",不在就**自己**走正常停机。
12
+ * 这一形。所以引擎给自己装一只**自查**腿:定期问一句"生我的那个还在吗"。
13
+ *
14
+ * ## 本腿**不**做什么(S-306,硬 breaking)
15
+ *
16
+ * 它**不判死、不 drain、不退进程**。父没了只说明这台引擎成了**孤儿**,而「孤儿该不该退」是**租约**
17
+ * 的问题:还有没有人附着、有没有在飞的 turn、静默够不够久 —— 那是 `boot/engine-lease.ts` 那一条腿,
18
+ * 孤儿态只是把它的窗从 `ENGINE_LINGER_MS` 换成 `ENGINE_ORPHAN_LINGER_MS`(默认 2s)。
19
+ * 旧出口(父死**即** drain,单拍、零附着感知)在「A 起引擎、B 按 engine.port 复用、A 正常退出」这一形
20
+ * 下会在 B 的 turn 中间把引擎拆掉 —— 自退律从此**只有一条**,本腿退化成它的一个输入。
13
21
  *
14
22
  * ## 机制(逐条都是契约)
15
23
  *
16
24
  * 1. **opt-in**:`SEMA_PARENT_PID` 缺席 ⇒ 整件不装配(`boot/shutdown.ts` 连 `createParentWatch` 都不调)。
17
25
  * 存量部署逐字零变化 —— systemd / launchd / 容器托管的引擎本来就 reparent 到 1,自退是事故。
18
26
  * 坏值(非正整数 / 超 int32)⇒ **启动期拒**,在 `config.ts` 的 `parentPidEnv()`。
19
- * 2. **判死 = 两支合取口一句,任一成立即判死**,每 {@link PARENT_WATCH_INTERVAL_MS} 一拍:
27
+ * 2. **判据 = 两支合取口一句,任一成立即标孤儿**,每 {@link PARENT_WATCH_INTERVAL_MS} 一拍:
20
28
  *
21
29
  * · **强身份支 `reparented`**:`process.ppid` 不再是装配那一刻的那个值。生我的进程没了,内核就把
22
30
  * 我过继给 init(或容器里的 subreaper)—— 这是**内核对亲子关系的直答**,不依赖 pid 的身份。
23
31
  * 判据刻意**不写成 `ppid === 1`**:① 收养者未必是 1(PID 1 是 subreaper 的容器里不是);
24
32
  * ② 壳本身就是 pid 1 的容器形里(`SEMA_PARENT_PID=1`),`=== 1` 会在**开机第一拍**把一台健康
25
- * 引擎判死。「不再是生我的那个」两种形都答得对,而且不需要任何特判。
33
+ * 引擎标成孤儿。「不再是生我的那个」两种形都答得对,而且不需要任何特判。
26
34
  * 🔴 **前提**(codex 复审 R1 [high],验真后修):这一支只有在「被监视的 pid 就是生我的那个」时
27
35
  * 才等价于「它没了」。壳经一层 wrapper spawn 引擎却仍传**自己**的 pid 时,引擎的直接父是
28
- * wrapper —— wrapper 先退会让这一支把一台**壳还活着**的健康引擎连同 in-flight run 一起关掉。
36
+ * wrapper —— wrapper 先退会让这一支把一台**壳还活着**的健康引擎标成孤儿(于是它的租约窗被无故收短)。
29
37
  * 前提在**装配那一刻**就能一次判完(`ppidAtArm === pid`),所以它是**装配期的开关**而不是每拍
30
38
  * 的特判:不成立 ⇒ 强身份支整条不装,只留弱身份支(它在那一形下语义仍然正确),并且 armed 那行
31
39
  * 改打 **warn** —— 覆盖面被降级了不许静默。
@@ -37,16 +45,16 @@
37
45
  *
38
46
  * 强身份支**先判**:它成立时这一拍连探活都不发 —— 弱判据没有资格给一个已经被过继走的进程改口
39
47
  * (僵尸父那一形里 `kill(pid,0)` 恰恰恒成功)。
40
- * 3. **单拍即判,不去抖**。[ref] 定稿是「15s 一拍、连续两拍才判死」(上界 30s),S-292 整条退役:
41
- * 部署面的判据是「壳挂了 ≤5s 内数据根重新可用」,而 `ESRCH` / `ppid` 都不是会抖的读数(两者都是
42
- * 内核的直答,不是采样值)。去抖买不到确定性,只买到 15s 的延迟和一个 30s 长的 pid-复用窗口。
43
- * 4. **判死后走 `drain`**,即 `boot/shutdown.ts` 里 SIGTERM 用的那一条 `drainThenShutdown` —— in-flight
44
- * run 的结算 / park 语义与人工停机**逐字一致**,而且收尾链里的 `backend.close()` 才是把 `LOCK`
45
- * 真正还回去的那一步。本模块自己**从不** `process.exit`。已有停机属主(`isStopped`)时站下不重入。
48
+ * 3. **单拍即判,不去抖**。[ref] 定稿是「15s 一拍、连续两拍才判定」(上界 30s),S-292 整条退役:
49
+ * 部署面的判据是「壳挂了以后数据根要在秒级重新可用」,而 `ESRCH` / `ppid` 都不是会抖的读数(两者
50
+ * 都是内核的直答,不是采样值)。去抖买不到确定性,只买到 15s 的延迟和一个 30s 长的 pid-复用窗口。
51
+ * 4. **命中一次即停探**(父不会复活),出口是 {@link ParentWatchDeps.onOrphaned} —— 装配层据此记下
52
+ * 「孤儿了 + 是哪一支判的」,租约腿下一拍读到它就换窗。本模块自己**从不** drain、**从不**
53
+ * `process.exit`。已有停机属主(`isStopped`)时站下不重入(一次正在进行的优雅停机不必再被标孤儿)。
46
54
  * 5. **interval 必须 unref**:监视腿不得成为"进程本该退出却退不掉"的理由(与 drain tick 刻意不 unref
47
55
  * 的取向相反 —— 那一只 tick 就是停机驱动,这一只只是观察者)。
48
- * 6. **日志分档**([ref] 响亮律):装配 = `info`;判死与探活自坏 = `warn`(非计划内的停机、以及"这条腿
49
- * 此刻是失效的",都不许混在 info 流里)。档位是**闭集** {@link ParentWatchLogLevel},装配层用
56
+ * 6. **日志分档**([ref] 响亮律):装配 = `info`;标孤儿与探活自坏 = `warn`(非计划内的生命周期事件、
57
+ * 以及"这条腿此刻是失效的",都不许混在 info 流里)。档位是**闭集** {@link ParentWatchLogLevel},装配层用
50
58
  * `Record<ParentWatchLogLevel, …>` 派发 ⇒ 新增一档是编译错,不会静默落进 info。
51
59
  *
52
60
  * ## 已知残余(成文,现版不收窄)
@@ -64,8 +72,9 @@
64
72
  export declare const PARENT_WATCH_INTERVAL_MS = 2000;
65
73
  /** 日志档位闭集。装配层按 `Record<ParentWatchLogLevel, …>` 派发 —— 新增一档必须在那里显式安置(编译错)。 */
66
74
  export type ParentWatchLogLevel = "info" | "warn";
67
- /** 判死缘由闭集(上 `parent_watch_exit` 的 `reason` 键,运维按它区分两支)。 */
68
- export type ParentDeathReason = "reparented" | "esrch";
75
+ /** 成孤儿的缘由闭集(上 `parent_watch_orphaned` 的 `reason` 键,运维按它区分两支;也是租约自退留痕
76
+ * `exit-last.json` 与 `engine_lease_auto_exit` 事件里 `reason` 的孤儿那两个词)。 */
77
+ export type ParentOrphanReason = "reparented" | "esrch";
69
78
  export interface ParentWatchDeps {
70
79
  /** 被监视的父 pid(`config.parentPid`,已在 config 层校验为正整数)。 */
71
80
  pid: number;
@@ -75,11 +84,16 @@ export interface ParentWatchDeps {
75
84
  /** 亲子关系 seam:缺省 = `process.ppid`(**活读**,不是 boot 时的快照 —— 实测过继后当场变)。
76
85
  * 存在的理由与 `probe` 相同:reparent 在进程内测试里没有确定性的触发法。 */
77
86
  readPpid?: () => number;
78
- /** 已有停机属主(`closing || draining`)时监视腿站下:一次正在进行的优雅 drain 不该被"父也没了"
79
- * 升级成第二信号硬停(那会斩掉 in-flight leg —— 与本件"和人工停机逐字一致"的取向相反)。 */
87
+ /** 已有停机属主(`closing || draining`)时监视腿站下:停机已经在走了,再标一次孤儿改变不了任何事,
88
+ * 只会给一次计划内的停机添一条误导的 warn。 */
80
89
  isStopped: () => boolean;
81
- /** 判死出口 = SIGTERM 那条 graceful drain。 */
82
- drain: () => void;
90
+ /**
91
+ * **唯一出口**(S-306):父不在了 ⇒ 把这台引擎标成孤儿。**至多调用一次**(命中即停探)。
92
+ *
93
+ * 🔴 它**不是** drain:退不退由 `boot/engine-lease.ts` 的租约律按「无附着 ∧ 无在飞 ∧ 静默满
94
+ * `orphanLingerMs`」判。装配层拿到 reason 之后要做的只有一件事 —— 记下孤儿态(时刻 + 缘由)。
95
+ */
96
+ onOrphaned: (reason: ParentOrphanReason) => void;
83
97
  log: (level: ParentWatchLogLevel, event: string, fields: Record<string, unknown>) => void;
84
98
  }
85
99
  /** 装配并**立刻**起监视(装配即 `parent_watch_armed`)。返回停机链用的 `stop()`。 */
@@ -12,11 +12,11 @@ export function createParentWatch(deps) {
12
12
  directParent,
13
13
  intervalMs: PARENT_WATCH_INTERVAL_MS,
14
14
  note: directParent
15
- ? "engine watches this pid; it enters the same graceful drain as SIGTERM as soon as EITHER the engine is " +
16
- "reparented away from it OR process.kill(pid,0) reports ESRCH (EPERM = alive). " +
17
- "Unset SEMA_PARENT_PID to disable."
15
+ ? "engine watches this pid; it marks ITSELF an orphan as soon as EITHER the engine is reparented away from " +
16
+ "it OR process.kill(pid,0) reports ESRCH (EPERM = alive). Being orphaned does NOT shut the engine down — " +
17
+ "it only shortens the attachment-lease window to ENGINE_ORPHAN_LINGER_MS. Unset SEMA_PARENT_PID to disable."
18
18
  : "SEMA_PARENT_PID is NOT this engine's direct parent (ppid differs at arm time) — a wrapper process sits " +
19
- "in between. The reparent arm is therefore NOT armed (it would stop a healthy engine when the wrapper " +
19
+ "in between. The reparent arm is therefore NOT armed (it would orphan a healthy engine when the wrapper " +
20
20
  "exits while the watched process is still alive); only the process.kill(pid,0)/ESRCH arm is active, so a " +
21
21
  "watched parent that exits WITHOUT being reaped (zombie) will not be detected. Spawn the engine directly " +
22
22
  "from the process whose pid you pass.",
@@ -30,7 +30,7 @@ export function createParentWatch(deps) {
30
30
  }
31
31
  const ppid = readPpid();
32
32
  if (directParent && ppid !== ppidAtArm) {
33
- fire("reparented", { ppidAtArm, ppid });
33
+ markOrphaned("reparented", { ppidAtArm, ppid });
34
34
  return;
35
35
  }
36
36
  try {
@@ -39,7 +39,7 @@ export function createParentWatch(deps) {
39
39
  catch (err) {
40
40
  const code = err?.code;
41
41
  if (code === "ESRCH") {
42
- fire("esrch", { ppid });
42
+ markOrphaned("esrch", { ppid });
43
43
  return;
44
44
  }
45
45
  if (code !== "EPERM" && !probeErrorReported) {
@@ -56,15 +56,17 @@ export function createParentWatch(deps) {
56
56
  }
57
57
  }, PARENT_WATCH_INTERVAL_MS);
58
58
  timer.unref?.();
59
- function fire(reason, fields) {
59
+ function markOrphaned(reason, fields) {
60
60
  stop();
61
- deps.log("warn", "parent_watch_exit", {
61
+ deps.log("warn", "parent_watch_orphaned", {
62
62
  pid: deps.pid,
63
63
  reason,
64
64
  ...fields,
65
- note: "the parent that spawned this engine is gone — entering the SIGTERM graceful drain path",
65
+ note: "the parent that spawned this engine is gone — this engine is now an ORPHAN. It does NOT shut down here: " +
66
+ "it stays up while anything is attached or in flight, and self-exits via the SIGTERM graceful drain once " +
67
+ "the attachment lease has been idle for ENGINE_ORPHAN_LINGER_MS (see engine_lease_auto_exit).",
66
68
  });
67
- deps.drain();
69
+ deps.onOrphaned(reason);
68
70
  }
69
71
  function stop() {
70
72
  if (stopped)
@@ -2,7 +2,7 @@ import { contentKindOf } from "./approval-content-kind.js";
2
2
  import { AsyncLocalStorage } from "node:async_hooks";
3
3
  import { uuidv7, MAX_RULE_TEXT_CHARS, parseRuleText, pathConfinableWriteToolNames } from "@sema-agent/core";
4
4
  import { precheckCardRuleText } from "./rules-consent.js";
5
- import { normalizeForRedaction, redactDeep, redactSecrets } from "./trace/redact.js";
5
+ import { redactDeep, redactSecrets, redactSecretsWithArms } from "./trace/redact.js";
6
6
  import { hasBidiControls } from "./text-bidi.js";
7
7
  import { approvalPayloadFingerprint } from "./plugins/approval-payload-fingerprint.js";
8
8
  import { createLogger } from "./observability/logger.js";
@@ -32,7 +32,7 @@ const DEFAULT_PARK_TOMBSTONE_TTL_MS = 60 * 60_000;
32
32
  const DEFAULT_PARK_TOMBSTONE_PER_PRINCIPAL_MAX = 128;
33
33
  const PARKING_RETRY_AFTER_MS = 250;
34
34
  function ruleMaterialCarriesCredentialShape(command) {
35
- return redactSecrets(command, { observe: false }) !== normalizeForRedaction(command);
35
+ return redactSecretsWithArms(command, { observe: false }).arms.has("full");
36
36
  }
37
37
  function durableRuleMaterial(material) {
38
38
  if (material === undefined)
@@ -69,7 +69,9 @@ export declare function resetPpkScanBytesForTest(): void;
69
69
  * core [ref] 落地后这一支**趋零是证据、不归零不是证明**。别把它当截断事实的度量用。 */
70
70
  export type RedactionArm = "full" | "fragment";
71
71
  export interface RedactionHitMeta {
72
- /** `"fragment"` = {@link FRAGMENT_SECRET_PATTERNS} 里的前缀残片臂;`"full"` = 完整臂。闭集两词。 */
72
+ /** 命中臂的**判别力档位**(闭集两词,S-303):`"fragment"` = {@link FRAGMENT_SECRET_PATTERNS} 那一族
73
+ * 「短前缀 + 长度/熵阈值」的弱证据臂;`"full"` = 其余全部(结构锚 / 词法关键字 / 定长裸形)。
74
+ * 判据 = 表归属,不是 token 的 `:partial` 后缀 —— 两条轴的分家逐字见 {@link armOfToken}。 */
73
75
  arm: RedactionArm;
74
76
  }
75
77
  export declare function setRedactionObserver(fn: ((patternLabel: string, count: number, meta: RedactionHitMeta) => void) | undefined): void;
@@ -77,6 +79,26 @@ export declare function redactSecrets(text: string, opts?: {
77
79
  observe?: boolean;
78
80
  json?: boolean;
79
81
  }): string;
82
+ /**
83
+ * 脱敏 + **这一遍命中了哪些档位的臂**({@link RedactionArm} 闭集两词)—— 与 {@link redactSecrets} 同一只
84
+ * 脱敏器、同一张表、同一遍扫描的另一个出口。
85
+ *
86
+ * 🔴 **为什么要这个出口**(S-303):下游有一类消费方问的不是「脱完长什么样」而是「这段文本里有没有
87
+ * **强证据**的凭据形」——`tool-approval.ts` 的规则素材闸是头一个。旧形只能拿「字节变了没有」当谓词,
88
+ * 于是弱证据臂(短前缀 + 阈值启发式)的一次多脱就等于一次「素材整只扣下」,用户拿不到常驻放行。
89
+ * 出口开在脱敏器**自己**身上是硬约束:全仓只该有一个「什么算凭据」的属主,消费方**不许**按 token 字面
90
+ * (`includes(":partial»")`)或另跑一遍正则去反推 —— 那就是第二只脱敏器。
91
+ *
92
+ * `arms` 是**这一遍真命中的档位集合**:空集 = 一条臂都没命中(文本可能仍被归一化剥了 `\p{Cf}`,
93
+ * 所以「`text !== 入参`」与「`arms` 非空」不是一回事,后者才是凭据形的证据)。
94
+ */
95
+ export declare function redactSecretsWithArms(text: string, opts?: {
96
+ observe?: boolean;
97
+ json?: boolean;
98
+ }): {
99
+ text: string;
100
+ arms: ReadonlySet<RedactionArm>;
101
+ };
80
102
  /**
81
103
  * 🔴 环 + 深度预算(2026-07-25 补)。原实现是纯递归、**既不记已访问节点也不限深度**,而它的调用位置是
82
104
  * **最不可信的边界**:`toolStart`/`toolEnd` 把工具的 `args`/`output`/`structured` 直接喂进来。后果两条:
@@ -761,7 +761,20 @@ function redactionTokenEndAt(s, p, limit) {
761
761
  return 0;
762
762
  }
763
763
  const labelOfToken = (token) => token.replace(/[«»]/g, "").replace(/^redacted:/, "");
764
- const armOfToken = (repl) => (repl.match(/«[^»]*»/g) ?? [repl]).some((t) => t.endsWith(":partial»")) ? "fragment" : "full";
764
+ const FRAGMENT_ARM_TOKENS = new Set(FRAGMENT_SECRET_PATTERNS.map(([, repl]) => repl));
765
+ {
766
+ const partialSuffix = ":partial»";
767
+ const hasPartialToken = (repl) => (repl.match(/«[^»]*»/g) ?? [repl]).some((t) => t.endsWith(partialSuffix));
768
+ for (const [, repl] of FRAGMENT_SECRET_PATTERNS) {
769
+ if (!hasPartialToken(repl))
770
+ throw new Error(`redact: fragment-arm replacement ${JSON.stringify(repl)} must end with ${partialSuffix}`);
771
+ }
772
+ for (const [, repl] of [...STRUCTURAL_SECRET_PATTERNS, ...KEYWORD_SECRET_PATTERNS, ...SHAPE_SECRET_PATTERNS]) {
773
+ if (hasPartialToken(repl))
774
+ throw new Error(`redact: strong-arm replacement ${JSON.stringify(repl)} must not end with ${partialSuffix}`);
775
+ }
776
+ }
777
+ const armOfToken = (repl) => (FRAGMENT_ARM_TOKENS.has(repl) ? "fragment" : "full");
765
778
  const NO_HITS = [];
766
779
  const singleHit = (label, arm, r) => r.count > 0 ? { out: r.out, hits: [{ label, count: r.count, arm }] } : { out: r.out, hits: NO_HITS };
767
780
  const regexRule = ([re, repl]) => {
@@ -1021,10 +1034,24 @@ const armPoolRule = (() => {
1021
1034
  const out = applySpans(text, kept).out;
1022
1035
  if (!wantCount || kept.length === 0)
1023
1036
  return { out, hits: NO_HITS };
1024
- const byToken = new Map();
1025
- for (const sp of kept)
1026
- byToken.set(sp.repl, (byToken.get(sp.repl) ?? 0) + 1);
1027
- return { out, hits: [...byToken].map(([token, count]) => ({ label: labelOfToken(token), count, arm: armOfToken(token) })) };
1037
+ const strong = spans.filter((sp) => armOfToken(sp.repl) === "full").sort((a, b) => a.start - b.start);
1038
+ const byArmedToken = new Map();
1039
+ let si = 0;
1040
+ let strongMaxEnd = -1;
1041
+ for (const sp of kept) {
1042
+ while (si < strong.length && strong[si].start < sp.end) {
1043
+ strongMaxEnd = Math.max(strongMaxEnd, strong[si].end);
1044
+ si += 1;
1045
+ }
1046
+ const arm = strongMaxEnd > sp.start ? "full" : armOfToken(sp.repl);
1047
+ const key = `${sp.repl}\u0000${arm}`;
1048
+ const cur = byArmedToken.get(key);
1049
+ if (cur === undefined)
1050
+ byArmedToken.set(key, { token: sp.repl, arm, count: 1 });
1051
+ else
1052
+ cur.count += 1;
1053
+ }
1054
+ return { out, hits: [...byArmedToken.values()].map(({ token, arm, count }) => ({ label: labelOfToken(token), count, arm })) };
1028
1055
  },
1029
1056
  };
1030
1057
  })();
@@ -1063,13 +1090,20 @@ function reportHits(hits) {
1063
1090
  }
1064
1091
  }
1065
1092
  }
1066
- export function redactSecrets(text, opts) {
1067
- const wantCount = onRedaction !== undefined && (opts?.observe ?? true);
1068
- const json = opts?.json === true;
1069
- const r = applyRules(normalizeForRedaction(text), wantCount, json, opts?.observe ?? true);
1093
+ function runRedaction(text, observe, json, wantHits) {
1094
+ const wantCount = wantHits || (onRedaction !== undefined && observe);
1095
+ const r = applyRules(normalizeForRedaction(text), wantCount, json, observe);
1070
1096
  forgetEscapedSeparatorTails();
1071
- reportHits(r.hits);
1072
- return r.out;
1097
+ if (observe)
1098
+ reportHits(r.hits);
1099
+ return { text: r.out, hits: r.hits };
1100
+ }
1101
+ export function redactSecrets(text, opts) {
1102
+ return runRedaction(text, opts?.observe ?? true, opts?.json === true, false).text;
1103
+ }
1104
+ export function redactSecretsWithArms(text, opts) {
1105
+ const r = runRedaction(text, opts?.observe ?? true, opts?.json === true, true);
1106
+ return { text: r.text, arms: new Set(r.hits.map((h) => h.arm)) };
1073
1107
  }
1074
1108
  const REDACT_MAX_DEPTH = 64;
1075
1109
  export function redactDeep(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.76.1",
3
+ "version": "7.76.2",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",