@sema-agent/server 7.44.0 → 7.45.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 (48) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/USAGE.md +10 -1
  4. package/dist/approval-card.d.ts +5 -0
  5. package/dist/approval-card.js +2 -0
  6. package/dist/boot/config-center.js +22 -11
  7. package/dist/boot/execution-env.js +1 -0
  8. package/dist/boot/shutdown.d.ts +18 -2
  9. package/dist/boot/shutdown.js +54 -12
  10. package/dist/boot/side-query-lane.d.ts +31 -46
  11. package/dist/boot/side-query-lane.js +3 -15
  12. package/dist/boot/webfetch-summarize-lane.d.ts +3 -2
  13. package/dist/boot/webfetch-summarize-lane.js +1 -1
  14. package/dist/brain.js +32 -1
  15. package/dist/config-center/apply-effective.js +3 -2
  16. package/dist/config-types.d.ts +16 -1
  17. package/dist/config.js +68 -3
  18. package/dist/hooks/hook-llm.js +29 -20
  19. package/dist/hooks/hook-runner.js +3 -0
  20. package/dist/http/routes/capabilities.js +10 -1
  21. package/dist/http/routes/runs.js +4 -0
  22. package/dist/http/server.d.ts +12 -1
  23. package/dist/http/server.js +6 -3
  24. package/dist/leader/merge.js +4 -2
  25. package/dist/main.js +6 -2
  26. package/dist/memory-sync.js +15 -2
  27. package/dist/observability/fail-open.d.ts +10 -2
  28. package/dist/observability/fail-open.js +10 -2
  29. package/dist/orchestration/hardened-vm-runner.d.ts +29 -0
  30. package/dist/orchestration/hardened-vm-runner.js +52 -4
  31. package/dist/plugins/remote-env-ssh.d.ts +3 -0
  32. package/dist/plugins/remote-env-ssh.js +20 -1
  33. package/dist/plugins/send-user-file.js +2 -1
  34. package/dist/plugins/tidb-session-storage.d.ts +8 -6
  35. package/dist/plugins/tidb-session-storage.js +2 -5
  36. package/dist/rules-consent.d.ts +19 -16
  37. package/dist/rules-consent.js +18 -1
  38. package/dist/run-local.js +6 -1
  39. package/dist/session-titler.js +2 -1
  40. package/dist/sighup-idle.d.ts +16 -2
  41. package/dist/sighup-idle.js +10 -1
  42. package/dist/ssh-host-key.d.ts +65 -0
  43. package/dist/ssh-host-key.js +117 -0
  44. package/dist/text-bidi.d.ts +50 -0
  45. package/dist/text-bidi.js +8 -0
  46. package/dist/tool-approval.d.ts +56 -2
  47. package/dist/tool-approval.js +95 -14
  48. package/package.json +2 -2
