@sema-agent/server 7.113.0 → 7.115.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 (87) hide show
  1. package/MIGRATION.md +109 -2
  2. package/USAGE.md +36 -3
  3. package/deploy/sema-up/cluster-up.sh +13 -1
  4. package/deploy/sema-up/kube-up.sh +13 -1
  5. package/deploy/sema-up/preflight.sh +13 -1
  6. package/deploy/sema-up/sema-up.sh +13 -1
  7. package/deploy/sema-up/smoke.sh +13 -1
  8. package/dist/adoption/wire.d.ts +0 -3
  9. package/dist/approval-card.d.ts +21 -24
  10. package/dist/approval-card.js +12 -0
  11. package/dist/approval.js +5 -2
  12. package/dist/boot/exit-boundary.d.ts +3 -2
  13. package/dist/boot/exit-boundary.js +3 -0
  14. package/dist/boot/memory-boundary.d.ts +2 -1
  15. package/dist/boot/memory-consolidation.d.ts +1 -1
  16. package/dist/boot/resolve-spec.js +6 -3
  17. package/dist/boot/shutdown.d.ts +19 -0
  18. package/dist/boot/shutdown.js +12 -1
  19. package/dist/capabilities/composite-members.d.ts +1 -0
  20. package/dist/capabilities/composite-members.js +1 -0
  21. package/dist/config-catalog.js +8 -6
  22. package/dist/config-center/apply-effective.js +11 -11
  23. package/dist/config-invariants.d.ts +27 -0
  24. package/dist/config-invariants.js +54 -39
  25. package/dist/config-provider.d.ts +16 -0
  26. package/dist/config-provider.js +11 -2
  27. package/dist/config-types.d.ts +24 -11
  28. package/dist/config-types.js +0 -1
  29. package/dist/config.d.ts +41 -34
  30. package/dist/config.js +78 -81
  31. package/dist/deployment-governance.d.ts +15 -4
  32. package/dist/deployment-governance.js +20 -2
  33. package/dist/env-facts.d.ts +3 -1
  34. package/dist/env-facts.js +5 -1
  35. package/dist/execution-lane-caps.d.ts +54 -30
  36. package/dist/execution-lane-caps.js +48 -20
  37. package/dist/fleet/fleet-bus.d.ts +1 -22
  38. package/dist/hooks/hook-runner.d.ts +112 -6
  39. package/dist/hooks/hook-runner.js +138 -32
  40. package/dist/hosted-posture.d.ts +2 -2
  41. package/dist/http/admission.js +9 -4
  42. package/dist/http/approval-sweeps.js +20 -0
  43. package/dist/http/checkpoint-ctx.d.ts +68 -0
  44. package/dist/http/checkpoint-ctx.js +36 -0
  45. package/dist/http/durable-run-leg.js +2 -1
  46. package/dist/http/resume-legs.js +9 -0
  47. package/dist/http/route-ctx.d.ts +7 -0
  48. package/dist/http/routes/a2a-serve.d.ts +1 -3
  49. package/dist/http/routes/a2a-serve.js +0 -2
  50. package/dist/http/routes/admin-memory-optout.d.ts +0 -2
  51. package/dist/http/routes/admin-memory-optout.js +0 -1
  52. package/dist/http/routes/capabilities.js +3 -2
  53. package/dist/http/routes/diagnostics.d.ts +5 -0
  54. package/dist/http/routes/diagnostics.js +2 -0
  55. package/dist/http/routes/sessions.js +1 -1
  56. package/dist/http/routes/tasks.js +3 -2
  57. package/dist/index.d.ts +1 -1
  58. package/dist/leader/wire.js +2 -1
  59. package/dist/local-data-root.d.ts +1 -1
  60. package/dist/mcp-content-origin.d.ts +0 -8
  61. package/dist/model-entry-refusal.d.ts +10 -2
  62. package/dist/model-entry-refusal.js +19 -3
  63. package/dist/observability/corrupt-read-seat.d.ts +3 -3
  64. package/dist/observability/corrupt-read-seat.js +5 -4
  65. package/dist/observability/fail-open.d.ts +10 -9
  66. package/dist/observability/fail-open.js +5 -1
  67. package/dist/permission-rule-list.d.ts +81 -0
  68. package/dist/permission-rule-list.js +17 -0
  69. package/dist/plugins/remote-env-host.d.ts +0 -35
  70. package/dist/plugins/remote-env-host.js +0 -8
  71. package/dist/plugins/remote-env-local-docker.d.ts +5 -13
  72. package/dist/plugins/remote-env-local-docker.js +2 -4
  73. package/dist/run-local.js +4 -1
  74. package/dist/runtime-governance.d.ts +21 -1
  75. package/dist/runtime-governance.js +6 -1
  76. package/dist/security.js +2 -2
  77. package/dist/session-policy-wire.d.ts +5 -4
  78. package/dist/store-posture.d.ts +7 -7
  79. package/dist/store-posture.js +0 -1
  80. package/dist/task-settings.d.ts +19 -5
  81. package/dist/task-settings.js +19 -30
  82. package/dist/tool-approval.d.ts +16 -4
  83. package/dist/tool-approval.js +2 -1
  84. package/dist/trace/core-keyset-guard.d.ts +9 -4
  85. package/dist/trace/project.js +9 -0
  86. package/package.json +4 -4
  87. package/scripts/{retired-mode-seed-drain-local.mjs → drain-check-local.mjs} +147 -26
@@ -160,7 +160,7 @@ export interface ConsolidationWiringFacts {
160
160
  /** 记忆后端自带控制面归属吗(`typeof backend.controlPlaneRoot === "string"` 且非空)。 */
161
161
  readonly controlPlaneOwned: boolean;
162
162
  /** 只进文案 —— 让「该去拧哪根旋钮」这句话指向该腿上**真的**有效的那一根。 */
163
- readonly memoryEngineBackend: "file" | "pg" | "tidb";
163
+ readonly memoryEngineBackend: ServiceConfig["memoryEngineBackend"];
164
164
  /** 部署声明的 provenance 姿态(`config.memoryProvenance`;缺席 ≡ core 缺省 `carry`)。 */
165
165
  readonly provenance?: "off" | "carry";
166
166
  }
@@ -9,7 +9,7 @@ import { gateScenarioRequest, mergeUserSkills, selectScenario } from "../capabil
9
9
  import { stampHandsLane, toolPolicyForHands } from "../capabilities/hands-lane.js";
10
10
  import { buildNoticeLaneFacts, shouldDiscloseNoPersistentMemory, withNoPersistentMemoryNotice } from "../capabilities/memory-notice.js";
11
11
  import { applyLongtailDefer } from "../capabilities/tool-defer.js";
12
- import { assertGuardPatternsUsable, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs } from "../deployment-governance.js";
12
+ import { assertGuardPatternsUsable, buildApprovalGrantNotice, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs } from "../deployment-governance.js";
13
13
  import { singleUserAutoAcceptBaselineApplies } from "../approval.js";
14
14
  import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScratchpadDir, resumeFactsForLane, scratchpadCoveringTaskRoot } from "../env-facts.js";
15
15
  import { FleetEventBus } from "../fleet/fleet-bus.js";
