@sema-agent/server 7.62.0 → 7.64.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 (69) hide show
  1. package/MIGRATION.md +8 -0
  2. package/README.md +2 -0
  3. package/README.zh-CN.md +2 -0
  4. package/USAGE.md +48 -15
  5. package/deploy/sema-up/smoke.sh +2 -2
  6. package/dist/approval-ask-audit-store.d.ts +149 -3
  7. package/dist/approval-ask-audit-store.js +11 -4
  8. package/dist/bench/s1/arms.d.ts +2 -2
  9. package/dist/bench/s1/arms.js +20 -15
  10. package/dist/bench/s1/live-deps.js +3 -1
  11. package/dist/bench/s1/run-firm.js +2 -2
  12. package/dist/boot/runner-deps.d.ts +2 -2
  13. package/dist/boot/runner-deps.js +1 -0
  14. package/dist/config-catalog.js +2 -0
  15. package/dist/config-types.d.ts +27 -2
  16. package/dist/config.js +83 -2
  17. package/dist/degenerate-instrument.d.ts +0 -33
  18. package/dist/degenerate-instrument.js +2 -1
  19. package/dist/fleet/fleet-bus.d.ts +9 -4
  20. package/dist/fleet/fleet-bus.js +2 -1
  21. package/dist/host-decision.d.ts +39 -0
  22. package/dist/host-decision.js +6 -0
  23. package/dist/http/principal-gate.d.ts +9 -0
  24. package/dist/http/principal-gate.js +12 -0
  25. package/dist/http/route-ctx.d.ts +8 -5
  26. package/dist/http/routes/a2a-serve.js +4 -2
  27. package/dist/http/routes/approvals-assistant.d.ts +0 -13
  28. package/dist/http/routes/approvals-assistant.js +2 -1
  29. package/dist/http/routes/capabilities.js +2 -0
  30. package/dist/http/routes/diagnostics.js +7 -4
  31. package/dist/http/routes/runs.js +3 -2
  32. package/dist/http/routes/tasks.js +33 -29
  33. package/dist/http/routes/workflows.d.ts +0 -11
  34. package/dist/http/routes/workflows.js +18 -14
  35. package/dist/http/server.d.ts +7 -0
  36. package/dist/http/server.js +50 -46
  37. package/dist/leader/fanout.d.ts +6 -23
  38. package/dist/leader/fanout.js +8 -19
  39. package/dist/leader/leader.d.ts +13 -10
  40. package/dist/leader/leader.js +30 -22
  41. package/dist/leader/repair-wire.d.ts +4 -1
  42. package/dist/leader/repair-wire.js +7 -5
  43. package/dist/leader/wire.js +5 -4
  44. package/dist/main.js +7 -3
  45. package/dist/observability/fail-open.d.ts +8 -0
  46. package/dist/observability/fail-open.js +8 -0
  47. package/dist/observability/metrics.js +1 -1
  48. package/dist/observability/run-terminal-log.d.ts +25 -7
  49. package/dist/observability/run-terminal-log.js +15 -4
  50. package/dist/observability/tool-trace.d.ts +13 -12
  51. package/dist/observability/tool-trace.js +8 -13
  52. package/dist/parked-decide.d.ts +8 -4
  53. package/dist/parked-decide.js +1 -1
  54. package/dist/plugins/file-run-store.js +12 -21
  55. package/dist/plugins/memory-run-store.js +3 -2
  56. package/dist/plugins/run-store-sql.js +3 -2
  57. package/dist/run-local.js +6 -4
  58. package/dist/runs.d.ts +4 -19
  59. package/dist/runs.js +49 -47
  60. package/dist/terminal.d.ts +100 -0
  61. package/dist/terminal.js +55 -0
  62. package/dist/trace/core-keyset-guard.d.ts +20 -4
  63. package/dist/trace/ledger-sink.d.ts +2 -1
  64. package/dist/trace/ledger-sink.js +14 -7
  65. package/dist/trace/project.d.ts +9 -12
  66. package/dist/trace/project.js +54 -9
  67. package/dist/write-protection.d.ts +80 -0
  68. package/dist/write-protection.js +30 -0
  69. package/package.json +4 -4
@@ -6,7 +6,7 @@
6
6
  * compiling unchanged. All imports below are top-level `import type` (erased — never a load edge,
7
7
  * see test/module-cycle-gate.test.ts header on verbatimModuleSyntax). */
8
8
  import type { SealedKeyPoison } from "./sealed-key.js";
9
- import type { A2aServerSpec, CompliancePosture, CrossSessionDialogExpiry, CrossSessionInboundSetting, FileHistoryRetentionPolicy, LockedKey, McpServerSpec, Model, ModelRoles, RetentionPolicy } from "@sema-agent/core";
9
+ import type { A2aServerSpec, CompliancePosture, CrossSessionDialogExpiry, CrossSessionInboundSetting, FileHistoryRetentionPolicy, LockedKey, McpServerSpec, Model, ModelRoles, RetentionPolicy, WriteProtectedEntry } from "@sema-agent/core";
10
10
  import type { ApprovalHmacKey, PrincipalJwtKey } from "./auth-keys.js";
11
11
  import type { ElicitationThrottle } from "./elicitation.js";
12
12
  import type { QuestionThrottle } from "./question.js";