@@ -518,6 +518,10 @@ export interface ServiceConfigFlat {
518
518
  username: string;
519
519
  privateKey: string;
520
520
  mountPath?: string;
521
+ hostKey?: {
522
+ fingerprintSha256?: string;
523
+ knownHostsPath?: string;
524
+ };
521
525
  } | {
522
526
  provider: "adb";
523
527
  serial: string;
@@ -965,6 +969,17 @@ export interface ServiceConfigFlat {
965
969
  * set `PLAN_MODE_ENABLED=false` to opt out. USER-driven plan (`body.permissionMode=plan` → enablePlanMode +
966
970
  * handsReadOnly, read-only from start) is unaffected/independent. */
967
971
  planModeEnabled: boolean;
972
+ /**
973
+ * [5118]⑥(core 5.56 `Hooks.timeoutMs` 座)—— **每一只 hook 席位单次调用的时间上限**(ms)。
974
+ * `HOOKS_TIMEOUT_MS`;**缺席 = 不铸键**,由 core 自默认(600s = CC 自己的数;server 不复制上游默认)。
975
+ *
976
+ * 🔴 `0` 是有效值(core 契约逐字:写下零的部署说了一件事 —— 每个席位立即到期),不是「未设」;
977
+ * 坏值(非整数 / 负 / 超 `setTimeout` 32 位上限)**拒启**,解析见 config.ts `parseHooksTimeoutEnv`。
978
+ * 施加点 = `main.ts` 的 `deploymentHooks`(server 车道)与 `run-local.ts` 的同名记录(CLI 本地车道,
979
+ * merge-rescan 745 补折:该腿今日仅同步计数器席位、折上为防倒退)。server 车道一处施加两条腿同时
980
+ * 生效:既是 `RunnerDeps.hooks`,又是 `resolve-spec` 折进 task hooks 的部署基线。
981
+ */
982
+ hooksTimeoutMs?: number;
968
983
  /** SVC-2 (adversarial-review): the workflow_journal GC retention — the reaper purges journal rows older than
969
984
  * this (bounds the heaviest, TaskResult-bearing table; a resume older than it re-runs live). Default 7 days.
970
985
  * `WORKFLOW_JOURNAL_RETENTION_MS`. */
@@ -1401,7 +1416,7 @@ export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled"
1401
1416
  * config 这一层它只是一句声明。 */
1402
1417
  export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authTokens" | "allowUnauthedWrites" | "corsOrigins" | "principalHeader" | "requirePrincipal" | "autonomy" | "commandPolicy" | "compliancePosture" | "lockedConfigKeys" | "retentionPolicy" | "retentionSweep" | "operatorPrincipals" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "principalJwtMaxTtlSec" | "bindHost" | "bindHostSource">;
1403
1418
  /** 组:orchestration(编排 + 执行车道 + 沙箱面 + workflow/后台 agent 存留)。 */
1404
- export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "worktreeIsolation" | "leaderEnabled" | "leaderFanoutEnabled" | "routerEnabled" | "selfOrchestrationEnabled" | "selfOrchestrationModels" | "selfOrchestrationWorkerIsolation" | "forkEnabled" | "experimentalObserverAgents" | "schedulerEnabled" | "schedulerSessionWakeup" | "schedulerSessionLifetime" | "schedulerStorePath" | "planModeEnabled" | "workflowRunStoreBackend" | "workflowOrphanGraceMs" | "workflowJournalRetentionMs" | "workflowRunRetentionMs" | "usageWindows" | "workflowAgentsReadOnly" | "workflowSizeGuideline" | "backgroundAgentRetentionMs" | "backgroundAgentStaleRunningMs" | "backgroundAgentParkClaimStaleMs" | "delegationEntryCaps" | "rosterRetentionMs" | "leaderRunStaleMs" | "scratchpadSweepTtlMs" | "sandboxPkgSource" | "sessionAutoTitle" | "selectEnvironmentTool" | "envFactsEnabled" | "toolDeferLongtail" | "lspEnabled" | "lspHostEnabled" | "imageBakes" | "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude">;
1419
+ export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "worktreeIsolation" | "leaderEnabled" | "leaderFanoutEnabled" | "routerEnabled" | "selfOrchestrationEnabled" | "selfOrchestrationModels" | "selfOrchestrationWorkerIsolation" | "forkEnabled" | "experimentalObserverAgents" | "schedulerEnabled" | "schedulerSessionWakeup" | "schedulerSessionLifetime" | "schedulerStorePath" | "planModeEnabled" | "hooksTimeoutMs" | "workflowRunStoreBackend" | "workflowOrphanGraceMs" | "workflowJournalRetentionMs" | "workflowRunRetentionMs" | "usageWindows" | "workflowAgentsReadOnly" | "workflowSizeGuideline" | "backgroundAgentRetentionMs" | "backgroundAgentStaleRunningMs" | "backgroundAgentParkClaimStaleMs" | "delegationEntryCaps" | "rosterRetentionMs" | "leaderRunStaleMs" | "scratchpadSweepTtlMs" | "sandboxPkgSource" | "sessionAutoTitle" | "selectEnvironmentTool" | "envFactsEnabled" | "toolDeferLongtail" | "lspEnabled" | "lspHostEnabled" | "imageBakes" | "readFace" | "readDenyPatterns" | "readDenyBuiltinTiers" | "readDenyBuiltinExclude">;
1405
1420
  /** 组:limitsHttp(HTTP 面 + 各类上限/配额/回收窗)。 */
1406
1421
  export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "parentPid" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
1407
1422
  /** 组:observability(可观测)。 */
package/dist/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
@@ -9,6 +10,7 @@ import { isV2ScopeKey } from "./memory-scope.js";
9
10
  import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
10
11
  import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js";
11
12
  import { RETENTION_MODES } from "./config-types.js";
13
+ import { normalizeSshFingerprint } from "./ssh-host-key.js";
12
14
  export { RETENTION_MODES } from "./config-types.js";
13
15
  function csv(name) {
14
16
  return (process.env[name] ?? "")
@@ -362,6 +364,35 @@ function headerNameEnv(name, fallback) {
362
364
  throw new Error(`env ${name}="${v}" must be a single header name ([A-Za-z0-9_-]+)`);
363
365
  return v;
364
366
  }
367
+ function parseHooksTimeoutEnv() {
368
+ const raw = process.env.HOOKS_TIMEOUT_MS;
369
+ if (raw === undefined || raw === "")
370
+ return undefined;
371
+ const n = parseNumOrFail("HOOKS_TIMEOUT_MS", raw);
372
+ if (!Number.isInteger(n) || n < 0 || n > 2147483647) {
373
+ throw new Error(`env HOOKS_TIMEOUT_MS="${raw}" must be an integer in [0, 2147483647] ms (0 = every hook seat expires immediately; unset = core's own default)`);
374
+ }
375
+ return n;
376
+ }
377
+ function parseSshHostKeyEnv() {
378
+ const fpRaw = process.env.SSH_HOST_FINGERPRINT;
379
+ const khRaw = process.env.SSH_KNOWN_HOSTS;
380
+ const fingerprintSha256 = fpRaw !== undefined && fpRaw !== "" ? normalizeSshFingerprint(fpRaw) : undefined;
381
+ let knownHostsPath;
382
+ if (khRaw !== undefined && khRaw !== "") {
383
+ try {
384
+ readFileSync(khRaw, "utf8");
385
+ }
386
+ catch (e) {
387
+ throw new Error(`SSH_KNOWN_HOSTS=${JSON.stringify(khRaw)} is not a readable file (${e instanceof Error ? e.message : String(e)}) — ` +
388
+ `refusing to start rather than pretending host-key verification is configured (#296 / C-R21).`);
389
+ }
390
+ knownHostsPath = khRaw;
391
+ }
392
+ if (fingerprintSha256 === undefined && knownHostsPath === undefined)
393
+ return undefined;
394
+ return { ...(fingerprintSha256 !== undefined ? { fingerprintSha256 } : {}), ...(knownHostsPath !== undefined ? { knownHostsPath } : {}) };
395
+ }
365
396
  function normalizeSshPrivateKey(raw) {
366
397
  const v = raw.trim();
367
398
  if (v.includes("BEGIN ") && v.includes("PRIVATE KEY")) {
@@ -416,6 +447,22 @@ export function applyAutoCompactWindow(m, explicit) {
416
447
  delete target.autoCompactTokens;
417
448
  return was1m ? "removed-1m" : "removed";
418
449
  }
450
+ function noticeAutoCompactAboveWindow(slot, envName, m) {
451
+ const act = m.autoCompactTokens;
452
+ const window = m.contextWindow ?? 0;
453
+ if (act === undefined || window <= 0 || act <= window)
454
+ return;
455
+ CONFIG_NOTICES.push({
456
+ event: "auto_compact_tokens_above_context_window",
457
+ fields: {
458
+ slot,
459
+ model: m.id,
460
+ value: String(act),
461
+ contextWindow: String(window),
462
+ note: `${envName}=${act} is ABOVE the ${slot} model "${m.id}" physical context window (${window}) — core uses autoCompactTokens as the TRIGGER-side window, so a value above the physical window means autocompaction effectively never fires and the destructive context guard truncates history instead (the opposite of what this knob is for). The value is applied as configured (not clamped, not rejected); lower it below the window — CC's own shape is window-33000.`,
463
+ },
464
+ });
465
+ }
419
466
  function parseStoreDomain(ctx) {
420
467
  const { requirePrincipal } = ctx;
421
468
  const dbBackendSet = !!process.env.DB_BACKEND;
@@ -577,12 +624,28 @@ function parseModelDomain() {
577
624
  CONFIG_NOTICES.push({
578
625
  event: "auto_compact_window_not_derived_1m",
579
626
  fields: {
627
+ slot: "main",
628
+ model: model.id,
580
629
  note: `model "${model.id}" declares a >=1M context window but no autoCompactTokens — earlier versions implicitly applied 967000 here, which is CC's claude-sonnet-5-only per-model value (A-065 P2-6 rescinded the width-based derivation). Set MODEL_AUTO_COMPACT_TOKENS for a dual-window trigger; without it core uses its plain W-33000 trigger geometry on the full window.`,
581
630
  },
582
631
  });
583
632
  }
584
- if (cheapModel)
585
- applyAutoCompactWindow(cheapModel);
633
+ noticeAutoCompactAboveWindow("main", "MODEL_AUTO_COMPACT_TOKENS", model);
634
+ if (cheapModel) {
635
+ const cheapAct = optFinitePositiveEnv("MODEL_CHEAP_AUTO_COMPACT_TOKENS");
636
+ if (applyAutoCompactWindow(cheapModel, cheapAct) === "removed-1m") {
637
+ const inherited = process.env.MODEL_CHEAP_CONTEXT_WINDOW === undefined || process.env.MODEL_CHEAP_CONTEXT_WINDOW === "";
638
+ CONFIG_NOTICES.push({
639
+ event: "auto_compact_window_not_derived_1m",
640
+ fields: {
641
+ slot: "cheap",
642
+ model: cheapModel.id,
643
+ note: `cheap slot model "${cheapModel.id}" declares a >=1M context window (${cheapModel.contextWindow}${inherited ? ", inherited from the main model — MODEL_CHEAP_CONTEXT_WINDOW is unset" : ""}) but no autoCompactTokens — earlier versions implicitly applied 967000 to this slot too, which is CC's claude-sonnet-5-only per-model value (A-065 P2-6 rescinded the width-based derivation). Set MODEL_CHEAP_AUTO_COMPACT_TOKENS for a dual-window trigger on this slot (MODEL_AUTO_COMPACT_TOKENS is the MAIN slot's knob and is deliberately NOT inherited — it is declared against the main model's id/window); without it core uses its plain W-33000 trigger geometry on the full window for the subagent/summarize roles.`,
644
+ },
645
+ });
646
+ }
647
+ noticeAutoCompactAboveWindow("cheap", "MODEL_CHEAP_AUTO_COMPACT_TOKENS", cheapModel);
648
+ }
586
649
  const models = { default: model, [model.id]: model };
587
650
  if (cheapModel)
588
651
  models[cheapModel.id] = cheapModel;
@@ -1189,6 +1252,7 @@ function parseOrchestrationDomain(ctx) {
1189
1252
  privateKey: normalizeSshPrivateKey(process.env.SSH_PRIVATE_KEY),
1190
1253
  ...((n) => (n !== undefined ? { port: n } : {}))(optNumEnvStrict("SSH_PORT")),
1191
1254
  ...(process.env.SSH_MOUNT_PATH ? { mountPath: process.env.SSH_MOUNT_PATH } : {}),
1255
+ ...((hk) => (hk !== undefined ? { hostKey: hk } : {}))(parseSshHostKeyEnv()),
1192
1256
  }
1193
1257
  :
1194
1258
  process.env.REMOTE_EXEC === "adb" && process.env.ADB_SERIAL
@@ -1244,6 +1308,7 @@ function parseOrchestrationDomain(ctx) {
1244
1308
  experimentalObserverAgents: boolEnv("EXPERIMENTAL_OBSERVER_AGENTS", false),
1245
1309
  schedulerEnabled: postureOn("SCHEDULER_ENABLED"),
1246
1310
  planModeEnabled: boolEnv("PLAN_MODE_ENABLED", true),
1311
+ ...((n) => (n !== undefined ? { hooksTimeoutMs: n } : {}))(parseHooksTimeoutEnv()),
1247
1312
  ...(process.env.SCHEDULER_STORE_PATH ? { schedulerStorePath: process.env.SCHEDULER_STORE_PATH } : {}),
1248
1313
  schedulerSessionWakeup: boolEnv("SCHEDULER_SESSION_WAKEUP", true),
1249
1314
  schedulerSessionLifetime: boolEnv("SCHEDULER_SESSION_LIFETIME", true),
@@ -1514,7 +1579,7 @@ const AUTH_GROUP_KEYS = [
1514
1579
  const ORCHESTRATION_GROUP_KEYS = [
1515
1580
  "remoteExec", "worktreeIsolation", "leaderEnabled", "leaderFanoutEnabled", "routerEnabled", "selfOrchestrationEnabled",
1516
1581
  "selfOrchestrationModels", "selfOrchestrationWorkerIsolation", "forkEnabled", "experimentalObserverAgents",
1517
- "schedulerEnabled", "schedulerSessionWakeup", "schedulerSessionLifetime", "schedulerStorePath", "planModeEnabled", "workflowRunStoreBackend",
1582
+ "schedulerEnabled", "schedulerSessionWakeup", "schedulerSessionLifetime", "schedulerStorePath", "planModeEnabled", "hooksTimeoutMs", "workflowRunStoreBackend",
1518
1583
  "workflowOrphanGraceMs", "workflowJournalRetentionMs", "workflowRunRetentionMs", "usageWindows", "workflowAgentsReadOnly",
1519
1584
  "workflowSizeGuideline", "backgroundAgentRetentionMs", "backgroundAgentStaleRunningMs",
1520
1585
  "backgroundAgentParkClaimStaleMs", "delegationEntryCaps", "rosterRetentionMs", "leaderRunStaleMs", "scratchpadSweepTtlMs", "sandboxPkgSource",
@@ -1,3 +1,4 @@
1
+ import { adjudicateModelRoute, normalizeBaseUrl, resolveRouteCredential, routeRefusalText } from "@sema-agent/core";
1
2
  import { matchCatalogModel } from "../model-select.js";
2
3
  export function createHookLlm(deps) {
3
4
  const { config, metrics } = deps;
@@ -34,6 +35,25 @@ export function createHookLlm(deps) {
34
35
  return shapeFrom(requested, config.model, requested);
35
36
  return { ok: false, error: `hook model "${requested}" is not in this deployment's catalog` };
36
37
  };
38
+ const judgeRoute = (pick, perModelKey, deploymentKey, declaredRoot) => {
39
+ const credential = resolveRouteCredential({
40
+ ...(perModelKey !== undefined ? { optionsApiKey: perModelKey } : {}),
41
+ ...(deploymentKey !== undefined ? { configApiKey: deploymentKey } : {}),
42
+ });
43
+ const verdict = adjudicateModelRoute({ id: pick.catalogRef, baseUrl: pick.baseUrl }, credential, { ...(declaredRoot !== undefined ? { baseUrl: declaredRoot } : {}) });
44
+ if (!verdict.ok) {
45
+ return { ok: false, error: `hook model "${pick.catalogRef}" cannot be routed — ${routeRefusalText(verdict)}` };
46
+ }
47
+ if ((verdict.posture === "unpinned" || verdict.posture === "keyless") && normalizeBaseUrl(pick.baseUrl) !== normalizeBaseUrl(declaredRoot)) {
48
+ return {
49
+ ok: false,
50
+ error: `hook model "${pick.catalogRef}" points at ${pick.baseUrl}, which this deployment did not declare as its own endpoint, ` +
51
+ `and no per-model credential resolved — refusing to send the prompt there (it embeds user content; an unauthenticated ` +
52
+ `request still hands that content to whoever operates that URL)`,
53
+ };
54
+ }
55
+ return { ok: true, credential };
56
+ };
37
57
  const fetchImpl = deps.fetchImpl ?? fetch;
38
58
  const hookLlm = async ({ prompt, model, timeoutMs, system, maxOutputTokens }) => {
39
59
  const pick = hookModelFor(model);
@@ -52,30 +72,20 @@ export function createHookLlm(deps) {
52
72
  return { ok: false, error: `per-model key resolution for "${pick.catalogRef}" failed — refusing to fall back to the shared route credential (${String(e)})` };
53
73
  }
54
74
  }
55
- const normUrl = (u) => {
56
- try {
57
- const x = new URL(u);
58
- return `${x.protocol}//${x.host}${x.pathname.replace(/\/+$/, "")}`;
59
- }
60
- catch {
61
- return u.replace(/\/+$/, "");
62
- }
63
- };
64
75
  let url;
65
76
  let headers;
66
77
  let body;
67
78
  if (pick.api === "anthropic-messages") {
68
79
  const routeBase = anthropicRouteBaseUrl();
69
- const sameRoute = routeBase !== undefined && normUrl(pick.baseUrl) === normUrl(routeBase);
70
- if (!perModelKey && !sameRoute) {
71
- return { ok: false, error: `hook model "${pick.catalogRef}" points at a foreign anthropic baseUrl with no per-model key — refusing to send the prompt off-route` };
72
- }
80
+ const routed = judgeRoute(pick, perModelKey, config.anthropic?.authToken ?? config.anthropic?.apiKey, routeBase);
81
+ if (!routed.ok)
82
+ return routed;
73
83
  url = `${pick.baseUrl.replace(/\/+$/, "")}/v1/messages`;
74
84
  headers = {
75
85
  "content-type": "application/json",
76
86
  "anthropic-version": config.anthropic?.version ?? "2023-06-01",
77
- ...(perModelKey
78
- ? { "x-api-key": perModelKey }
87
+ ...(routed.credential.source === "per-model"
88
+ ? { "x-api-key": routed.credential.apiKey ?? "" }
79
89
  : config.anthropic?.authToken
80
90
  ? { authorization: `Bearer ${config.anthropic.authToken}` }
81
91
  : config.anthropic?.apiKey
@@ -87,11 +97,10 @@ export function createHookLlm(deps) {
87
97
  body = JSON.stringify({ model: pick.providerModelId, max_tokens: maxTokens, ...(system ? { system } : {}), messages: [{ role: "user", content: prompt }] });
88
98
  }
89
99
  else {
90
- const sameGateway = normUrl(pick.baseUrl) === normUrl(config.gatewayBaseUrl);
91
- const hookKey = perModelKey ?? (sameGateway ? config.gatewayApiKey : undefined);
92
- if (!sameGateway && !hookKey) {
93
- return { ok: false, error: `hook model "${pick.catalogRef}" points at a foreign baseUrl with no per-model key — refusing to send the prompt off-gateway` };
94
- }
100
+ const routed = judgeRoute(pick, perModelKey, config.gatewayApiKey, config.gatewayBaseUrl);
101
+ if (!routed.ok)
102
+ return routed;
103
+ const hookKey = routed.credential.apiKey;
95
104
  url = `${pick.baseUrl.replace(/\/+$/, "")}/chat/completions`;
96
105
  headers = { "content-type": "application/json", ...(hookKey ? { authorization: `Bearer ${hookKey}` } : {}) };
97
106
  body = JSON.stringify({ model: pick.providerModelId, ...(maxOutputTokens !== undefined ? { max_tokens: maxOutputTokens } : {}), messages: [...(system ? [{ role: "system", content: system }] : []), { role: "user", content: prompt }], stream: false });
@@ -1035,6 +1035,9 @@ export function composeHooks(deployment, task) {
1035
1035
  const d = deployment;
1036
1036
  const t = task;
1037
1037
  const out = {};
1038
+ const timeoutMs = d.timeoutMs ?? t.timeoutMs;
1039
+ if (timeoutMs !== undefined)
1040
+ out.timeoutMs = timeoutMs;
1038
1041
  if (d.preToolUse || t.preToolUse) {
1039
1042
  const bothObservational = (d.preToolUse === undefined || d.preToolUseObservational === true) &&
1040
1043
  (t.preToolUse === undefined || t.preToolUseObservational === true);
@@ -1,5 +1,7 @@
1
- import { DEFAULT_EFFORT_LEVELS, expandTiers } from "@sema-agent/core";
1
+ import { DEFAULT_EFFORT_LEVELS, expandTiers, routePairingStatus } from "@sema-agent/core";
2
2
  import { isModelAllowlisted } from "../../model-select.js";
3
+ import { resolveModelApiKey } from "../../key-resolver.js";
4
+ import { isSealedKeyPoison } from "../../sealed-key.js";
3
5
  import { cwdHonored } from "../../task-cwd.js";
4
6
  import { a2aInjectionHonored } from "../../task-a2a.js";
5
7
  import { mcpInjectionHonored } from "../../task-mcp.js";
@@ -8,6 +10,12 @@ import { resolveStreamApprovalGate } from "../../tool-approval.js";
8
10
  import { projectMemoryEngineCapability } from "../../memory-posture.js";
9
11
  import { centerEntitlementSourceWired } from "../../runtime-caps-resolver.js";
10
12
  import { gatedPrincipal } from "../principal-gate.js";
13
+ function routePairingOf(deps, name, m) {
14
+ if (isSealedKeyPoison(deps.config.modelApiKeys?.[name]))
15
+ return "unknown";
16
+ const key = resolveModelApiKey(name, deps.config.modelApiKeyEnv ?? {}, process.env, deps.config.modelApiKeys ?? {});
17
+ return routePairingStatus(deps.routeJudgeBrain ?? {}, m, key !== undefined ? { apiKey: key } : undefined);
18
+ }
11
19
  function retentionAdvertisement(config) {
12
20
  const sweep = config.retentionSweep;
13
21
  const policy = config.retentionPolicy;
@@ -146,6 +154,7 @@ async function handleCapabilitiesBody(req, res, url, ctx, miss) {
146
154
  ...(m.maxTokens ? { maxOutputTokens: m.maxTokens } : {}),
147
155
  ...(m.reasoning ? { supportedEffortLevels: [...DEFAULT_EFFORT_LEVELS] } : {}),
148
156
  ...(atAllowlist.length > 0 ? { atMentionable: isModelAllowlisted(name, atCatalog, atAllowlist) } : {}),
157
+ routePairing: routePairingOf(deps, name, m),
149
158
  }));
150
159
  const nonAlias = Object.entries(deps.config.models).filter(([n]) => n !== "default");
151
160
  const defaultName = (nonAlias.find(([, m]) => m === deps.config.model) ?? nonAlias.find(([, m]) => m.id === deps.config.model.id))?.[0];
@@ -1069,6 +1069,10 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1069
1069
  return;
1070
1070
  }
1071
1071
  const { status, body: respBody } = await deps.toolApproval.respond(id, principal, body, req);
1072
+ const retryAfterSec = respBody.retryAfterSec;
1073
+ if (status === 429 && typeof retryAfterSec === "number" && Number.isFinite(retryAfterSec) && retryAfterSec >= 0) {
1074
+ res.setHeader("retry-after", String(Math.ceil(retryAfterSec)));
1075
+ }
1072
1076
  sendJson(res, status, respBody);
1073
1077
  return;
1074
1078
  }
@@ -1,6 +1,6 @@
1
1
  import http from "node:http";
2
2
  import type { IncomingMessage } from "node:http";
3
- import { type Runner, type TaskSpec, type TaskResult, type WorkflowRunStore, type MemoryEntry, type MemoryExportBundle, type MemoryImportReport, type SideQuerySpec, type SideQueryResult } from "@sema-agent/core";
3
+ import { type Runner, type TaskSpec, type TaskResult, type WorkflowRunStore, type MemoryEntry, type MemoryExportBundle, type MemoryImportReport, type SideQuerySpec, type SideQueryResult, type Brain } from "@sema-agent/core";
4
4
  import type { TaskRequestBody } from "./wire-types.js";
5
5
  import type { ServiceConfig } from "../config-types.js";
6
6
  import { type RestartSignal, type SessionMirrorRuling } from "../config-center/facade.js";
@@ -293,6 +293,17 @@ export interface ServiceSeamDeps {
293
293
  * backend). Enables GET /v1/memory/export — present ONLY on a DB memory plane (MEMORY_ENGINE_BACKEND=
294
294
  * pg|tidb); absent / file posture ⇒ 501 (single-user file memory exports by copying the memory dir). */
295
295
  memoryExport?: (scope: string) => Promise<MemoryEntry[]>;
296
+ /**
297
+ * #345(core 5.57.0 `routePairingStatus`)—— **key↔URL 配对读面**:`GET /v1/models` 每行的
298
+ * `routePairing` 标注就是拿这一只判的。main.ts 接的是**装配好的那只 brain 本身**(不是摘出来的
299
+ * 函数引用:A-057.2 那条「裸摘引用丢接收者」的教训同款,且 core 的装饰器族是按 `stream` 的同一条
300
+ * 路再分派 `adjudicateRoute` 的,整只传才不会绕过路由/快切层)。
301
+ *
302
+ * 🔴 缺席 ⇒ 该面每行标 `"unknown"`,**绝不猜**:一只没有 `adjudicateRoute` 面的自定义 brain 是
303
+ * core 明令「callers must treat that as 'no judgment', never as OK or as broken」的形。测试夹具
304
+ * 不接这一件时读面字节逐字不变(标注仍在,值恒 `"unknown"`)。
305
+ */
306
+ routeJudgeBrain?: Pick<Brain, "adjudicateRoute">;
296
307
  /** 142-S2.5(设计 §2.5;裁定 plan 随数据走,core 零运行时半场):one full sync round for one
297
308
  * (scope, peer) — main.ts wires `performMemorySync` over the DB memory backend + the sync-cursor
298
309
  * store. Enables POST /v1/memory/sync/:scope — present ONLY on a DB memory plane
@@ -1277,7 +1277,7 @@ export function createHttpServer(rawDeps) {
1277
1277
  await appendPromptManifest(ap, deps.promptManifests, taskId);
1278
1278
  if (reSuspended || vrReopened) {
1279
1279
  await appendModelUsageDelta(ap, deps.modelUsage, taskId);
1280
- const vrParkCallId = reSuspended ? await parkToolCallId(vr, deps.checkpointStore) : undefined;
1280
+ const vrParkCallId = reSuspended ? await parkToolCallId({ ...vr, status: reParkStatus }, deps.checkpointStore) : undefined;
1281
1281
  await ap(reSuspended ? reParkStatus : "suspended", { gate: vr.checkpointGate ?? null, ...(vrParkCallId !== undefined ? { toolCallId: vrParkCallId } : {}), ...(vrReopened ? { reopened: vr.errorCode ?? null } : {}) });
1282
1282
  }
1283
1283
  else {
@@ -1532,9 +1532,12 @@ export function createHttpServer(rawDeps) {
1532
1532
  ...(legDeadlineMonotonic !== undefined ? { legDeadlineMonotonic } : {}),
1533
1533
  }, driveResume)
1534
1534
  : driveResume();
1535
- return await (deps.elicitation && taskId
1535
+ const withElicitation = () => deps.elicitation && taskId
1536
1536
  ? deps.elicitation.runWithContext({ taskId, owner: principal ?? null, emit: (frame) => { const { type, ...rest } = frame; return append(type, rest); }, abortSignal: cancelCtrl.signal }, withApproval)
1537
- : withApproval());
1537
+ : withApproval();
1538
+ return await (deps.sendUserFile && taskId
1539
+ ? deps.sendUserFile.runWithContext({ taskId, emit: async (frame) => { const { type, ...rest } = frame; await append(type, rest); } }, withElicitation)
1540
+ : withElicitation());
1538
1541
  });
1539
1542
  }
1540
1543
  catch (e) {
@@ -61,7 +61,9 @@ export async function mergeBranches(reports, baseSha, deps) {
61
61
  deps.logger?.warn?.("integration_repair_error", { round: round + 1, err: e instanceof Error ? e.message : String(e) });
62
62
  break;
63
63
  }
64
+ repairMeta = { rounds: round + 1, fixed: false, costUsd: cost };
64
65
  test = await env.exec(deps.testCmd, { cwd: repo });
66
+ repairMeta = { rounds: round + 1, fixed: test.exitCode === 0, costUsd: cost };
65
67
  }
66
68
  repairMeta = { rounds: round, fixed: test.exitCode === 0, costUsd: cost };
67
69
  deps.logger?.info?.("integration_repair_done", { ...repairMeta });
@@ -88,7 +90,7 @@ export async function mergeBranches(reports, baseSha, deps) {
88
90
  if (!integrated.stdout.trim()) {
89
91
  return { ok: false, phase: "push", reason: "integrated patch is empty (nothing applied over base) — refusing to push", quarantine: mergeable.map((r) => r.workerId), ...costTail };
90
92
  }
91
- const evald = await evaluatePushHold(env, repo, mergeable, measure, integrated.stdout, deps);
93
+ const evald = await evaluatePushHold(mergeable, measure, integrated.stdout, deps);
92
94
  if (evald.measure !== undefined)
93
95
  measure = evald.measure;
94
96
  if (evald.hold) {
@@ -126,7 +128,7 @@ export async function mergeBranches(reports, baseSha, deps) {
126
128
  await destroy().catch(() => { });
127
129
  }
128
130
  }
129
- async function evaluatePushHold(env, repo, mergeable, observeMeasure, integratedPatch, deps) {
131
+ async function evaluatePushHold(mergeable, observeMeasure, integratedPatch, deps) {
130
132
  const heldCandidate = () => delimitUntrusted("CANDIDATE PATCH (untrusted worker code)", integratedPatch);
131
133
  const carried = mergeable.map((r) => r.repairTerminal).filter((t) => t !== undefined && t !== "fixed");
132
134
  if (carried.length > 0) {
package/dist/main.js CHANGED
@@ -136,7 +136,10 @@ async function main() {
136
136
  const toolTracer = config.toolTrace ? createToolTracer(logger) : undefined;
137
137
  if (toolTracer)
138
138
  logger.info("tool_trace_enabled", {});
139
- const deploymentHooks = toolTracer ? composeHooks(createPermissionDeniedMeter(metrics), toolTracer) : createPermissionDeniedMeter(metrics);
139
+ const deploymentHooks = {
140
+ ...(toolTracer ? composeHooks(createPermissionDeniedMeter(metrics), toolTracer) : createPermissionDeniedMeter(metrics)),
141
+ ...(config.hooksTimeoutMs !== undefined ? { timeoutMs: config.hooksTimeoutMs } : {}),
142
+ };
140
143
  const { sqlWorkflowRunStore, workflowNotifyJournal, workflowCompletionInbox, deliverWorkflowCompletion, workflowNotifyGate, fleetBus, workflowRunStore, workflowJournalStore, outcomeSink, workflowRecoverOpts, workflowAgentRegistry, subagentSteerRegistry, } = createWorkflowOrchestration({ config, logger, metrics, localRoot, backend, getRunStore: () => runStore });
141
144
  let parkedAskRedeem;
142
145
  const { elicitation, question, toolApproval, durableEnabled, streamApprovalGate, sendUserFileEmitter, sendFileLedger, sendUserFileToolSpec } = createLiveCoordinators({
@@ -533,6 +536,7 @@ async function main() {
533
536
  };
534
537
  const seams = {
535
538
  sessionAudit,
539
+ routeJudgeBrain: brain,
536
540
  purgeSession: purgeSession ? purgeSession : undefined,
537
541
  subagentTaskOutput: (handle, access) => backgroundAgentOutput(defaultTaskRegistry, handle, access, backgroundAgentStore),
538
542
  taskHandleOutput: (handle, access) => taskHandleOutput(defaultTaskRegistry, handle, access, backgroundAgentStore),
@@ -723,7 +727,7 @@ async function main() {
723
727
  else {
724
728
  resolveFleetRecovery();
725
729
  }
726
- installShutdownHandlers({
730
+ void installShutdownHandlers({
727
731
  config, logger, server, reaper, fleetReconcile, otelExporter, breakerState, costQuota, rateLimiter,
728
732
  version: serviceVersion(),
729
733
  runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState,
@@ -154,15 +154,28 @@ export function parseMemorySyncRequest(body, scope) {
154
154
  function baselineStub(id, rev, scope, slug) {
155
155
  return { id, rev, scope, slug: slug ?? `sync-baseline-stub/${id}`, frontmatter: {}, body: "" };
156
156
  }
157
+ function malformedEntryReason(err) {
158
+ const msg = err instanceof Error ? err.message : String(err);
159
+ return `malformed_entry: core cannot read this entry's frontmatter shape (${msg.slice(0, 200)}) — the entry is refused and never stored; fix the frontmatter (a present \`distilled\` block needs planId/at/carrierRev/inputs[]) and resend`;
160
+ }
157
161
  export async function performMemorySync(backend, cursors, scope, req, now = Date.now) {
158
162
  const conflicts = [];
159
163
  const rejected = new Map();
164
+ const malformed = new Set();
160
165
  for (const e of req.entries) {
161
- const computed = computeEntryRev(e);
166
+ let computed;
167
+ try {
168
+ computed = computeEntryRev(e);
169
+ }
170
+ catch (err) {
171
+ malformed.add(e.id);
172
+ rejected.set(e.id, malformedEntryReason(err));
173
+ continue;
174
+ }
162
175
  if (computed !== e.rev)
163
176
  rejected.set(e.id, `rev_mismatch: claimed ${JSON.stringify(e.rev)} but the content hashes to ${JSON.stringify(computed)} (rev is core's computeEntryRev content-hash contract)`);
164
177
  }
165
- for (const f of screenInboundEntries(req.entries)) {
178
+ for (const f of screenInboundEntries(malformed.size === 0 ? req.entries : req.entries.filter((e) => !malformed.has(e.id)))) {
166
179
  const gate = `inbound_gate: ${f.findings.map((x) => `${x.code}: ${x.reason}`).join("; ")}`;
167
180
  const prior = rejected.get(f.id);
168
181
  rejected.set(f.id, prior !== undefined ? `${prior}; ${gate}` : gate);
@@ -121,9 +121,17 @@ export declare const FAIL_OPEN_TAGS: {
121
121
  readonly cls: "F";
122
122
  readonly note: "[4913]:durable park 投影腿在 strip 之前拿结果里的 checkpointToken 换 `pendingAction.toolCallId`(cs.get 一次读),那次读**抛了**(store 抖动 / 滚动升级期 checkpoint 格式 version guard / 行已被并发消费)⇒ 本次 park 的 wire 投影**缺 `toolCallId` 键**,其余键(gate/checkpointId)照旧。放行的最坏后果 = 消费方(cli 判别子 v3 写者②)退回该键到货前的行为——同族多兄弟 durable park 分不出主角、诚实全不标(cli 侧 S3 局限,成文的旧现状),纯展示/关联面,不参与任何门/CAS/resume 判定,故 F 类。绝不挡终局路径是承重的:park 投影点全在 done/suspended 事件写链上,让它抛会把一次 store 抖动升级成整条 run 终局写失败。必须留痕:键缺席与「tool-less park 本就无键」([1995]② OMITTED 契约)在 wire 上同形,不计数则「投影为什么总缺」在遥测里永不显形。";
123
123
  };
124
- readonly "server.model-key.off-route-model-uses-gateway-key": {
124
+ readonly "server.model-key.degrade-hop-borrows-gateway-key": {
125
125
  readonly cls: "P-DEBT";
126
- readonly note: "A-057.59:一只 **baseUrl 指向别家主机**(非本部署 `gatewayBaseUrl`)的目录模型**没有** per-model key `createPerModelAuthSeat` `undefined`(side-query = core 5.46.0 `SideQuerySpec.getApiKeyAndHeaders` 座位;WebFetch 摘要面 = 同一只座位包在 `createPerModelKeyBrain` 里,那面 core 至今无座位)⇒ core `options?.apiKey ?? config.apiKey` 回落**主网关 key**,而同一行下面的 `model.baseUrl || config.baseUrl` 仍把请求路由到那台外部主机 —— 这一次调用真把共享网关凭据发给了运维在 config-center 里指定的另一家。方向明知不对(hook 面对逐字同形的场景是 TRUE fail-closed:「refusing to send the prompt off-gateway」),之所以记债而不是当场收严:该缺席语义与**主推理链**(`dist/engine/harness/agent-harness.js` `auth?.apiKey !== undefined` 臂)逐字同语义,只收严 wrapper 这两面会造成「同一只模型跑任务能用、问一句 401」,且「同账号多网关主机/多区域」是既有且正当的部署形、无旋钮可退。计数覆盖两面(side-query / WebFetch 摘要),`detail` = 模型名。⚠️ 判据需要参照系:调用方没给 `gatewayBaseUrl` **不计**(宁可漏计不可错计),故计数是**下限**。终局收口 = 跨面收严件 #309(三面同批 + 旋钮 + 表态制),届时本 tag 同批销。";
126
+ readonly note: "[4998] BROKEN 实锤的**留痕半场**(#345 codex 对抗复审 R1-[high] 的可采纳部分,亲验落实)。`brain.ts` 的反应式降级 hop 在降级目标**没有自己的凭据**时回落主网关 key(`own ?? config.gatewayApiKey`),而那只 fallback brain 是用 `fallbackModel.baseUrl` `config.baseUrl` 造的 —— 于是 core 5.57.0 的配对裁决器看到的是「部署凭据 + 它自己的声明根」⇒ `paired` 照发。🔴 **上游的法在这条路上结构性看不见问题**:不是 core 漏判,是**本仓把一个撒谎的 config 递给了它**(把主网关的 key 与降级目标的根说成一对)。真读数:`test/brain.test.ts` 的「既有腿不回归」格实测 `Bearer primary-gateway-key` 打到 `http://degrade-gw.test/v1/chat/completions`。为什么记债而不是本批修:正解要么是「off-root 且无自 key 降级链按不可用后退」(改的是**可用性**轴,会让一类既有部署的降级当场失效),要么是「把目标自己的凭据改走 per-model 位(`Model.headers`)让 core 判得见」(改核心装配 + 既有钉字节),两者都是独立设计件、不是提货批的尾巴。判据 = 解不出目标自己的 key 目标 baseUrl 既不是 `gatewayBaseUrl` 也不在 `gatewayFallbackUrls`(后者按同文件成文契约 = 同一逻辑网关/同一 key 有效域,不算换家),归一用 core `normalizeBaseUrl`(与本仓其它配对判定同一只)。⚠️ 与已撤线的 `off-route-model-uses-gateway-key` **不是同一条路**:那条的铸点是 side-query/WebFetch 的 per-model auth 座,而座位恒以**被请求的那只**模型被调用、位置在装饰器栈**之外** —— 它从来没有、也不可能观察到降级 hop 换上的 `fallbackModel`。所以这不是「撤早了」,是一条**一直没人看**的姊妹路。";
127
+ };
128
+ readonly "server.exec-lane.ssh-host-key-unverified": {
129
+ readonly cls: "P-DEBT";
130
+ readonly note: "#296(A-052.2,clay 裁定 C-R21):`REMOTE_EXEC=ssh` 且**两只主机密钥旋钮都缺席**(`SSH_HOST_FINGERPRINT` / `SSH_KNOWN_HOSTS`)⇒ 本次连接接受**任何**主机密钥,等价 `ssh -o StrictHostKeyChecking=no`。放行的最坏后果:一次中间人拿到这条会话的**全部命令、全部产物、以及 agent 在那台机器上的全部动作**(私钥仍在控制面手里、拿不走,design/61 §5 #2)。⇒ 保护型欠账,不是体验回退。**方向是裁定的、不是遗漏**:本 lane 的既有部署形是「AI 编排批量部署到一批 SSH 可达的机器」,那些机器的指纹通常不在任何 known_hosts 里,默认拒连 = 一次静默的可用性断裂;C-R21 裁「在场即严格,缺席即响亮」。补偿三件:①**每连接**一条 `ssh_host_key_unverified` warn(不是每进程一次 —— 一条长期裸连的 lane 必须在每条连接上都看得见);②本计数(遥测里「有多少条连接是裸连」是一个可观测的数,曲线应当只降不升);③两旋钮任一在场即**严格校验、不符拒连**(拒因带两半指纹 + `ssh-keyscan` 指路)。终局 = 部署把旋钮配上(那时本计数归零);旋钮不配而想收紧的部署级开关(「无旋钮即拒启」)是下一件,不在 C-R21 射程。";
131
+ };
132
+ readonly "server.approval.park-tombstone-global-cap-refused": {
133
+ readonly cls: "F";
134
+ readonly note: "#343(黑板 [5100] Q3):park 墓碑表(#329 迟到回决的 wire approvalId → 店内坐标指路条)**全局帽已满、而本 principal 还没到自己的 per-principal 子帽** ⇒ 这条新墓碑**不落**(拒新,一格都不挤别人)。放行的最坏后果 = 这一只 ask 的 **live 腿指路便利**缺席,壳按 Yes 拿到的是 404 `unknown_or_other_replica` —— 也就是 #329 之前的那一格逐字行为,既不会错兑也不会双兑;**完整兑付面一个字节没动**(park 行本体在 durable 店未失,`GET /v1/approvals` 列表 + `/v1/approvals/:sessionId/decide` 仍可回决),故 F 类而不是保护型。为什么方向是「拒新」而不是修前的「FIFO 挤最旧」:那个形让**甲的用量决定乙的可用性**(已鉴权租户量产 park 终局 ask 即可把别家墓碑全部挤出,merge-rescan 744 confirmed),而 park 是常态路由、不需要任何异常手法。必须留痕:「表被别人打满」与「这张卡本来就没有墓碑」在 wire 上完全同形,不计数则运维只能靠「用户说按 Yes 老是 404」这种间接症状发现,而那正是 [4845] 排障代价实证过的那条路。detail 带 owner(判「压力来自谁」的第一手线索)。本 tag 有**两条臂、两个根因**,detail 判别:(a) 全局帽满且本 principal 未超子帽 ⇒ 拒新(detail 带 owner);(b) 本 principal 超子帽但名下条目**全在飞**、无一可逐 ⇒ 拒新(detail 带 `reason=all-inflight`)。超子帽且**有**可逐条目的常态臂逐自己最旧的一条(量产者自伤,设计内有界表纪律)才不走本 tag。";
127
135
  };
128
136
  };
129
137
  /** 词表键推导的闭集类型——未登记的 tag 传不进 {@link recordFailOpen}(编译期拒)。 */
@@ -112,9 +112,17 @@ export const FAIL_OPEN_TAGS = {
112
112
  cls: "F",
113
113
  note: "[4913]:durable park 投影腿在 strip 之前拿结果里的 checkpointToken 换 `pendingAction.toolCallId`(cs.get 一次读),那次读**抛了**(store 抖动 / 滚动升级期 checkpoint 格式 version guard / 行已被并发消费)⇒ 本次 park 的 wire 投影**缺 `toolCallId` 键**,其余键(gate/checkpointId)照旧。放行的最坏后果 = 消费方(cli 判别子 v3 写者②)退回该键到货前的行为——同族多兄弟 durable park 分不出主角、诚实全不标(cli 侧 S3 局限,成文的旧现状),纯展示/关联面,不参与任何门/CAS/resume 判定,故 F 类。绝不挡终局路径是承重的:park 投影点全在 done/suspended 事件写链上,让它抛会把一次 store 抖动升级成整条 run 终局写失败。必须留痕:键缺席与「tool-less park 本就无键」([1995]② OMITTED 契约)在 wire 上同形,不计数则「投影为什么总缺」在遥测里永不显形。",
114
114
  },
115
- "server.model-key.off-route-model-uses-gateway-key": {
115
+ "server.model-key.degrade-hop-borrows-gateway-key": {
116
116
  cls: "P-DEBT",
117
- note: "A-057.59:一只 **baseUrl 指向别家主机**(非本部署 `gatewayBaseUrl`)的目录模型**没有** per-model key `createPerModelAuthSeat` `undefined`(side-query = core 5.46.0 `SideQuerySpec.getApiKeyAndHeaders` 座位;WebFetch 摘要面 = 同一只座位包在 `createPerModelKeyBrain` 里,那面 core 至今无座位)⇒ core `options?.apiKey ?? config.apiKey` 回落**主网关 key**,而同一行下面的 `model.baseUrl || config.baseUrl` 仍把请求路由到那台外部主机 —— 这一次调用真把共享网关凭据发给了运维在 config-center 里指定的另一家。方向明知不对(hook 面对逐字同形的场景是 TRUE fail-closed:「refusing to send the prompt off-gateway」),之所以记债而不是当场收严:该缺席语义与**主推理链**(`dist/engine/harness/agent-harness.js` `auth?.apiKey !== undefined` 臂)逐字同语义,只收严 wrapper 这两面会造成「同一只模型跑任务能用、问一句 401」,且「同账号多网关主机/多区域」是既有且正当的部署形、无旋钮可退。计数覆盖两面(side-query / WebFetch 摘要),`detail` = 模型名。⚠️ 判据需要参照系:调用方没给 `gatewayBaseUrl` **不计**(宁可漏计不可错计),故计数是**下限**。终局收口 = 跨面收严件 #309(三面同批 + 旋钮 + 表态制),届时本 tag 同批销。",
117
+ note: "[4998] BROKEN 实锤的**留痕半场**(#345 codex 对抗复审 R1-[high] 的可采纳部分,亲验落实)。`brain.ts` 的反应式降级 hop 在降级目标**没有自己的凭据**时回落主网关 key(`own ?? config.gatewayApiKey`),而那只 fallback brain 是用 `fallbackModel.baseUrl` `config.baseUrl` 造的 —— 于是 core 5.57.0 的配对裁决器看到的是「部署凭据 + 它自己的声明根」⇒ `paired` 照发。🔴 **上游的法在这条路上结构性看不见问题**:不是 core 漏判,是**本仓把一个撒谎的 config 递给了它**(把主网关的 key 与降级目标的根说成一对)。真读数:`test/brain.test.ts` 的「既有腿不回归」格实测 `Bearer primary-gateway-key` 打到 `http://degrade-gw.test/v1/chat/completions`。为什么记债而不是本批修:正解要么是「off-root 且无自 key 降级链按不可用后退」(改的是**可用性**轴,会让一类既有部署的降级当场失效),要么是「把目标自己的凭据改走 per-model 位(`Model.headers`)让 core 判得见」(改核心装配 + 既有钉字节),两者都是独立设计件、不是提货批的尾巴。判据 = 解不出目标自己的 key 目标 baseUrl 既不是 `gatewayBaseUrl` 也不在 `gatewayFallbackUrls`(后者按同文件成文契约 = 同一逻辑网关/同一 key 有效域,不算换家),归一用 core `normalizeBaseUrl`(与本仓其它配对判定同一只)。⚠️ 与已撤线的 `off-route-model-uses-gateway-key` **不是同一条路**:那条的铸点是 side-query/WebFetch 的 per-model auth 座,而座位恒以**被请求的那只**模型被调用、位置在装饰器栈**之外** —— 它从来没有、也不可能观察到降级 hop 换上的 `fallbackModel`。所以这不是「撤早了」,是一条**一直没人看**的姊妹路。",
118
+ },
119
+ "server.exec-lane.ssh-host-key-unverified": {
120
+ cls: "P-DEBT",
121
+ note: "#296(A-052.2,clay 裁定 C-R21):`REMOTE_EXEC=ssh` 且**两只主机密钥旋钮都缺席**(`SSH_HOST_FINGERPRINT` / `SSH_KNOWN_HOSTS`)⇒ 本次连接接受**任何**主机密钥,等价 `ssh -o StrictHostKeyChecking=no`。放行的最坏后果:一次中间人拿到这条会话的**全部命令、全部产物、以及 agent 在那台机器上的全部动作**(私钥仍在控制面手里、拿不走,design/61 §5 #2)。⇒ 保护型欠账,不是体验回退。**方向是裁定的、不是遗漏**:本 lane 的既有部署形是「AI 编排批量部署到一批 SSH 可达的机器」,那些机器的指纹通常不在任何 known_hosts 里,默认拒连 = 一次静默的可用性断裂;C-R21 裁「在场即严格,缺席即响亮」。补偿三件:①**每连接**一条 `ssh_host_key_unverified` warn(不是每进程一次 —— 一条长期裸连的 lane 必须在每条连接上都看得见);②本计数(遥测里「有多少条连接是裸连」是一个可观测的数,曲线应当只降不升);③两旋钮任一在场即**严格校验、不符拒连**(拒因带两半指纹 + `ssh-keyscan` 指路)。终局 = 部署把旋钮配上(那时本计数归零);旋钮不配而想收紧的部署级开关(「无旋钮即拒启」)是下一件,不在 C-R21 射程。",
122
+ },
123
+ "server.approval.park-tombstone-global-cap-refused": {
124
+ cls: "F",
125
+ note: "#343(黑板 [5100] Q3):park 墓碑表(#329 迟到回决的 wire approvalId → 店内坐标指路条)**全局帽已满、而本 principal 还没到自己的 per-principal 子帽** ⇒ 这条新墓碑**不落**(拒新,一格都不挤别人)。放行的最坏后果 = 这一只 ask 的 **live 腿指路便利**缺席,壳按 Yes 拿到的是 404 `unknown_or_other_replica` —— 也就是 #329 之前的那一格逐字行为,既不会错兑也不会双兑;**完整兑付面一个字节没动**(park 行本体在 durable 店未失,`GET /v1/approvals` 列表 + `/v1/approvals/:sessionId/decide` 仍可回决),故 F 类而不是保护型。为什么方向是「拒新」而不是修前的「FIFO 挤最旧」:那个形让**甲的用量决定乙的可用性**(已鉴权租户量产 park 终局 ask 即可把别家墓碑全部挤出,merge-rescan 744 confirmed),而 park 是常态路由、不需要任何异常手法。必须留痕:「表被别人打满」与「这张卡本来就没有墓碑」在 wire 上完全同形,不计数则运维只能靠「用户说按 Yes 老是 404」这种间接症状发现,而那正是 [4845] 排障代价实证过的那条路。detail 带 owner(判「压力来自谁」的第一手线索)。本 tag 有**两条臂、两个根因**,detail 判别:(a) 全局帽满且本 principal 未超子帽 ⇒ 拒新(detail 带 owner);(b) 本 principal 超子帽但名下条目**全在飞**、无一可逐 ⇒ 拒新(detail 带 `reason=all-inflight`)。超子帽且**有**可逐条目的常态臂逐自己最旧的一条(量产者自伤,设计内有界表纪律)才不走本 tag。",
118
126
  },
119
127
  };
120
128
  export function failOpenTagForDroppedFrame(frameType) {
@@ -62,6 +62,35 @@ export interface HardenedVmLimits {
62
62
  * A script exceeding it OOMs the WORKER (killed), never the shared main process. Default 128. */
63
63
  maxHeapMb?: number;
64
64
  }
65
+ /**
66
+ * The realm test itself, exported so there is exactly ONE ruler for "did this rejection come from an untrusted
67
+ * script?" in the process. A promise minted INSIDE a vm context fails `instanceof` against the HOST `Promise`
68
+ * (different intrinsic per realm) — that is the whole judgement.
69
+ *
70
+ * 🔴 Why it is exported (A-070 二轮重扫, lifecycle-pairing, double-opus CONFIRMED): the guard below is NOT the
71
+ * only `unhandledRejection` listener in a real boot. `src/boot/shutdown.ts` installs one at boot (#320① crash
72
+ * last words) that exits(1); Node walks EVERY listener for the event, so containment cannot be expressed by
73
+ * one listener returning early — whoever exits wins regardless of registration order. The containment DECISION
74
+ * therefore lives in the boot handler, and it must use this same ruler rather than minting a second one.
75
+ */
76
+ export declare function isScriptRealmRejection(promise: unknown): boolean;
77
+ /**
78
+ * Summarize an unhandled rejection reason for a log line WITHOUT running anything the (untrusted) script
79
+ * controls. Both rejection listeners in this process use it.
80
+ *
81
+ * 🔴 Why not `String(reason)` / `${reason}` / `reason.message` (codex adversarial-review F1, REPRODUCED):
82
+ * the reason of a script-realm rejection is a LIVE object from the vm context, and every readout path is
83
+ * script-controlled code running in the HOST turn with no vm timeout over it:
84
+ * · `String(x)` / template literals invoke `Symbol.toPrimitive` / `toString` / `valueOf`;
85
+ * · a property read invokes a getter, and on a Proxy even `getOwnPropertyDescriptor` is a trap.
86
+ * `Promise.reject({ toString() { throw ... } })` therefore threw OUT of the guard, became an
87
+ * `uncaughtException` and exited(1) — i.e. an untrusted script could escape containment and kill the process
88
+ * by choosing its rejection value; the loop variant (`toString(){ for(;;){} }`) wedges the event loop instead.
89
+ * So: PRIMITIVES only (their coercion cannot run user code), everything else gets a fixed placeholder. The
90
+ * cost is honest and stated — an operator sees "a script rejection was contained", not its message; the
91
+ * message channel for a rejection nobody awaited does not exist anyway.
92
+ */
93
+ export declare function describeRejectionReason(reason: unknown): string;
65
94
  export declare function installScriptRealmRejectionGuard(): void;
66
95
  /**
67
96
  * The membrane handed to a surface's `buildGlobals` callback to construct the CONTEXT-LOCAL bindings the