@@ -34,7 +34,7 @@ import { normalizeAttachments, normalizeResilience, normalizeResumeAtMode, norma
34
34
  import { cwdHonored, deviceCwdHonored, effectiveHostWorkspace, inProcessSingleUserLane, isValidCwd, parseAdditionalDirectories, satisfiedByProcessCwd, shellEnvMismatchCount } from "../task-cwd.js";
35
35
  import { assertRequestA2aUnlocked, resolveRequestA2a } from "../task-a2a.js";
36
36
  import { assertRequestMcpContentOrigin, assertRequestMcpUnlocked, resolveRequestMcp } from "../task-mcp.js";
37
- import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, effectivePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, providerDropsAppend, settingsPermissionBucketIssue, withPermissionMode } from "../task-settings.js";
37
+ import { MAX_SETTINGS_OUTPUT_STYLE_CHARS, acceptAppendSystemPrompt, applyTaskSettings, effectivePermissionMode, effectiveThinking, hasConstitutionAnchors, parseTaskSettings, taskHooksHonored, providerDropsAppend, settingsPermissionBucketIssue, withPermissionMode } from "../task-settings.js";
38
38
  import { enableForkFromBody, normalizeRetainSubagentSessions, selfOrchestrationFromBody } from "../task-workflow.js";
39
39
  import { redactSecrets } from "../trace/redact.js";
40
40
  import { DeferredSandboxPathEnv, isSandboxPathAdjudicationLane, sandboxPathEnvSlots } from "./deferred-sandbox-path-env.js";
@@ -59,6 +59,9 @@ export function createResolveSpec(ctx) {
59
59
  };
60
60
  if (onlySensitiveBaseline)
61
61
  logger.warn(onlySensitiveBaseline.event, onlySensitiveBaseline.fields);