@@ -1268,6 +1268,31 @@ export interface ServiceConfigFlat {
1268
1268
  * 已收口:守卫按 `ToolCallRequest.cwd` 解析写目标,两条 lane 同得。剩余射程边界(引擎未盖戳的直接
1269
1269
  * 调用形)成文在 `deployment-governance.ts` 的 `RelativeTargetLexicalEnv` 类注。 */
1270
1270
  sensitiveWritePatterns: string[];
1271
+ /**
1272
+ * S-138 —— core **写保护名表**(`RunnerDeps.writeProtectedPaths` 座)的部署决定。两根 env 旋钮
1273
+ * 共铸这**一个**键(它们写的是同一条语义面,两根同写 ⇒ boot 拒启,见 config.ts 的解析点):
1274
+ * · `WRITE_PROTECTED_EXTRA` ⇒ `{knob:"extra", entries:[...WRITE_PROTECTED_DEFAULT_TABLE, …增量]}`;
1275
+ * · `WRITE_PROTECTED_TABLE_REPLACE` ⇒ `{knob:"replace", entries:<JSON 数组原样>}`(`[]` = 显式无表)。
1276
+ *
1277
+ * 🔴 **缺席 = 不铸键**(与 `readFace` 族同律):座缺席时 core 的 `resolveWriteProtectedTable` 返回它
1278
+ * 自己的缺省 51 行 —— 本仓**不复制那张表**,上游改表之日起不会有第二份真源。「缺席 = 没装表」是
1279
+ * B-023 F2 的那个误读,读面(`writeProtection` 两位)正是为了让它不可能再发生。
1280
+ *
1281
+ * 🔴 为什么带 `knob` 而不是只存数组:`extra` 与 `replace` 铸出的 `entries` 在**值**上分不出来
1282
+ * (一份 `[...DEFAULT, 两行]` 既可能是加法、也可能是运维手抄的整表),而两者对运维的意义相反 ——
1283
+ * 读面的 `source`/`replaced` 要回答的正是「运维动没动那张表」。从值反推 = 猜,故意存事实。
1284
+ * 座席(纯数组)由 `buildDeploymentPostureSeats` 从本键**派生**(`crossSessionInbound` 同族形制:
1285
+ * config 存决定、座席存引擎形)。
1286
+ *
1287
+ * ⚠️ 与 {@link sensitiveWritePatterns} 是**并列机制、不互相覆盖**(core d.ts 原话):那一套是模式
1288
+ * (正则/glob)型 deny,这一套是字面名表的 `allow → ask` 降级。两个读面因此分开报。
1289
+ */
1290
+ writeProtectedPaths?: {
1291
+ /** 哪根旋钮写的(闭集;读面 `source` / `replaced` 的唯一判据 —— 不从 entries 的值反推)。 */
1292
+ readonly knob: "extra" | "replace";
1293
+ /** 座席原样递给 core 的表项(裸名简写或 `{name,kind}` 行;坏值在 boot 期由 core 响亮拒)。 */
1294
+ readonly entries: readonly WriteProtectedEntry[];
1295
+ };
1271
1296
  /** [ref]§四 opt-in (cli[ref]② finding, core[ref] suggested mechanism): the CC manual-family permission
1272
1297
  * modes (default/auto/acceptEdits) gate every fs WRITE hand tool (Write/Edit/NotebookEdit) but never touched
1273
1298
  * Bash — a model can `echo -n '…' > file` straight past the write-approval card. `MANUAL_MODE_SHELL_GATE`
@@ -1638,7 +1663,7 @@ export type ServiceStoreConfig = Pick<ServiceConfigFlat, "sessionBackend" | "ses
1638
1663
  export type ServiceModelPlaneConfig = Pick<ServiceConfigFlat, "gatewayBaseUrl" | "gatewayApiKey" | "gatewayFallbackUrls" | "gatewayMaxRetries" | "anthropic" | "resilience" | "model" | "models" | "modelApiKeyEnv" | "modelApiKeys" | "modelQuotaWeights" | "tiers" | "projects" | "roles" | "atModelAllowlist" | "cascadeLadder" | "degrade">;
1639
1664
  /** 组:approval(审批 / HITL 门)。`directDoorActive` 无 env 解析腿(装配层三域合取的产物),但语义上
1640
1665
  * 就是本组的门状态,故进组;`parseApprovalDomain` 的返回类型相应是 `Omit<…, "directDoorActive">`。 */
1641
- export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "resourceSuspendTotalTokens" | "resourceSuspendTotalBudgetUsd" | "resourceSuspendMaxSlices" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "unattendedApprovalPolicy" | "mcpElicitation" | "sensitiveWritePatterns" | "manualModeShellGate" | "permissionsDisableAutoMode" | "crossSessionInbound" | "crossSessionDialogExpiry">;
1666
+ export type ServiceApprovalConfig = Pick<ServiceConfigFlat, "approvalRequire" | "approvalDeny" | "approvalTimeoutSec" | "approvalAutoBudget" | "approvalNeverAuto" | "approvalHmacKeys" | "durableApproval" | "directApprovalDoor" | "directDoorActive" | "resourceSuspend" | "resourceSuspendTtlSec" | "resourceSuspendTotalTokens" | "resourceSuspendTotalBudgetUsd" | "resourceSuspendMaxSlices" | "askQuestionEnabled" | "questionThrottle" | "toolApprovalEnabled" | "permissionRulesEnabled" | "permissionRulesEnabledExplicit" | "streamAskWindowMarginMs" | "streamApproval" | "unattendedApprovalPolicy" | "mcpElicitation" | "sensitiveWritePatterns" | "writeProtectedPaths" | "manualModeShellGate" | "permissionsDisableAutoMode" | "crossSessionInbound" | "crossSessionDialogExpiry">;
1642
1667
  /** 组:memory(记忆面 + TOC 同步腿)。 */
1643
1668
  export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled" | "memoryEngineDir" | "memoryEngineRemoteLaneAllowed" | "memoryEngineBackend" | "memoryScope" | "memoryPersistenceCapable" | "memorySync" | "memoryEmbedder" | "memoryOrgAdmissionMode" | "memoryDelegationEvidence" | "memoryProvenance" | "memoryCapturePolicy" | "memoryConsolidationDriver" | "memoryOrgDirectoryJson" | "memoryOrgGrantTtlMs" | "memoryOrgUnavailableBackoffMs" | "projectMemoryEnabled" | "syncImportLeaseStaleSec">;
1644
1669
  /** 组:auth(鉴权 / 身份 / 治理棒)。`commandPolicy` 只有 sema-registry 腿(无 env 标量形),故 env 解析
package/dist/config.js CHANGED
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { BUILTIN_COMPLIANCE_DENIES, COMPLIANCE_CAPABILITIES, CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES, compileReadDeny, resolveReadDenyBuiltins, CODE_AGENT_PROMPT, formatUserScope, PROTOCOL_TABLE, protocolOf, RECOMMENDED_SENSITIVE_PATTERNS, RETIRED_TOOL_NAMES, resolveFileHistoryRetention, resolveLockedKeys, validatePermissionRules } from "@sema-agent/core";
5
+ import { BUILTIN_COMPLIANCE_DENIES, COMPLIANCE_CAPABILITIES, CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES, compileReadDeny, resolveReadDenyBuiltins, CODE_AGENT_PROMPT, formatUserScope, PROTOCOL_TABLE, protocolOf, RECOMMENDED_SENSITIVE_PATTERNS, RETIRED_TOOL_NAMES, resolveFileHistoryRetention, resolveLockedKeys, validatePermissionRules, WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable } from "@sema-agent/core";
6
6
  import { ROSTER_PRIMARY_ROLES, ROSTER_CHEAP_ROLES } from "@sema-agent/settings-schema";
7
7
  import { parseApprovalHmacKeys, parsePrincipalJwks } from "./auth-keys.js";
8
8
  import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
@@ -10,6 +10,7 @@ import { isV2ScopeKey } from "./memory-scope.js";
10
10
  import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
11
11
  import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js";
12
12
  import { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
13
+ import { buildWriteProtectionPosture } from "./write-protection.js";
13
14
  import { normalizeSshFingerprint } from "./ssh-host-key.js";
14
15
  import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
15
16
  export { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
@@ -414,6 +415,85 @@ function assertReadDenyBuiltinsResolvable(tiers, exclude) {
414
415
  if (exclude !== undefined)
415
416
  check("READ_DENY_BUILTIN_EXCLUDE", { ...(tiers !== undefined ? { tiers } : {}), exclude });
416
417
  }
418
+ const WRITE_PROTECTED_JSON_SHAPED = /[[\]{}"]/;
419
+ function writeProtectedTableOrRefuse(name, raw, opts) {
420
+ const trimmed = raw.trim();
421
+ if (trimmed === "") {
422
+ throw new Error(`env ${name}="" is empty — refusing to start rather than guessing which of the two meanings you wanted. ` +
423
+ `An unexpanded compose variable and a deliberate posture must not look alike on a protection knob: unset the variable to keep the engine's default table, ` +
424
+ `or write WRITE_PROTECTED_TABLE_REPLACE=[] for the explicit "no write-protection table at all" posture.`);
425
+ }
426
+ let list;
427
+ if (WRITE_PROTECTED_JSON_SHAPED.test(trimmed)) {
428
+ let parsed;
429
+ try {
430
+ parsed = JSON.parse(trimmed);
431
+ }
432
+ catch (e) {
433
+ throw new Error(`env ${name} is not valid JSON: ${e instanceof Error ? e.message : String(e)} — expected a JSON ARRAY of "name" strings or {name, kind} rows.`);
434
+ }
435
+ if (!Array.isArray(parsed)) {
436
+ throw new Error(`env ${name} must be a JSON ARRAY of "name" strings or {name, kind} rows — got ${typeof parsed === "object" && parsed !== null ? "a JSON object" : `the JSON scalar ${JSON.stringify(parsed)}`}. ` +
437
+ `A single row still needs its brackets: [${trimmed}].`);
438
+ }
439
+ list = parsed;
440
+ }
441
+ else if (!opts.bareNames) {
442
+ throw new Error(`env ${name} must be a JSON array — the whole-table REPLACEMENT knob deliberately has no comma shorthand: ` +
443
+ `a slipped bare string there would swap the engine's whole table for one row. Write ["${trimmed.split(",")[0].trim()}"] if that is really what you mean, ` +
444
+ `or use WRITE_PROTECTED_EXTRA to ADD rows to the default table.`);
445
+ }
446
+ else {
447
+ const parts = trimmed.split(",").map((s) => s.trim());
448
+ const names = parts.filter((s) => s.length > 0);
449
+ if (names.length !== parts.length)
450
+ throw new Error(`env ${name}="${raw}" has an empty entry between commas — refusing to start rather than silently applying a half-parsed list (#157).`);
451
+ list = names;
452
+ }
453
+ try {
454
+ return resolveWriteProtectedTable([...(opts.prefix ?? []), ...list]);
455
+ }
456
+ catch (e) {
457
+ throw new Error(`env ${name}: ${e instanceof Error ? e.message : String(e)}`);
458
+ }
459
+ }
460
+ function parseWriteProtectedTableEnv(extraRaw, replaceRaw) {
461
+ if (extraRaw !== undefined && replaceRaw !== undefined) {
462
+ throw new Error(`env WRITE_PROTECTED_EXTRA and WRITE_PROTECTED_TABLE_REPLACE are both set — two writers on ONE semantic surface (the engine's write-protection table). ` +
463
+ `There is no honest composition of "add to the default table" and "replace the table wholesale", so this refuses to boot instead of picking one: ` +
464
+ `keep WRITE_PROTECTED_EXTRA for additions, or fold your additions into WRITE_PROTECTED_TABLE_REPLACE and unset the other.`);
465
+ }
466
+ if (extraRaw !== undefined) {
467
+ return { knob: "extra", entries: writeProtectedTableOrRefuse("WRITE_PROTECTED_EXTRA", extraRaw, { bareNames: true, prefix: WRITE_PROTECTED_DEFAULT_TABLE }) };
468
+ }
469
+ if (replaceRaw === undefined)
470
+ return undefined;
471
+ const decision = { knob: "replace", entries: writeProtectedTableOrRefuse("WRITE_PROTECTED_TABLE_REPLACE", replaceRaw, { bareNames: false }) };
472
+ const posture = buildWriteProtectionPosture({ writeProtectedPaths: decision });
473
+ const dropped = posture.droppedDefaultRows ?? [];
474
+ CONFIG_NOTICES.push(posture.source === "off"
475
+ ? {
476
+ event: "write_protection_table_disabled",
477
+ fields: {
478
+ env: "WRITE_PROTECTED_TABLE_REPLACE",
479
+ raw: "[]",
480
+ droppedCount: String(dropped.length),
481
+ effect: `the engine's write-protection table is now EMPTY — all ${String(dropped.length)} default rows are gone and a write to any of them ` +
482
+ `(.zshrc / .ssh / .git / …) no longer gets the allow→ask demotion. This is a legal, explicit posture; it is logged loudly so it can never be a silent one`,
483
+ },
484
+ }
485
+ : {
486
+ event: "write_protection_table_replaced",
487
+ fields: {
488
+ env: "WRITE_PROTECTED_TABLE_REPLACE",
489
+ rows: String(posture.rows.length),
490
+ droppedCount: String(dropped.length),
491
+ droppedDefaultRows: dropped.join(","),
492
+ effect: "whole-table replacement: the default rows listed above are NO LONGER protected (use WRITE_PROTECTED_EXTRA if you meant to ADD rows to the default table)",
493
+ },
494
+ });
495
+ return decision;
496
+ }
417
497
  function enumEnv(name, fallback, allowed) {
418
498
  const v = (process.env[name] ?? fallback);
419
499
  if (!allowed.includes(v))
@@ -966,6 +1046,7 @@ function parseApprovalDomain(ctx) {
966
1046
  return [];
967
1047
  return trimmed.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
968
1048
  })(process.env.SENSITIVE_WRITE_PATTERNS),
1049
+ ...((decision) => (decision === undefined ? {} : { writeProtectedPaths: decision }))(parseWriteProtectedTableEnv(process.env.WRITE_PROTECTED_EXTRA, process.env.WRITE_PROTECTED_TABLE_REPLACE)),
969
1050
  ...((raw) => {
970
1051
  const trimmed = (raw ?? "").trim();
971
1052
  if (trimmed === "always" || trimmed === "classify")
@@ -1803,7 +1884,7 @@ const APPROVAL_GROUP_KEYS = [
1803
1884
  "approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
1804
1885
  "approvalHmacKeys", "durableApproval", "directApprovalDoor", "directDoorActive", "resourceSuspend",
1805
1886
  "resourceSuspendTtlSec", "resourceSuspendTotalTokens", "resourceSuspendTotalBudgetUsd", "resourceSuspendMaxSlices", "askQuestionEnabled", "questionThrottle", "toolApprovalEnabled", "permissionRulesEnabled", "permissionRulesEnabledExplicit", "streamAskWindowMarginMs", "streamApproval", "unattendedApprovalPolicy", "mcpElicitation",
1806
- "sensitiveWritePatterns", "manualModeShellGate", "permissionsDisableAutoMode", "crossSessionInbound", "crossSessionDialogExpiry",
1887
+ "sensitiveWritePatterns", "writeProtectedPaths", "manualModeShellGate", "permissionsDisableAutoMode", "crossSessionInbound", "crossSessionDialogExpiry",
1807
1888
  ];
1808
1889
  const MEMORY_GROUP_KEYS = [
1809
1890
  "memoryEngineEnabled", "memoryEngineDir", "memoryEngineRemoteLaneAllowed", "memoryEngineBackend", "memoryScope",
@@ -1,36 +1,3 @@
1
- /**
2
- * Degenerate-repetition instrument (a/b classifier).
3
- *
4
- * When a task fails with `errorCode === "output.degenerate"` (core 1.59), core hands back
5
- * `salvagedOutput` = the degenerate turn's text — **already tail-TRIMMED at the brain stream layer**.
6
- * 🔴 [ref] correction (this note used to claim "the **whole** text, good head + looped garbage tail";
7
- * that was true of core 1.59 and false of every engine that ships `trimDegenerateTail` — verified against
8
- * the installed dist, not against JSDoc): on the main path `assemble-result` fills the seat from the same
9
- * final message the brain already trimmed, so what arrives here is `head + exactly ONE copy of the
10
- * repeating unit` (unit ≤ `MAX_PERIOD` = 100 chars). Untrimmed text only reaches us on the narrow
11
- * fallbacks — the cut landed on the reasoning face, or `trimDegenerateTail` bailed (`reps < 2`, empty
12
- * unit, unit-loop period mismatch). Consequence for the numbers below: {@link repetitionTail} is a
13
- * **no-op** on the main-path input (one copy is not ≥ {@link MIN_REPEATS}), so `uniquePrefixLen`
14
- * collapses onto `salvagedLen` and the a/unknown split degrades to a pure length test. The a-vs-b
15
- * decision is unaffected (it is decided by `priorChars`, independently); the residual bias on a/unknown
16
- * is bounded by one unit ≤ 100 chars. Pin: the "已裁形" case in `test/degenerate-instrument.test.ts`
17
- * drives the REAL `inspectDegenerate` + `trimDegenerateTail` and asserts the tail measures 0.
18
- * Core's *salvage ②* — recovering the "last substantive turn" instead of the current one — is being
19
- * gated on REAL data: how often is the useful answer actually in an EARLIER turn vs. in the degenerate
20
- * turn itself? This instrument answers that, per event, without changing any behaviour.
21
- *
22
- * Classification (since the last user message = this task's own turns):
23
- * - **a** good+garbage SAME turn — the degenerate turn carries a substantive unique head before the
24
- * loop. Whole-turn salvage ① + a tail-trim is enough; ② would add nothing.
25
- * - **b** good EARLIER / degenerate LATER — a substantive assistant turn already exists before the
26
- * degenerate one. ② ("last-substantive-turn") would recover it; ① (current turn) throws it away.
27
- * - **unknown** — degenerate from the start, nothing substantive anywhere (neither ① nor ② helps).
28
- *
29
- * Output is measurement only: a `degenerate_total{class}` counter (→ `/metrics/summary`) plus one
30
- * structured log line carrying the raw signals. The aggregate a/b/unknown frequencies are what gets
31
- * reported back to the core AI to decide whether ② is worth building. The repetition-boundary estimate
32
- * here is for *measuring* the unique head — it is NOT the production tail-trim (that lives in core).
33
- */
34
1
  import { type TaskResult } from "@sema-agent/core";
35
2
  import type { Pool } from "mysql2/promise";
36
3
  import type { Metrics } from "./observability/metrics.js";
@@ -1,3 +1,4 @@
1
+ import { failureCodeOf } from "./terminal.js";
1
2
  import { StoredSession } from "@sema-agent/core";
2
3
  import { TiDBSessionStorage } from "./plugins/tidb-session-storage.js";
3
4
  const SUBSTANTIVE_CHARS = 200;
@@ -95,7 +96,7 @@ async function readPriorTurns(pool, sessionId, salvaged) {
95
96
  }
96
97
  export function makeDegenerateInstrument(pool, metrics, logger) {
97
98
  return (result) => {
98
- if (result.errorCode !== "output.degenerate")
99
+ if (failureCodeOf(result.terminal) !== "output.degenerate")
99
100
  return;
100
101
  void (async () => {
101
102
  const salvaged = result.salvagedOutput ?? "";
@@ -1,4 +1,4 @@
1
- import type { TaskNotificationPayload, WorkflowRun } from "@sema-agent/core";
1
+ import type { TaskNotificationPayload, TerminalCause, WorkflowRun } from "@sema-agent/core";
2
2
  import type { RunRecord } from "../plugins/store-contracts.js";
3
3
  /** [ref] 幽灵行案的单源判别:一条 `task_notification` 只有在 **agent 族 × 终态** 时才允许打
4
4
  * `onChildTerminal`(fleet「subagent 树」只渲 agent 子代)。`background_bash`/`monitor`/`external`
@@ -546,11 +546,16 @@ export declare function fleetRunPublisher(bus: FleetEventBus | undefined, run: {
546
546
  export type FleetRunPublisher = ReturnType<typeof fleetRunPublisher>;
547
547
  /** [ref]①/(a) 案:TaskResult → 终态行帧残局键映射(sync/background/resume 三条腿各自 settle,共用一处
548
548
  * 映射——键名映射写三遍必漂移)。usage 与 {@link BgNotification.usage} 同形:stats.tokens→totalTokens、
549
- * stats.toolCalls→toolUses(名对齐,消费端一个形状)。errorCode "cancelled" ⇒ stoppedBy:"user"(与
550
- * markStopSource 的归因值同源);transcriptId 顶层 = run 的 sessionId([ref]§二拍)。缺源的键不铸。 */
549
+ * stats.toolCalls→toolUses(名对齐,消费端一个形状)。失败码 "cancelled" ⇒ stoppedBy:"user"(与
550
+ * markStopSource 的归因值同源);transcriptId 顶层 = run 的 sessionId([ref]§二拍)。缺源的键不铸。
551
+ *
552
+ * 🔴 S-136(core 7.6.0 D-8):入参从**结构性可缺席** `errorCode?: string` 改成 `terminal?: TerminalCause`。
553
+ * 留着旧形不会红 —— 那正是危险所在:`TaskResult` 上已经没有 `errorCode` 这个键了,可结构性可选参数照样
554
+ * 编译通过,于是 `stoppedBy:"user"` 会**静默地永远不再铸**(取消的 run 在舰队行上从此看不出是被人杀的)。
555
+ * 终局事实一律走因由,`failureCodeOf` 是本仓读「这次失败带的是哪个码」的唯一形。 */
551
556
  export declare function fleetRunResiduals(result: {
552
557
  sessionId?: string;
553
- errorCode?: string;
558
+ terminal?: TerminalCause;
554
559
  stats?: {
555
560
  turns?: number;
556
561
  tokens?: number;
@@ -1,5 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { deriveAgentDisplayStatus, isDelegatedAgentTerminal } from "@sema-agent/core";
3
+ import { persistedPlaneOf } from "../terminal.js";
3
4
  import { recordFailOpen } from "../observability/fail-open.js";
4
5
  import { redactSecrets } from "../trace/redact.js";
5
6
  export function isFleetAgentTerminalNotification(n) {
@@ -341,7 +342,7 @@ export function fleetRunResiduals(result) {
341
342
  : {};
342
343
  return {
343
344
  ...(Object.keys(usage).length > 0 ? { usage } : {}),
344
- ...(result.errorCode === "cancelled" ? { stoppedBy: "user" } : {}),
345
+ ...(persistedPlaneOf(result).errorCode === "cancelled" ? { stoppedBy: "user" } : {}),
345
346
  ...(result.sessionId ? { transcriptId: result.sessionId } : {}),
346
347
  };
347
348
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * **宿主的决策事实** —— 一条耐久 park 被结算时,本服务能如实陈述的那半件事:谁结束了这次等待,以及
3
+ * 审批通道自报的归属。一个叶子模块(只依赖 `@sema-agent/core`),四个消费点共用一个定义。
4
+ *
5
+ * ## 为什么是「事实」而不是「结算词」(core 7.6.0 / [ref] S6-A,BREAKING)
6
+ *
7
+ * 旧形里 server 要往 `ResumeOutcome` 上写 core 的**结算词**(`ApprovalSettledBy` = `"human"` /
8
+ * `"timeout"` / `"aborted"`),于是「哪个词配哪个 decision」这条相容规则得由 server 自己记住 —— 而记错的
9
+ * 方向恰恰是这条规则存在的理由:**「窗到期」被报成「另一个人拒绝了」**。7.6.0 起 core 的
10
+ * `@contract settlement.single_mint` 逐字写着「宿主永不铸词」:宿主交事实,core 在 resume 入口铸
11
+ * `human_allowed` / `human_refused` / `park_sla_expired`,写进行的 `resolvedOutcome.gateOutcome`,再投影到
12
+ * 那次调用的 `tool_end.gate`。
13
+ *
14
+ * ⇒ 本仓的规则从「记住一张词×决定的相容表」变成「说出你知道的那两件事」。相容性也不再靠记忆:一次
15
+ * sweep 不可能 `allow`,core pre-CAS 拒。
16
+ */
17
+ import { type ResumeOutcome } from "@sema-agent/core";
18
+ /**
19
+ * 谁结束了这次等待,以及通道自报的归属。
20
+ * · `decidedBy: "person"` —— 运维经审批通道做的决定(两条 HTTP `/decide` 腿);
21
+ * · `decidedBy: "sla_timeout"` —— 宿主自己的 SLA 扫在行的 deadline 上判的(FACET A;**只有** sweep 能这么说,
22
+ * 而 sweep 结构上不能 `allow`)。
23
+ * · `approver` —— 通道自报的结算方标识串。**缺席永不读成**「没人批」或「有人批」,只意味着通道没报名字。
24
+ */
25
+ /** 直接取 core `ResumeOutcome` policy_ask 臂的 `hostDecision` 型 —— 本仓不复述那两个词(第二铸点)。 */
26
+ export type HostDecision = Extract<ResumeOutcome, {
27
+ gate: "policy_ask";
28
+ }>["hostDecision"];
29
+ /**
30
+ * 一次**人为** decide 的事实。`principal` = 那条腿已经验过身份的决策者(缺席 = 匿名档的部署,通道确实
31
+ * 没有名字可报)。
32
+ *
33
+ * 归属串走 core 的 `screenApproverAttribution` **先筛后带**:core 对形不合的归属是 pre-CAS 响亮拒
34
+ * (`checkpoint.invalid_outcome`),而一个奇形怪状的 principal 把一次合法的人工决策变成 400 是纯回归。
35
+ * 筛不过 ⇒ **不带这一格**(而不是带一个被改造过的串):归属是转录,不是可以「修一修」的东西 —— 修出来的
36
+ * 名字既不是通道报的那个,也不是「通道没报」,两头都不真。
37
+ */
38
+ export declare function personDecision(principal: string | undefined): HostDecision;
39
+ //# sourceMappingURL=host-decision.d.ts.map
@@ -0,0 +1,6 @@
1
+ import { screenApproverAttribution } from "@sema-agent/core";
2
+ export function personDecision(principal) {
3
+ const screened = screenApproverAttribution(principal);
4
+ return { decidedBy: "person", ...(screened.approver !== undefined ? { approver: screened.approver } : {}) };
5
+ }
6
+ //# sourceMappingURL=host-decision.js.map
@@ -11,6 +11,15 @@ export declare function systemFor(req: IncomingMessage, config: ServiceConfig):
11
11
  /** "presented credentials must verify":Authorization 带 Bearer/token scheme 且三路全验不过 → true。
12
12
  * 其他 scheme(Basic 等,非我方凭证空间)不管——由原有门语义处置,避免误伤未知代理注头。 */
13
13
  export declare function bearerPresentedButUnverified(req: IncomingMessage, config: ServiceConfig): boolean;
14
+ /** loopback 三形(IPv4 / IPv6 / IPv4-mapped)—— 一处定义,diagnostics 门与任何「本机自用」判据同吃。缺席 ⇒ false(fail-closed)。 */
15
+ export declare function isLoopbackAddress(addr: string | undefined): boolean;
16
+ /**
17
+ * 这次请求的身份是不是**密码学验过**的(而不是调用方自述的 header):`sso` = registry JWT(入口预认证的签名真源)/
18
+ * `direct_door_jwt` = direct-door principal JWT 验过 / `service_token` = 出示了单一或 per-system service token。
19
+ * 缺席 = 只有自述头或什么都没有。一处判定(7.63.0 codex [high]+[medium]):off-box 读 operator 面的门用它——名单只在
20
+ * 有可验身份时才是身份,零凭据下它只是谁都能写的字符串;而 JWT 身份不依赖静态 token 的在场。
21
+ */
22
+ export declare function verifiedIdentitySource(req: IncomingMessage, config: ServiceConfig): "sso" | "direct_door_jwt" | "service_token" | undefined;
14
23
  export declare function gatedPrincipal(req: IncomingMessage, config: ServiceConfig): string | undefined;
15
24
  /** The EXPLICIT operator gate (IMAGE-API-DESIGN.md §P2.4a MUST-FIX) — NOT the bare `isOperator` (whose empty-list
16
25
  * "true-for-all" is an unrestricted-write exec door the instant OPERATOR_PRINCIPALS is empty). Requires a non-empty
@@ -33,6 +33,18 @@ export function bearerPresentedButUnverified(req, config) {
33
33
  return false;
34
34
  return true;
35
35
  }
36
+ export function isLoopbackAddress(addr) {
37
+ return addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
38
+ }
39
+ export function verifiedIdentitySource(req, config) {
40
+ if (ssoVerifiedPrincipal(req))
41
+ return "sso";
42
+ if (systemFor(req, config) !== undefined)
43
+ return "service_token";
44
+ if (config.directDoorActive && verifiedPrincipal(req, config) !== undefined)
45
+ return "direct_door_jwt";
46
+ return undefined;
47
+ }
36
48
  export function gatedPrincipal(req, config) {
37
49
  if (bearerPresentedButUnverified(req, config))
38
50
  return undefined;
@@ -15,7 +15,8 @@
15
15
  * 本文件对 server.ts 的引用一律 `import type`(tsc 擦除,非装载边)。
16
16
  */
17
17
  import type { IncomingMessage, ServerResponse } from "node:http";
18
- import type { ApprovalSettledBy, TaskStream, TaskSpec, TaskResult, QuestionAnswer, CheckpointToken, ResumeOutcome, Runner } from "@sema-agent/core";
18
+ import type { HostDecision } from "../host-decision.js";
19
+ import type { TaskStream, TaskSpec, TaskResult, QuestionAnswer, CheckpointToken, ResumeOutcome, Runner } from "@sema-agent/core";
19
20
  import type { FlatServiceDeps, RequestAuth } from "./server.js";
20
21
  import type { TaskRequestBody, DecideBinding } from "./wire-types.js";
21
22
  import type { IdempotencyCache } from "./idempotency.js";
@@ -244,10 +245,12 @@ export interface RouteLegs {
244
245
  /** 同步腿的终局记账(计费/配额/指标),tasks 域用。 */
245
246
  finalizeTaskResult(result: TaskResult, principal: string | undefined, objective: string, sessionId: string | undefined): void;
246
247
  /** approvals 决策腿:session → pending checkpoint → markResuming CAS → 驱动续跑。
247
- * `settledBy` = **这次结算的出处**(core `ApprovalSettledBy`),必填:本腿有两个调用方(HTTP
248
- * `/decide` = 人;内部 D-D SLA deny-sweep = 窗到期),而函数内部分辨不出谁在叫它 —— 必填参数把
249
- * 「漏报出处」变成编译错,不是一条下游把「没人答」渲染成「有人拒」的静默假出处。 */
250
- resumeCheckpoint(sessionId: string, decision: "approve" | "deny", reason: string | undefined, settledBy: ApprovalSettledBy, req?: IncomingMessage, answer?: QuestionAnswer, binding?: DecideBinding,
248
+ * `hostDecision` = **这次结算的事实**(谁结束了这次等待 + 通道自报的归属),必填:本腿有两个调用方
249
+ * (HTTP `/decide` = 人;内部 D-D SLA deny-sweep = 窗到期),而函数内部分辨不出谁在叫它 —— 必填参数把
250
+ * 「漏报出处」变成编译错,不是一条下游把「没人答」渲染成「有人拒」的静默假出处。
251
+ * 🔴 S-136(core 7.6.0 S6-A):宿主报事实、core 铸词 —— server 不再传结算词(旧形传 core 的三词之一,
252
+ * 于是「哪个词配哪个 decision」这条相容规则要 server 自己记,而记错的方向恰是上面那句要防的)。 */
253
+ resumeCheckpoint(sessionId: string, decision: "approve" | "deny", reason: string | undefined, hostDecision: HostDecision, req?: IncomingMessage, answer?: QuestionAnswer, binding?: DecideBinding,
251
254
  /** [ref] codex R4(验真后修):remember 豁免的 grant 挂点。**工具名不再由路由自己读**(那是与本函数
252
255
  * 载入行分歧的第三次 scope-less 读,三读分歧下会给他人工具落豁免=提权)——由本函数把**已属主校验的
253
256
  * 载入 cp** 的工具名(`validatedToolName`,非 tool_approval / AskUserQuestion / legacy null ⇒ `null`
@@ -1,3 +1,4 @@
1
+ import { persistedPlaneOf } from "../../terminal.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { memoRedactedRead, redactErrorMessage } from "../../observability/run-terminal-log.js";
3
4
  import { A2A_TASK_STATES } from "@sema-agent/core";
@@ -84,8 +85,9 @@ function statusTextForRun(run, state) {
84
85
  const memo = (seat, raw) => (isTerminalRunStatus(run.status) ? memoRedactedRead(`a2a.${seat}`, raw, (v) => redactErrorMessage(v) ?? "") : (redactErrorMessage(raw) ?? ""));
85
86
  if (run.error)
86
87
  return memo("error", run.error);
87
- if (run.result?.errorMessage)
88
- return memo("errorMessage", run.result.errorMessage);
88
+ const runPlaneMessage = persistedPlaneOf(run.result).errorMessage;
89
+ if (runPlaneMessage)
90
+ return memo("errorMessage", runPlaneMessage);
89
91
  return undefined;
90
92
  }
91
93
  function taskFromRun(run, stale) {
@@ -1,16 +1,3 @@
1
- /**
2
- * [ref] A9 域模块 —— 人在环审批面(durable F4 + legacy)与 assistant-scheduler 三动词。
3
- *
4
- * 路由(前缀 `/v1/approvals` 与 `/v1/assistant`):
5
- * · durable 腿(接了 checkpointStore 时优先):list / stream(SSE 原生推送)/ decide|answer / preempt /
6
- * resume / plan_review;
7
- * · legacy 腿(approvalStore 轮询车道)已于 5.0.0 整体退役([ref]/[ref] A-5)——无 checkpointStore 时
8
- * `/v1/approvals` 落 404(诚实缺席),不再有 trusted-header 决裁面。
9
- *
10
- * 所有会**烧模型**的续跑都经 `ctx.legs.*`(resumeCheckpoint / resumePreempted / resumePlanReview)走 resume
11
- * 家族的同一条腿:lease admission、markResuming CAS、写 run log、发 fleet 帧都在腿里,本域只做入站鉴权/形状门。
12
- * 本域零可变状态(抢占登记簿 `preemptableRuns` 属跨域 registry)。
13
- */
14
1
  import type { IncomingMessage, ServerResponse } from "node:http";
15
2
  import type { QuestionAnswer } from "@sema-agent/core";
16
3
  import type { CheckpointStoreFull } from "../../plugins/store-backend.js";
@@ -1,3 +1,4 @@
1
+ import { personDecision } from "../../host-decision.js";
1
2
  import { DECIDED_REPLAY_SCAN_CAP, approvalPayloadFingerprint } from "../../plugins/checkpoint-store-sql.js";
2
3
  import { APPROVAL_GATE_KINDS } from "../../tool-approval.js";
3
4
  import { isParkedRunStatus } from "../../plugins/store-contracts.js";
@@ -483,7 +484,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
483
484
  pendingGrant = { sessionId: grant.overrideSessionId ?? sessionId, toolName: canonical };
484
485
  }
485
486
  : undefined;
486
- const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, "human", req, answer, binding, grantOnCommit, true, { principal: deciderPrincipal, explicitOperator });
487
+ const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, personDecision(deciderPrincipal), req, answer, binding, grantOnCommit, true, { principal: deciderPrincipal, explicitOperator });
487
488
  const outErrorCode = out.body.errorCode;
488
489
  if (pendingGrant !== undefined && out.status === 200 && outErrorCode !== "cancelled" && deps.approvalExemptionStore) {
489
490
  try {
@@ -9,6 +9,7 @@ import { mcpInjectionHonored } from "../../task-mcp.js";
9
9
  import { sendJson, sendError } from "../send.js";
10
10
  import { resolveStreamApprovalGate } from "../../tool-approval.js";
11
11
  import { projectMemoryEngineCapability } from "../../memory-posture.js";
12
+ import { projectWriteProtectionCapability } from "../../write-protection.js";
12
13
  import { projectSqlEngineCapability } from "../../sql-engine-posture.js";
13
14
  import { workflowModelAllowlistFor, selfOrchestrationDenial } from "../../task-workflow.js";
14
15
  import { centerEntitlementSourceWired } from "../../runtime-caps-resolver.js";
@@ -114,6 +115,7 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
114
115
  ...autoMode,
115
116
  },
116
117
  readFace: deps.config.readFace ?? null,
118
+ writeProtection: projectWriteProtectionCapability(deps.writeProtection),
117
119
  appendSystemPrompt: true,
118
120
  compactionModel: true,
119
121
  subagentOutput: Boolean(deps.subagentTaskOutput) && Boolean(deps.runStore),
@@ -1,6 +1,6 @@
1
1
  import { resolveStreamApprovalGate } from "../../tool-approval.js";
2
2
  import { sendJson, sendError } from "../send.js";
3
- import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
3
+ import { gatedPrincipal, explicitOperatorOk, isLoopbackAddress, verifiedIdentitySource } from "../principal-gate.js";
4
4
  export function buildServerWiringGates(input) {
5
5
  const gate = resolveStreamApprovalGate({
6
6
  toolApprovalEnabled: input.toolApprovalEnabled,
@@ -56,9 +56,7 @@ export function zeroCredentialLoopbackDenied(remoteAddress, config) {
56
56
  const anyServiceAuth = Boolean(config.authToken) || Object.keys(config.authTokens ?? {}).length > 0;
57
57
  if (anyServiceAuth)
58
58
  return false;
59
- const addr = remoteAddress ?? "";
60
- const isLoopback = addr === "127.0.0.1" || addr === "::1" || addr === "::ffff:127.0.0.1";
61
- return !isLoopback;
59
+ return !isLoopbackAddress(remoteAddress);
62
60
  }
63
61
  export async function handleDiagnostics(req, res, url, ctx) {
64
62
  const miss = { fell: false };
@@ -74,6 +72,10 @@ async function handleDiagnosticsBody(req, res, url, ctx, miss) {
74
72
  sendError(res, 403, "auth.operator_only", "wiring diagnostics are operator-only on a multi-tenant worker");
75
73
  return;
76
74
  }
75
+ if (!isLoopbackAddress(req.socket.remoteAddress) && verifiedIdentitySource(req, deps.config) === undefined) {
76
+ sendError(res, 403, "auth.loopback_only", "wiring diagnostics on a zero-credential deployment are readable from loopback only — configure SERVICE_AUTH_TOKEN to read remotely");
77
+ return;
78
+ }
77
79
  }
78
80
  else if (zeroCredentialLoopbackDenied(req.socket.remoteAddress, deps.config)) {
79
81
  sendError(res, 403, "auth.loopback_only", "wiring diagnostics on a zero-credential deployment are readable from loopback only — configure SERVICE_AUTH_TOKEN to read remotely");
@@ -87,6 +89,7 @@ async function handleDiagnosticsBody(req, res, url, ctx, miss) {
87
89
  static: deps.staticWiring,
88
90
  memoryPosture: deps.memoryPosture ?? null,
89
91
  sqlEngine: deps.sqlEngineFacts?.() ?? null,
92
+ writeProtection: deps.writeProtection ?? null,
90
93
  configApply: deps.configApplyState?.() ?? null,
91
94
  mcpRevocations: deps.mcpRevocationState?.() ?? null,
92
95
  serverGates: buildServerWiringGates({
@@ -1,3 +1,4 @@
1
+ import { persistedPlaneOf } from "../../terminal.js";
1
2
  import { memoRedactedRead, memoRedactTerminalResult, redactErrorMessage, redactHead, redactLedgerEventData, redactTerminalResult } from "../../observability/run-terminal-log.js";
2
3
  import { z } from "zod";
3
4
  import { uuidv7, mintCheckpointToken, CheckpointError, validatePendingSteer } from "@sema-agent/core";
@@ -260,7 +261,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
260
261
  result: isTerminalRunStatus(run.status) ? memoRedactTerminalResult("run.result", run.result ?? undefined) : redactTerminalResult(run.result ?? undefined),
261
262
  supervisorCost,
262
263
  suggestions,
263
- errorCode: stale ? undefined : (run.result?.errorCode ?? undefined),
264
+ errorCode: stale ? undefined : (persistedPlaneOf(run.result).errorCode ?? undefined),
264
265
  error: stale ? "run stalled (instance lost?)" : isTerminalRunStatus(run.status) ? memoRedactedRead("run.error", run.error ?? undefined, redactErrorMessage) : redactErrorMessage(run.error ?? undefined),
265
266
  jobId: run.jobId ?? undefined,
266
267
  source: run.source ?? undefined,
@@ -331,7 +332,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
331
332
  const err = note;
332
333
  try {
333
334
  const cancelContext = buildCancelContext(taskId, Date.parse(run.createdAt));
334
- const result = { taskId, sessionId: run.sessionId, status: "failed", errorCode: "cancelled", errorMessage: err, stats: { turns: 0, tokens: 0 }, ...(cancelContext ? { cancelContext } : {}) };
335
+ const result = { taskId, sessionId: run.sessionId, terminal: { kind: "failed", code: "cancelled", message: err }, stats: { turns: 0, tokens: 0 }, ...(cancelContext ? { cancelContext } : {}) };
335
336
  let lastErr;
336
337
  for (let attempt = 0;; attempt++) {
337
338
  try {