62
+ const approvalGrantNotice = buildApprovalGrantNotice(config);
63
+ if (approvalGrantNotice)
64
+ logger.info(approvalGrantNotice.event, approvalGrantNotice.fields);
62
65
  const resolveSpec = async (body, req, auth, opts) => {
63
66
  assertRequestMcpUnlocked(body.mcpServers, lockedKeys);
64
67
  if (opts?.leg === "fresh")
@@ -210,7 +213,7 @@ export function createResolveSpec(ctx) {
210
213
  const hc = parsedSettings.settings?.hooks;
211
214
  if (!hc)
212
215
  return undefined;
213
- if (config.requirePrincipal === true) {
216
+ if (!taskHooksHonored(config)) {
214
217
  logger.warn("task_hooks_ignored", { honored: false, sessionId: auth?.sessionId ?? null });
215
218
  return undefined;
216
219
  }
@@ -96,6 +96,25 @@ export interface ShutdownCtx {
96
96
  * `backend.close()` 之前等它 quiesce —— 在飞的 `put`(审批 park / 资源挂起…,与审批协调器在不在场无关)不许被抽掉连接池。 */
97
97
  parkCommits: ParkCommitWatch | undefined;
98
98
  }
99
+ /**
100
+ * 受控退的**后台宽限**还要等多久(ms)—— hardShutdown 软停后台 shell 之后、`process.exit` 之前,给软停过的行**一次**
101
+ * `HOST_BG_KILL_GRACE_MS` 收完合作清理(host 梯子:SIGTERM → 宽限 → 组 SIGKILL)。
102
+ *
103
+ * 没有这一步时,**无活连接**的干净停机里 `server.close` 回调立刻落地、链上其余步骤都是毫秒级,`exit` 几乎紧跟软停,退出边界钩同拍一枪
104
+ * 组 SIGKILL ⇒ 收到 SIGTERM 后要一点时间停容器 / 刷盘的后台 shell 被腰斩。**只缩不延**的两条:
105
+ * · 起点是软停时刻(不是「别的步骤做完之后」)—— 慢 close / 审批面收尾已经耗掉的时间算进宽限里,链上别的等待不与它相加,取较晚者;
106
+ * · 封顶在 hardShutdown 起点算的**同一个到期点**(`deadlineMs`,与审批面收尾 / 店 quiesce 共读)—— 运维把宽限调得再长,
107
+ * 退出也不晚于到期点,一定早于 15 s 的 `finalExit`,不延长停机总上限。
108
+ * `softStopped` = 本次软停的后台行数(core `reapAllSessionBackground` 的返回;0 ⇒ 没有梯子在跑、不欠宽限,空闲引擎的退出不变慢)。
109
+ * 该数里含后台 agent / workflow 行(core 不拆开数),它们没有 shell 梯子 ⇒ 只有这一形会**多**等(≤ 一个宽限),不会少等。
110
+ */
111
+ export declare function backgroundGraceRemainingMs(a: {
112
+ softStopped: number;
113
+ softStoppedAt: number;
114
+ now: number;
115
+ graceMs: number;
116
+ deadlineMs: number;
117
+ }): number;
99
118
  /**
100
119
  * `installShutdownHandlers` 的**摘除把手**([ref] 二轮重扫件4)。
101
120
  *
@@ -2,11 +2,18 @@ import { Runner } from "@sema-agent/core";
2
2
  import { writeCrashLast, clearCrashLast, readCrashLast } from "./crash-last.js";
3
3
  import { createSighupIdleHandler } from "../sighup-idle.js";
4
4
  import { createParentWatch } from "../parent-watch.js";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import { HOST_BG_KILL_GRACE_MS } from "../plugins/remote-env-host.js";
5
7
  import { installProcessExitBoundary } from "./exit-boundary.js";
6
8
  import { createEngineLeaseTracker } from "./engine-lease.js";
7
9
  import { writeExitLast, readExitLast, clearExitLast } from "./exit-last.js";
8
10
  import { isScriptRealmRejection, describeRejectionReason } from "../orchestration/hardened-vm-runner.js";
9
11
  const HARD_STOP_GRACE_MS = 10_000;
12
+ export function backgroundGraceRemainingMs(a) {
13
+ if (a.softStopped <= 0)
14
+ return 0;
15
+ return Math.max(0, Math.min(a.softStoppedAt + a.graceMs, a.deadlineMs) - a.now);
16
+ }
10
17
  export function installShutdownHandlers(ctx) {
11
18
  const { config, logger, server, reaper, fleetReconcile, retentionLane, releaseRetentionLease, otelExporter, breakerState, costQuota, rateLimiter, runner, subRunner, lspManager, workflowNotifyJournal, fleetClient, backend, drainState, storeLiveProbe, configCenter, version, deviceHub, toolApproval, parkCommits, } = ctx;
12
19
  const dataRoot = config.localDataRoot;
@@ -102,7 +109,10 @@ export function installShutdownHandlers(ctx) {
102
109
  subRunner.sessions.dispose();
103
110
  void lspManager?.dispose?.();
104
111
  void workflowNotifyJournal?.close?.();
105
- exitBoundary.reapChildProcessesEarly();
112
+ const backgroundSoftStopped = exitBoundary.reapChildProcessesEarly();
113
+ const softStoppedAt = Date.now();
114
+ const backgroundGraceMs = backgroundGraceRemainingMs({ softStopped: backgroundSoftStopped, softStoppedAt, now: softStoppedAt, graceMs: HOST_BG_KILL_GRACE_MS, deadlineMs: graceDeadlineMs });
115
+ const backgroundGrace = backgroundGraceMs > 0 ? delay(backgroundGraceMs) : Promise.resolve();
106
116
  const drained = approvalDrain
107
117
  .then(() => parkCommits?.quiesce(graceDeadlineMs))
108
118
  .then((q) => {
@@ -120,6 +130,7 @@ export function installShutdownHandlers(ctx) {
120
130
  void Promise.all([retentionTeardown, ledgerStopped])
121
131
  .then(() => (backend ? backend.close() : Promise.resolve()))
122
132
  .then(() => routingStopped)
133
+ .finally(() => backgroundGrace)
123
134
  .finally(() => process.exit(0));
124
135
  });
125
136
  const finalExit = setTimeout(() => { logger.warn("hard_shutdown_final_deadline", { afterMs: 15_000 }); process.exit(0); }, 15_000);
@@ -46,6 +46,7 @@ export declare const COMPOSITE_CAPABILITY_MEMBERS: {
46
46
  readonly webSearch: readonly ["backend"];
47
47
  readonly background: readonly ["listFace", "exitFaces"];
48
48
  readonly taskSettings: readonly ["permissions", "permissionMode", "model", "outputStyle", "env", "hooks"];
49
+ readonly settings: readonly ["permissions"];
49
50
  readonly permissionModeAuto: readonly ["accepted", "classifierSeat", "entitlementSource", "armed", "reason", "model"];
50
51
  readonly writeProtection: readonly ["armed", "rows", "replaced"];
51
52
  readonly executionLane: readonly ["provider", "toolsOnThisHost", "isolated"];
@@ -7,6 +7,7 @@ export const COMPOSITE_CAPABILITY_MEMBERS = {
7
7
  webSearch: ["backend"],
8
8
  background: ["listFace", "exitFaces"],
9
9
  taskSettings: ["permissions", "permissionMode", "model", "outputStyle", "env", "hooks"],
10
+ settings: ["permissions"],
10
11
  permissionModeAuto: ["accepted", "classifierSeat", "entitlementSource", "armed", "reason", "model"],
11
12
  writeProtection: ["armed", "rows", "replaced"],
12
13
  executionLane: ["provider", "toolsOnThisHost", "isolated"],
@@ -2,10 +2,11 @@ import { CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES } fr
2
2
  import { CENTER_CREDENTIAL_ENV, DOMAIN_SCHEMAS } from "@sema-agent/settings-schema";
3
3
  import { CENTER_CREDENTIAL_RENEW_LEAD_DEFAULT_MS, CENTER_CREDENTIAL_RENEW_LEAD_MAX_MS, CENTER_CREDENTIAL_RENEW_LEAD_MIN_MS } from "./center-credential-renewer.js";
4
4
  import { THINKING_FORMATS } from "./model-compat.js";
5
- import { AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS, CONFIG_PROVIDER_WORDS, DB_BACKEND_WORDS, DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS, FIRST_BYTE_TIMEOUT_BOUNDS, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS } from "./config-types.js";
5
+ import { AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS, CONFIG_PROVIDER_WORDS, DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS, FIRST_BYTE_TIMEOUT_BOUNDS, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS } from "./config-types.js";
6
6
  import { webSearchProviderFromEnv, WEB_SEARCH_PROVIDERS } from "./plugins/web-search.js";
7
7
  import { AUTONOMY_MODES } from "./runtime-governance.js";
8
8
  import { REMOTE_EXEC_PROVIDER_WORDS } from "./execution-lane-caps.js";
9
+ import { MEMORY_ENGINE_BACKENDS, SESSION_BACKENDS, STORE_BACKENDS } from "@sema-agent/settings-schema/execution-lanes";
9
10
  import { MODEL_PROVIDER_WORDS } from "./model-provider.js";
10
11
  import { buildDisplaySafeUrl } from "./trace/redact.js";
11
12
  import { describeAllowlistPostures } from "./env-name-allowlist-knobs.js";
@@ -57,6 +58,7 @@ export const CONFIG_CATALOG = [
57
58
  r("ANTHROPIC_VERSION", "modelPlane", "string", "Anthropic API version 头(缺省由 SDK 定)"),
58
59
  r("APPROVAL_AUTO_BUDGET", "approval", "number", "每 run-leg 自动放行的普通门控工具调用数上限(0=关;防审批循环的熔断,不是安全 ask 的帽)", { staticDefault: "0", danger: SEC, configKey: "approvalAutoBudget" }),
59
60
  r("APPROVAL_DENY", "approval", "csv", "始终拒绝的工具名单(装配期拒不可匹配名)", { danger: SEC, configKey: "approvalDeny" }),
61
+ r("APPROVAL_GRANT", "approval", "csv", "运维的 allow 规则(工具名规则:精确名 / mcp__<server>__* / a2a__<peer>__*;命中 = 带理由放行,不再为这些调用出「没人表态」卡(不放掉任何一层自己的问);HTTP 提交 / durable 续跑 / 停驻后台 agent 赎回 / run-local 四腿同装(leader worker 首跑与修复腿到不了);不越过任何一层的 deny / ask;不是白名单;坏值拒启)", { danger: SEC, configKey: "approvalGrant", derivedDefaultNote: "缺席 = 空表(本层不表态,折叠与未设逐字同)" }),
60
62
  r("APPROVAL_HMAC_KEYS", "approval", "secret", "直连门(D-G)HMAC 完整性密钥集(空=直连门不激活)", { danger: SEC }),
61
63
  r("APPROVAL_NEVER_AUTO", "approval", "csv", "永不被 AUTO_BUDGET 自动放行的工具集(不可逆动作恒要人)", { danger: SEC, configKey: "approvalNeverAuto" }),
62
64
  r("APPROVAL_REQUIRE", "approval", "csv", "必须人审的工具名单(F4 门;center 腿热:refresh 拍整表换代,无 checkpoint 店的部署发非空名单 ⇒ 审批组整组拒)", { danger: SEC, configKey: "approvalRequire", center: cGov("approvalRequire") }),
@@ -99,7 +101,7 @@ export const CONFIG_CATALOG = [
99
101
  r("COST_QUOTA_WINDOW_SEC", "limitsHttp", "number", "per-principal 成本配额窗长(秒);SQL 腿窗长进桶主键 ⇒ center 腿显式 deferred(重启生效)", { staticDefault: "86400", danger: BILL, configKey: "costQuotaWindowSec", center: cLimits("costQuotaWindowSec", { timing: "restart", restartReason: "cost-quota-window" }) }),
100
102
  r("CROSS_SESSION_DIALOG_EXPIRY", "approval", "enum", "held 跨会话消息等待人审的期限(CC settings 键名 dialogExpiry;未设=不铸键,引擎自落缺省 5m;词表外拒启)", { danger: SEC, enumValues: [...CROSS_SESSION_DIALOG_EXPIRY_VALUES], configKey: "crossSessionDialogExpiry", derivedDefaultNote: "缺席 ⇒ 不铸键,core 自缺省 5m", resolve: (c) => c.crossSessionDialogExpiry ?? null }),
101
103
  r("CROSS_SESSION_INBOUND", "approval", "enum", "入站跨会话消息的部署/组织层治理(CC settings 键名 crossSessionInbound 的 managed 层;未设=本层不表态≠ next-turn;词表外拒启)", { danger: SEC, enumValues: [...CROSS_SESSION_INBOUND_SETTINGS], configKey: "crossSessionInbound", derivedDefaultNote: "缺席 ⇒ managed 层不设,由其余层与 mode-parity 决定", resolve: (c) => c.crossSessionInbound ?? null }),
102
- r("DB_BACKEND", "store", "enum", "store 后端引擎选择(mysql 兼容/pg/本机文件店/纯内存)", { enumValues: DB_BACKEND_WORDS, danger: DATA, derivedDefaultNote: "裸 boot ⇒ local(多租户裸 boot ⇒ memory + notice);有 SQL 信号 ⇒ mysql", configKey: "dbBackend", resolve: (c) => c.dbBackend }),
104
+ r("DB_BACKEND", "store", "enum", "store 后端引擎选择(mysql 兼容/pg/本机文件店/纯内存)", { enumValues: STORE_BACKENDS, danger: DATA, derivedDefaultNote: "裸 boot ⇒ local(多租户裸 boot ⇒ memory + notice);有 SQL 信号 ⇒ mysql", configKey: "dbBackend", resolve: (c) => c.dbBackend }),
103
105
  r("DB_QUERY_TIMEOUT_MS", "store", "number", "per-query DB 超时(ms;坏值回默认姿态+warn,绝不 NaN 进 driver)", { derivedDefaultNote: "缺席 ⇒ 池级不设/计数族 30s(按引擎)", configKey: "dbQueryTimeoutMs" }),
104
106
  r("DEFAULT_SCENARIO", "integrations", "string", "出厂缺省场景(编码产品出厂态;通用中性 persona 用 default 显式选回)", { staticDefault: "code", configKey: "defaultScenario" }),
105
107
  r("DELEGATION_MAX_CONCURRENT", "orchestration", "number", "委派入口并发帽", { derivedDefaultNote: "缺席 ⇒ 不铸键,core 自缺省(CC parity 20)" }),
@@ -155,7 +157,7 @@ export const CONFIG_CATALOG = [
155
157
  r("HOST_BG_DEFAULT_TIMEOUT_SEC", "orchestration", "number", "host 后台 shell 缺省超时(秒)", { staticDefault: "300" }),
156
158
  r("HOST_BG_ENABLED", "orchestration", "boolean", "host 车道后台 shell 开关", { staticDefault: "true" }),
157
159
  r("HOST_BG_FILE_CAP", "orchestration", "number", "host 后台 shell 磁盘护栏(字节;失控输出被 reap)", { staticDefault: String(64 * 1024 * 1024) }),
158
- r("HOST_BG_KILL_GRACE_MS", "orchestration", "number", "host 后台 shell SIGTERM→SIGKILL 宽限(ms);会话删除 / 会话停止口在软停与硬收之间等同一只", { staticDefault: "1000" }),
160
+ r("HOST_BG_KILL_GRACE_MS", "orchestration", "number", "host 后台 shell SIGTERM→SIGKILL 宽限(ms);会话删除 / 会话停止口在软停与硬收之间等同一只,受控退出(SIGTERM / SIGINT)在软停之后、退出之前也等一次(封顶在停机宽限窗内)", { staticDefault: "1000" }),
159
161
  r("HOST_BG_MAX_CONCURRENT", "orchestration", "number", "host 后台 shell 并发帽", { staticDefault: "8" }),
160
162
  r("HOST_BG_MAX_TIMEOUT_SEC", "orchestration", "number", "host 后台 shell 超时上限(秒)", { staticDefault: "1800" }),
161
163
  r("HOST_BG_MEM_CAP", "orchestration", "number", "host 后台 shell 内存流护栏(字节)", { staticDefault: String(8 * 1024 * 1024) }),
@@ -232,7 +234,7 @@ export const CONFIG_CATALOG = [
232
234
  r("MEMORY_EMBEDDER_MODEL", "memory", "string", "embeddings 模型名"),
233
235
  r("MEMORY_EMBEDDER_TIMEOUT_MS", "memory", "number", "单次 embed 活性上限(ms)", { staticDefault: "30000" }),
234
236
  r("MEMORY_ENGINE", "memory", "enum", "记忆引擎总开关(注入式文件记忆面;双族词表,未知词拒启)", { enumValues: ["on", "off"], staticDefault: "on", configKey: "memoryEngineEnabled", booleanEnum: { true: "on", false: "off" } }),
235
- r("MEMORY_ENGINE_BACKEND", "memory", "enum", "记忆持久面方言(file=现状;pg/tidb=DB 真身,多租户点亮的唯一门)", { enumValues: ["file", "pg", "tidb"], staticDefault: "file", danger: DATA, configKey: "memoryEngineBackend" }),
237
+ r("MEMORY_ENGINE_BACKEND", "memory", "enum", "记忆持久面方言(file=现状;pg/tidb=DB 真身,多租户点亮的唯一门)", { enumValues: MEMORY_ENGINE_BACKENDS, staticDefault: "file", danger: DATA, configKey: "memoryEngineBackend" }),
236
238
  r("MEMORY_ENGINE_DIR", "memory", "path", "记忆引擎根覆写", { derivedDefaultNote: "缺席 ⇒ core 默认链(AGENT_DATA_DIR → ~/.ai-agent)", configKey: "memoryEngineDir" }),
237
239
  r("MEMORY_ENGINE_REMOTE_LANE", "memory", "enum", "远端执行车道上放行 file 记忆引擎的 plane-split 覆写", { enumValues: ["allow"] }),
238
240
  r("MEMORY_ORG_ADMISSION_MODE", "memory", "enum", "org 记忆准入模式(audit=判决照算纯观察)", { enumValues: ["audit", "enforce"], staticDefault: "enforce", danger: SEC, configKey: "memoryOrgAdmissionMode" }),
@@ -358,7 +360,7 @@ export const CONFIG_CATALOG = [
358
360
  r("REAP_RUN_STALE_SEC", "limitsHttp", "number", "run 活性判陈旧阈(秒)", { staticDefault: "120", configKey: "runStaleSec", center: cLimits("runStaleSec") }),
359
361
  r("REMOTE_EXEC", "orchestration", "enum", "执行车道选择(点名而配不齐/认不出 ⇒ 拒启,绝不静默降级到 host/stub)", { enumValues: REMOTE_EXEC_PROVIDER_WORDS, danger: SEC, derivedDefaultNote: "缺席:CONFIG_PROVIDER=local ⇒ host;其余 ⇒ 进程内 stub(手带整族不挂;残余名册看活体 wiring_manifest 里 mountedBy=hands 的条目数,不按工具名点。想让这一形拒启见 REQUIRE_EXECUTION_LANE)", configKey: "remoteExec", resolve: (c) => c.remoteExec?.provider ?? null }),
360
362
  r("REMOTE_EXEC_HOME_DIR", "orchestration", "path", "执行环境的 home 目录(`~/` 形权限规则的基;core ExecutionEnv.homeDir 座)", { danger: SEC, derivedDefaultNote: "缺席:host/in-process ⇒ os.homedir();e2b ⇒ /home/user;k8s/local-docker/ssh/adb/device ⇒ 无(`~/` 规则 422 响亮拒)", configKey: "remoteExecHomeDir", resolve: (c) => c.remoteExecHomeDir ?? null }),
361
- r("REQUIRE_EXECUTION_LANE", "orchestration", "boolean", "开了之后:REMOTE_EXEC 未点名 ∧ CONFIG_PROVIDER≠local ⇒ **拒启**并点名该设什么。默认关 = 照旧启动,但执行环境是进程内 stub、手带整族不挂,任务仍回 status=completed(活体读数:mountedBy=hands 条目 10 → 0,名册 38 → 19)—— 与「模型不肯用工具」在用户面同形。残余名册判据 = 活体 wiring_manifest 里 mountedBy=hands 的条目数,不按工具名点。托管/云部署推荐开(docs/DEPLOY-PREREQS.md)", { staticDefault: "false", danger: SEC, configKey: "requireExecutionLane" }),
363
+ r("REQUIRE_EXECUTION_LANE", "orchestration", "boolean", "开了之后:REMOTE_EXEC 未点名 ∧ CONFIG_PROVIDER≠local ⇒ **拒启**并点名该设什么。默认关 = 照旧启动,但执行环境是进程内 stub、**零手带工具**(手带整族不挂;活体读数:mountedBy=hands 条目 10 → 0),名册里其余工具照常执行,任务仍回 status=completed —— 凡需要手的请求,与「模型不肯用工具」在用户面同形。判据 = 活体 wiring_manifest 里 mountedBy=hands 的条目数,不按工具名点;名册总条数随 core 版本走,不写死。托管/云部署推荐开(docs/DEPLOY-PREREQS.md)", { staticDefault: "false", danger: SEC, configKey: "requireExecutionLane" }),
362
364
  r("REQUIRE_PRINCIPAL", "auth", "boolean", "多租户开关(posture 判别位:true ⇒ CC 能力族默认收紧)", { staticDefault: "false", danger: SEC, configKey: "requirePrincipal" }),
363
365
  r("RESOURCE_SUSPEND", "approval", "boolean", "durable 路径 opt-in core resourceSuspend(预算/preempt ⇒ 可恢复挂起)", { staticDefault: "false", configKey: "resourceSuspend" }),
364
366
  r("RESOURCE_SUSPEND_MAX_SLICES", "approval", "number", "resume 链片数上限(0=不设;零进展循环兜底)", { staticDefault: "0", configKey: "resourceSuspendMaxSlices" }),
@@ -408,7 +410,7 @@ export const CONFIG_CATALOG = [
408
410
  r("SERVICE_AUTH_TOKEN", "auth", "secret", "共享 service token(写面门;缺席且未开 ALLOW_UNAUTHED_WRITES ⇒ 拒提交)", { danger: SEC }),
409
411
  r("SERVICE_AUTH_TOKENS", "auth", "secret", "per-system 凭据表(tok=system CSV;source 从凭据派生,绝不自报)", { danger: SEC }),
410
412
  r("SESSION_AUTO_TITLE", "orchestration", "boolean", "首次提交自动铸 session 标题(一次 cheap 调用)", { staticDefault: "true", danger: BILL, configKey: "sessionAutoTitle" }),
411
- r("SESSION_BACKEND", "store", "enum", "session 店引擎(auto=探到 DB 用 DB,探不到回内存;回显=boot 解析后的真引擎)", { enumValues: ["memory", "mysql", "auto"], danger: DATA, derivedDefaultNote: "缺席:显式 DB_BACKEND=mysql|pg ⇒ mysql,否则 memory", configKey: "sessionBackend", resolve: (c) => (c.sessionBackend === "tidb" ? "mysql" : c.sessionBackend) }),
413
+ r("SESSION_BACKEND", "store", "enum", "session 店引擎(auto=探到 DB 用 DB,探不到回内存;回显=boot 解析后的真引擎)", { enumValues: SESSION_BACKENDS, danger: DATA, derivedDefaultNote: "缺席:显式 DB_BACKEND=mysql|pg ⇒ mysql,否则 memory", configKey: "sessionBackend", resolve: (c) => (c.sessionBackend === "tidb" ? "mysql" : c.sessionBackend) }),
412
414
  r("SESSION_CACHE_TTL_SEC", "store", "number", "session 读缓存 TTL(秒;0=关)", { staticDefault: "300", configKey: "sessionCacheTtlSec" }),
413
415
  r("SESSION_EVENTS_HOT_MS", "limitsHttp", "number", "session SSE 订阅热探针间隔(ms)", { derivedDefaultNote: "缺席 ⇒ 200" }),
414
416
  r("SESSION_EVENTS_MAX_CONNS", "limitsHttp", "number", "session SSE 并发连接帽", { derivedDefaultNote: "缺席 ⇒ 256" }),
@@ -1,6 +1,6 @@
1
1
  import { resolveActiveTiers } from "@sema-agent/settings-schema";
2
2
  import { modelKeyPoison } from "../key-resolver.js";
3
- import { applyAutoCompactWindow, buildModelCostKey, findUnmatchableToolNames, formatUnmatchableToolNames, parseAutonomy } from "../config.js";
3
+ import { applyAutoCompactWindow, buildModelCostKey, parseAutonomy, unmatchableToolNamesRefusal } from "../config.js";
4
4
  import { buildModelCompat, readModelCompat } from "../model-compat.js";
5
5
  import { buildModelHeadersKey } from "../model-entry-headers.js";
6
6
  import { validateCommandRules } from "../runtime-governance.js";
@@ -9,7 +9,7 @@ import { stageLimits } from "./stage-limits.js";
9
9
  import { APPROVAL_GROUP_KEYS, BATCH1_LIMIT_KEYS } from "./hot-keys-registry.js";
10
10
  import { buildDisplaySafeUrl } from "../trace/redact.js";
11
11
  import { crossInvariant } from "../config-invariants.js";
12
- import { DEFAULT_MODEL_MISSING_CODE, REFUSED_NAME_MINTED_CODE, judgeCatalogEntries, mintedRefusedNames, refusalEventOf, refusalWarnFields, malformedDroppedCarrierRefusal } from "../model-entry-refusal.js";
12
+ import { DEFAULT_MODEL_MISSING_CODE, REFUSED_NAME_MINTED_CODE, judgeCatalogEntries, mintedRefusedNames, refusalEventOf, refusalWarnFields, refusedNameLogField, refusedNamesLogFields, malformedDroppedCarrierRefusal } from "../model-entry-refusal.js";
13
13
  import { judgeModelRouteEndpoint } from "../model-route-endpoint.js";
14
14
  import { gateIntentUnserviceableReason } from "../approval.js";
15
15
  import { refusedGroups } from "./apply-ledger.js";
@@ -314,7 +314,7 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
314
314
  if (usable.length > 0) {
315
315
  picked = resolveDefaultModelName(eff, (n) => models[n] !== undefined, usable[0].name, (source, name) => logger?.warn("sema_registry_default_dangling", {
316
316
  source,
317
- name,
317
+ ...(Object.hasOwn(verdict.refused, name) ? refusedNameLogField("name", name) : { name }),
318
318
  ...(Object.hasOwn(verdict.refused, name) ? { unavailable: verdict.refused[name].code } : {}),
319
319
  hint: "explicit default names a model that is not in the usable catalog (not enabled, or unavailable on this host — see `unavailable`) — falling to the next source",
320
320
  }));
@@ -327,7 +327,7 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
327
327
  continue;
328
328
  }
329
329
  const refusal = Object.hasOwn(verdict.refused, target) ? verdict.refused[target] : undefined;
330
- logger?.warn("sema_registry_tier_binding_unavailable", { tier, model: target, ...(refusal !== undefined ? { code: refusal.code } : {}), hint: "this tier binding names a model that is not in this generation's usable catalog — the binding is dropped (the tier word falls to the next lower bound tier, core's ladder); fix the entry or rebind the tier" });
330
+ logger?.warn("sema_registry_tier_binding_unavailable", { tier, ...(refusal !== undefined ? refusedNameLogField("model", target) : { model: target }), ...(refusal !== undefined ? { code: refusal.code } : {}), hint: "this tier binding names a model that is not in this generation's usable catalog — the binding is dropped (the tier word falls to the next lower bound tier, core's ladder); fix the entry or rebind the tier" });
331
331
  }
332
332
  const nextView = { ...config, models, tiers: activeTiers, modelRefusals: verdict.refused };
333
333
  const noDefault = crossInvariant("default-model-resolvable").check({ live: config, next: nextView });
@@ -343,7 +343,7 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
343
343
  const target = typeof spec === "string" ? spec : spec !== null && typeof spec === "object" && "model" in spec && typeof spec.model === "string" ? spec.model : undefined;
344
344
  const refusal = target !== undefined && Object.hasOwn(verdict.refused, target) ? verdict.refused[target] : undefined;
345
345
  if (refusal !== undefined) {
346
- logger?.warn("sema_registry_role_binding_unavailable", { role, model: target, code: refusal.code, hint: ROLE_BINDING_UNAVAILABLE_HINT });
346
+ logger?.warn("sema_registry_role_binding_unavailable", { role, ...refusedNameLogField("model", target), code: refusal.code, hint: ROLE_BINDING_UNAVAILABLE_HINT });
347
347
  }
348
348
  }
349
349
  const atModelAllowlist = stageAtModelAllowlist(eff.models?.atModelAllowlist, models, logger);
@@ -357,7 +357,7 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
357
357
  roles,
358
358
  activeTiers,
359
359
  atModelAllowlist,
360
- infoLine: { count: usable.length, ...(Object.keys(verdict.refused).length > 0 ? { unavailable: Object.keys(verdict.refused) } : {}), default: defaultModel.id, defaultSource: picked.source, roles: Object.keys(roles), tiers: Object.keys(activeTiers), atModelAllowlist: atModelAllowlist.length, sealedKeys: Object.values(modelApiKeys).filter((v) => typeof v === "string").length, sealedPoisoned: Object.values(modelApiKeys).filter((v) => typeof v !== "string").length },
360
+ infoLine: { count: usable.length, ...(Object.keys(verdict.refused).length > 0 ? refusedNamesLogFields("unavailable", Object.keys(verdict.refused)) : {}), default: defaultModel.id, defaultSource: picked.source, roles: Object.keys(roles), tiers: Object.keys(activeTiers), atModelAllowlist: atModelAllowlist.length, sealedKeys: Object.values(modelApiKeys).filter((v) => typeof v === "string").length, sealedPoisoned: Object.values(modelApiKeys).filter((v) => typeof v !== "string").length },
361
361
  };
362
362
  return { stages: { models: plane, collab: stageCollabDomain(eff) }, defaultName: picked.name, refused: verdict.refused };
363
363
  }
@@ -476,13 +476,13 @@ function stageApprovalRequire(raw) {
476
476
  return { ok: false, detail, reason: detail, names: nonString.map((e) => `[${e.i}]`), kept: KEPT_WOULD_REMOVE_GATE };
477
477
  }
478
478
  const names = raw.filter((n) => typeof n === "string").map((n) => n.trim()).filter((n) => n.length > 0);
479
- const bad = findUnmatchableToolNames(names, { consumer: "tool-name-table", behavior: "deny" });
480
- if (bad.length > 0) {
479
+ const unmatchable = unmatchableToolNamesRefusal("published governance.approvalRequire", names, { consumer: "tool-name-table", behavior: "deny" });
480
+ if (unmatchable !== undefined) {
481
481
  return {
482
482
  ok: false,
483
- detail: formatUnmatchableToolNames("published governance.approvalRequire", bad),
484
- reason: `published governance.approvalRequire has ${bad.length} entr${bad.length === 1 ? "y" : "ies"} that match no live tool (retired name / incomplete MCP name / CC-form rule) — the offending entries are named in the sema_registry_approval_require_invalid log line`,
485
- names: bad.map((b) => b.name),
483
+ detail: unmatchable.message,
484
+ reason: `published governance.approvalRequire has ${unmatchable.names.length} entr${unmatchable.names.length === 1 ? "y" : "ies"} that match no live tool (retired name / incomplete MCP name / CC-form rule) — the offending entries are named in the sema_registry_approval_require_invalid log line`,
485
+ names: unmatchable.names,
486
486
  kept: KEPT_WOULD_MATCH_NOTHING,
487
487
  };
488
488
  }
@@ -57,6 +57,33 @@ export interface CrossInvariant {
57
57
  /** 返回拒因;`undefined` = 通过。 */
58
58
  check(input: CrossInvariantInput): string | undefined;
59
59
  }
60
+ /**
61
+ * ── S-1008 ① 推广(车 UZ 推广轮):**拒因句单铸点**是一类 —— 下面五只是 `config.ts` 区里五条跨域拒启的**判据 + 句子的唯一属主**
62
+ * (`executionLaneRequiredRefusal` / `modelProviderRefusal` 同形):boot 的 throw(`config.ts`,锚在那儿)与注册表条目的 `check` **都调它**,
63
+ * 没有第二份文本可漂。修前这五条逐条两份不同的句子(boot 手写一份、表里另写一份),boot 那份是运维在 stderr 读到的(`config-validation.test.ts`
64
+ * 钉着它的指纹),表那份只在热路径上被调 —— 而热键集带不上这些键 ⇒ 实际无读者。句子**取 boot 那份**(逐字不变);类钉 =
65
+ * `test/config-hot/batch1-cross-invariants.test.ts` 的「类钉」一格(每条带 `bootAnchor` 的条目,boot 点不手写句子、且来自注册表本人或 check 同调的函数)。
66
+ * 参数取 `CrossInvariantView` 的窄 `Pick`:boot 期手里只有装配中的各域值,不是整只 config,按真字段名铸一个小对象传入。
67
+ */
68
+ /** `direct-door-anchors` 的状态臂:半开的直连门(开着却缺锚点)。转换臂(门姿态翻转)只活在热路径,留在条目里。 */
69
+ export declare function halfOpenDirectDoorRefusal(v: Pick<CrossInvariantView, "directApprovalDoor" | "durableApproval" | "approvalHmacKeys" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "requirePrincipal" | "operatorPrincipals">): string | undefined;
70
+ /**
71
+ * `operator-principals-required`([ref]-① 普查候选 1/2):BFF 多租户 durable 形(DURABLE_APPROVAL ∧ REQUIRE_PRINCIPAL,直连门关)的空 operator 名单 ——
72
+ * approvals-assistant 四个读面按裸 `isOperator` 划 scope,空名单 true-for-all ⇒ 任一租户 principal 可读他租户的待批队列。单租户 legacy(REQUIRE_PRINCIPAL 未设)刻意保留。
73
+ */
74
+ export declare function operatorPrincipalsRequiredRefusal(v: Pick<CrossInvariantView, "durableApproval" | "requirePrincipal" | "operatorPrincipals">): string | undefined;
75
+ /** `bake-operator-principals`(IMAGE-API-DESIGN §P2.4a):bake 门是 build-host 无限制执行面,空 operator 名单让 isOperator true-for-all = 无限制写入面。 */
76
+ export declare function bakeOperatorPrincipalsRefusal(v: Pick<CrossInvariantView, "imageBakes" | "operatorPrincipals">): string | undefined;
77
+ /**
78
+ * `bake-runner-credential`(IMAGE-API-DESIGN §P2.12 / H1):bake-runner 的身份由它的 bearer 凭据**派生**(token-derived `source`),不是自称的头 ——
79
+ * 没有 `system == BAKE_RUNNER_PRINCIPAL` 的 SERVICE_AUTH_TOKENS 条目,runner 永远过不了 isRunner,claim/ingest/heartbeat 整条管线静默全 403。
80
+ */
81
+ export declare function bakeRunnerCredentialRefusal(v: Pick<CrossInvariantView, "imageBakes" | "authTokens">): string | undefined;
82
+ /**
83
+ * `leader-durable-store`([ref] 车4 件2,台账 P1-9 / P1-10):leader run(N worker、最长 24h、以真 git push 收尾)的登记表没有外部 SQL 店就退化成进程内 Map。
84
+ * 「多副本」在 config 层判不了(副本数不是本进程的知识),可判的**代理**就是 durable 前提本身。
85
+ */
86
+ export declare function leaderDurableStoreRefusal(v: Pick<CrossInvariantView, "leaderEnabled" | "dbBackend">): string | undefined;
60
87
  export declare const CONFIG_CROSS_INVARIANTS: readonly CrossInvariant[];
61
88
  /**
62
89
  * 按 id 取**一条**(S-157②:boot 面的单源化出口)。
@@ -3,7 +3,7 @@ import { modelProviderRefusal } from "./model-provider.js";
3
3
  import { modelRouteUrlRefusal } from "./model-route-url.js";
4
4
  import { modelRouteEndpointRefusal } from "./model-route-endpoint.js";
5
5
  import { defaultModelRefusal, refusedNameMintedRefusal } from "./model-entry-refusal.js";
6
- import { executionLaneCaps, executionLaneOf, laneCapabilityHolds, REMOTE_EXEC_PROVIDER_WORDS } from "./execution-lane-caps.js";
6
+ import { executionLaneCaps, executionLaneOf, executionLaneRequiredRefusal, laneCapabilityHolds, REMOTE_EXEC_PROVIDER_WORDS } from "./execution-lane-caps.js";
7
7
  export const LIVENESS_HEARTBEAT_MS = 30_000;
8
8
  const anyServiceAuth = (v) => Boolean(v.authToken) || Object.keys(v.authTokens ?? {}).length > 0;
9
9
  const mustNarrowBind = (v) => Boolean(v.allowUnauthedWrites) && !anyServiceAuth(v);
@@ -16,6 +16,51 @@ function multiTenantSharedLaneRefusal(v) {
16
16
  return (`REQUIRE_PRINCIPAL=true (multi-tenant posture) with the "${lane}" execution lane runs every tenant's Bash on one shared target — ` +
17
17
  `this lane does not separate tenants; set REMOTE_EXEC to ${outlets.join(" / ")}, or unset REQUIRE_PRINCIPAL for a single-user box`);
18
18
  }
19
+ export function halfOpenDirectDoorRefusal(v) {
20
+ if (!v.directApprovalDoor)
21
+ return undefined;
22
+ const missing = [];
23
+ if (!v.durableApproval)
24
+ missing.push("DURABLE_APPROVAL=true (the direct door's crypto gate only exists on the durable /decide path)");
25
+ if (v.approvalHmacKeys.length === 0)
26
+ missing.push("APPROVAL_HMAC_KEYS");
27
+ if (v.principalJwtPubkeys.length === 0)
28
+ missing.push("PRINCIPAL_JWT_PUBKEYS");
29
+ if (!v.principalJwtIss)
30
+ missing.push("PRINCIPAL_JWT_ISS");
31
+ if (!v.principalJwtAud)
32
+ missing.push("PRINCIPAL_JWT_AUD");
33
+ if (v.operatorPrincipals.length === 0)
34
+ missing.push("OPERATOR_PRINCIPALS (else isOperator is true-for-all)");
35
+ if (!v.requirePrincipal)
36
+ missing.push("REQUIRE_PRINCIPAL=true (a direct door is multi-tenant; without it an absent principal is anonymous and the self-asserted header governs owner/quota checks)");
37
+ return missing.length > 0 ? `DIRECT_APPROVAL_DOOR=true but D-G anchors are missing: ${missing.join(", ")} — refusing to start a half-open direct door (design/80 D-G boot invariant)` : undefined;
38
+ }
39
+ export function operatorPrincipalsRequiredRefusal(v) {
40
+ return v.durableApproval === true && v.requirePrincipal && v.operatorPrincipals.length === 0
41
+ ? "DURABLE_APPROVAL=true + REQUIRE_PRINCIPAL=true but OPERATOR_PRINCIPALS is empty — on a multi-tenant worker the empty operator set makes isOperator() true-for-all, so every authenticated tenant could read every other tenant's pending-approval queue (GET /v1/approvals & siblings). Set OPERATOR_PRINCIPALS, or unset REQUIRE_PRINCIPAL if this is a single-tenant deployment (#157-① boot invariant)"
42
+ : undefined;
43
+ }
44
+ export function bakeOperatorPrincipalsRefusal(v) {
45
+ return v.imageBakes.enabled && v.operatorPrincipals.length === 0
46
+ ? "IMAGE_BAKES_ENABLED=true but OPERATOR_PRINCIPALS is empty — the bake door runs build.sh on the privileged build host; an empty operator set makes isOperator() true-for-all (world-writable RCE). Set OPERATOR_PRINCIPALS or disable bakes (IMAGE-API-DESIGN.md §P2.4a boot invariant)"
47
+ : undefined;
48
+ }
49
+ export function bakeRunnerCredentialRefusal(v) {
50
+ const runner = v.imageBakes.runnerPrincipal;
51
+ return v.imageBakes.enabled && !Object.values(v.authTokens ?? {}).includes(runner)
52
+ ? `IMAGE_BAKES_ENABLED=true but no SERVICE_AUTH_TOKENS entry maps to the runner principal '${runner}' — the bake-runner authenticates by its credential (token-derived source), so a '<BAKE_RUNNER_TOKEN>=${runner}' SERVICE_AUTH_TOKENS entry is required or claim/ingest/heartbeat all 403 (IMAGE-API-DESIGN.md §P2.12 boot invariant)`
53
+ : undefined;
54
+ }
55
+ export function leaderDurableStoreRefusal(v) {
56
+ return v.leaderEnabled && v.dbBackend !== "mysql" && v.dbBackend !== "pg"
57
+ ? `LEADER_ENABLED=true but DB_BACKEND=${v.dbBackend} (no external SQL store) is refused: a leader run is an ` +
58
+ "N-worker fan-out lasting up to 24h that ends in a REAL git push, and without a durable store its registry is " +
59
+ "a per-process Map — a cross-replica GET 404s, a restart forgets the run, and a retried POST starts a SECOND " +
60
+ "push. Point the service at a durable store (DB_BACKEND=mysql|pg), or turn the leader off (unset LEADER_ENABLED) " +
61
+ "(#193 车4 件2 / staleness ledger P1-9+P1-10 boot invariant)"
62
+ : undefined;
63
+ }
19
64
  export const CONFIG_CROSS_INVARIANTS = [
20
65
  {
21
66
  id: "run-stale-heartbeat",
@@ -39,57 +84,31 @@ export const CONFIG_CROSS_INVARIANTS = [
39
84
  keys: ["imageBakes", "authTokens"],
40
85
  restartReason: "bake-runner-credential",
41
86
  bootAnchor: { file: "src/config.ts", marker: "CROSS-INVARIANT:bake-runner-credential" },
42
- check: ({ next }) => next.imageBakes.enabled && !Object.values(next.authTokens ?? {}).includes(next.imageBakes.runnerPrincipal)
43
- ? `IMAGE_BAKES_ENABLED is on but no SERVICE_AUTH_TOKENS entry maps to the runner principal '${next.imageBakes.runnerPrincipal}' — claim/ingest/heartbeat would all 403`
44
- : undefined,
87
+ check: ({ next }) => bakeRunnerCredentialRefusal(next),
45
88
  },
46
89
  {
47
90
  id: "direct-door-anchors",
48
91
  keys: ["directApprovalDoor", "durableApproval", "approvalHmacKeys", "principalJwtPubkeys", "principalJwtIss", "principalJwtAud", "requirePrincipal", "operatorPrincipals"],
49
92
  restartReason: "direct-door",
50
93
  bootAnchor: { file: "src/config.ts", marker: "CROSS-INVARIANT:direct-door-anchors" },
51
- check: ({ live, next }) => {
52
- if (next.directApprovalDoor) {
53
- const missing = [];
54
- if (!next.durableApproval)
55
- missing.push("DURABLE_APPROVAL");
56
- if (next.approvalHmacKeys.length === 0)
57
- missing.push("APPROVAL_HMAC_KEYS");
58
- if (next.principalJwtPubkeys.length === 0)
59
- missing.push("PRINCIPAL_JWT_PUBKEYS");
60
- if (!next.principalJwtIss)
61
- missing.push("PRINCIPAL_JWT_ISS");
62
- if (!next.principalJwtAud)
63
- missing.push("PRINCIPAL_JWT_AUD");
64
- if (next.operatorPrincipals.length === 0)
65
- missing.push("OPERATOR_PRINCIPALS");
66
- if (!next.requirePrincipal)
67
- missing.push("REQUIRE_PRINCIPAL");
68
- if (missing.length > 0)
69
- return `a half-open direct approval door is refused: missing ${missing.join(", ")}`;
70
- }
71
- return directDoorConjuncts(live) !== directDoorConjuncts(next)
94
+ check: ({ live, next }) => halfOpenDirectDoorRefusal(next) ??
95
+ (directDoorConjuncts(live) !== directDoorConjuncts(next)
72
96
  ? "the direct-door posture (its conjunction of approval/JWT anchors) is decided at boot — refusing to hot-flip it (restart to change the door)"
73
- : undefined;
74
- },
97
+ : undefined),
75
98
  },
76
99
  {
77
100
  id: "operator-principals-required",
78
101
  keys: ["durableApproval", "requirePrincipal", "operatorPrincipals"],
79
102
  restartReason: "operator-principals",
80
103
  bootAnchor: { file: "src/config.ts", marker: "CROSS-INVARIANT:operator-principals-required" },
81
- check: ({ next }) => next.durableApproval === true && next.requirePrincipal && next.operatorPrincipals.length === 0
82
- ? "DURABLE_APPROVAL + REQUIRE_PRINCIPAL with an EMPTY OPERATOR_PRINCIPALS makes isOperator() true-for-all — every authenticated tenant could read every other tenant's pending-approval queue"
83
- : undefined,
104
+ check: ({ next }) => operatorPrincipalsRequiredRefusal(next),
84
105
  },
85
106
  {
86
107
  id: "bake-operator-principals",
87
108
  keys: ["imageBakes", "operatorPrincipals"],
88
109
  restartReason: "operator-principals",
89
110
  bootAnchor: { file: "src/config.ts", marker: "CROSS-INVARIANT:bake-operator-principals" },
90
- check: ({ next }) => next.imageBakes.enabled && next.operatorPrincipals.length === 0
91
- ? "IMAGE_BAKES_ENABLED with an EMPTY OPERATOR_PRINCIPALS is a world-writable bake door (the runner runs build.sh on the privileged build host)"
92
- : undefined,
111
+ check: ({ next }) => bakeOperatorPrincipalsRefusal(next),
93
112
  },
94
113
  {
95
114
  id: "sensitive-write-patterns",
@@ -111,9 +130,7 @@ export const CONFIG_CROSS_INVARIANTS = [
111
130
  keys: ["requireExecutionLane", "remoteExec"],
112
131
  restartReason: "execution-lane",
113
132
  bootAnchor: { file: "src/config.ts", marker: "CROSS-INVARIANT:require-execution-lane" },
114
- check: ({ next }) => next.requireExecutionLane && next.remoteExec === undefined
115
- ? "REQUIRE_EXECUTION_LANE=true with no execution lane named (REMOTE_EXEC unset/empty and CONFIG_PROVIDER not \"local\") — the deployment asked to refuse this state"
116
- : undefined,
133
+ check: ({ next }) => executionLaneRequiredRefusal(next),
117
134
  },
118
135
  {
119
136
  id: "leader-host-lane",
@@ -142,9 +159,7 @@ export const CONFIG_CROSS_INVARIANTS = [
142
159
  keys: ["leaderEnabled", "dbBackend"],
143
160
  restartReason: "leader-lane",
144
161
  bootAnchor: { file: "src/config.ts", marker: "CROSS-INVARIANT:leader-durable-store" },
145
- check: ({ next }) => next.leaderEnabled && next.dbBackend !== "mysql" && next.dbBackend !== "pg"
146
- ? `LEADER_ENABLED with DB_BACKEND=${next.dbBackend} (no external SQL store) degrades the leader-run registry to a per-process Map`
147
- : undefined,
162
+ check: ({ next }) => leaderDurableStoreRefusal(next),
148
163
  },
149
164
  {
150
165
  id: "device-durable-store",
@@ -35,6 +35,22 @@ export type FetchEffectiveResult = {
35
35
  readWarnings: readonly EffectiveReadWarning[];
36
36
  credentialIdentity?: string;
37
37
  } | null;
38
+ /**
39
+ * 引号段 = **一条规则**:先过 `redactSecrets`;文本里没有引号字符(`"` / `'`)⇒ 原样返回;有 ⇒ 从**第一个**引号到**最后一个**引号(含两端,
40
+ * 两种引号不分种类)整段换成**一个**指纹;`"` 的个数是奇数、或 `'` 的个数是奇数(落单 / 句子被截断)⇒ 从第一个引号遮到**文末**。
41
+ * 本函数不辨认任何一个引号是「开头」「收尾」「转义」还是「撇号」—— 生产方原文插值的值自己带引号时,句子里没有信号能把「值里的引号」与「值的边界」
42
+ * 分开,逐个辨认边界的读法每补一条豁免就多一种漏形。
43
+ *
44
+ * 为什么是超集:
45
+ * · 配对读法(`/"[^"]*"|'[^']*'/g`)遮住的每一段都夹在两个引号之间,因此落在「第一个引号 … 最后一个引号」之内;
46
+ * · 生产方放进引号里的值(不论值里带几个引号、怎么转义、撇号在哪)也都落在这一段之内 —— 值左边至少有一个引号、右边至少有一个引号;
47
+ * · 引号个数不成对时遮到文末,只会更宽。
48
+ * 代价:两个操作数之间的固定词与第一个操作数之后的句子不再可读(例:`roster "#…" (not in the models catalog)`,原句里 `references unknown model`
49
+ * 一并进了指纹);首个引号之前的部分(引用完整性腿拼在前面的坐标 `rosters.0.models.0: `、固定句头)与末个引号之后的句尾仍是明文。
50
+ * 仍不覆盖:生产方**不加引号**直接插值的值(本函数看不见值在哪);句子被截断、而两种引号的个数恰好都是偶数的形。真收法在消息生产方给出结构化操作数、
51
+ * 不回声值(上游 settings-schema)。
52
+ */
53
+ export declare function redactConfigError(text: string): string;
38
54
  /** Sentinel returned by {@link raceBootFetch} when the boot pull outlived its budget — the caller
39
55
  * starts serving on the env fallback and the SAME promise settles in the background (never cancelled). */
40
56
  export declare const BOOT_FETCH_DEFERRED: unique symbol;
@@ -8,8 +8,17 @@ import { fetchEffective as remoteFetchEffective, fetchSkillContent as remoteFetc
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  const OPERAND_FINGERPRINT_KEY = randomBytes(16);
11
- function redactConfigError(text) {
12
- return redactSecrets(text).replace(/"[^"]*"|'[^']*'/g, (m) => `"#${createHmac("sha256", OPERAND_FINGERPRINT_KEY).update(m.slice(1, -1)).digest("hex").slice(0, 8)}"`);
11
+ export function redactConfigError(text) {
12
+ const s = redactSecrets(text);
13
+ const first = s.search(/["']/);
14
+ if (first === -1)
15
+ return s;
16
+ const count = (q) => s.split(q).length - 1;
17
+ const fingerprint = (operand) => `"#${createHmac("sha256", OPERAND_FINGERPRINT_KEY).update(operand).digest("hex").slice(0, 8)}"`;
18
+ if (count('"') % 2 === 1 || count("'") % 2 === 1)
19
+ return `${s.slice(0, first)}${fingerprint(s.slice(first + 1))}`;
20
+ const last = Math.max(s.lastIndexOf('"'), s.lastIndexOf("'"));
21
+ return `${s.slice(0, first)}${fingerprint(s.slice(first + 1, last))}${s.slice(last + 1)}`;
13
22
  }
14
23
  export const BOOT_FETCH_DEFERRED = Symbol("boot-fetch-deferred");
15
24
  export function raceBootFetch(fetch, budgetMs) {