@sema-agent/server 7.101.1 → 7.103.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 (113) hide show
  1. package/MIGRATION.md +84 -0
  2. package/README.md +4 -4
  3. package/README.zh-CN.md +4 -4
  4. package/USAGE.md +156 -9
  5. package/dist/approval-card.d.ts +35 -2
  6. package/dist/approval-card.js +19 -1
  7. package/dist/background-session-reap.d.ts +58 -0
  8. package/dist/background-session-reap.js +90 -0
  9. package/dist/bake-runner/main.d.ts +21 -3
  10. package/dist/bake-runner/main.js +18 -11
  11. package/dist/boot/config-center.d.ts +21 -4
  12. package/dist/boot/config-center.js +62 -19
  13. package/dist/boot/exit-boundary.js +17 -0
  14. package/dist/boot/runner-deps.js +1 -0
  15. package/dist/boot/runtime-caps.js +3 -2
  16. package/dist/boot/session-faces.js +3 -4
  17. package/dist/boot/shutdown.d.ts +2 -1
  18. package/dist/boot/shutdown.js +33 -12
  19. package/dist/boot/stage-01-config.js +2 -0
  20. package/dist/boot/stage-07-capability-layer.d.ts +1 -1
  21. package/dist/boot/stage-07-capability-layer.js +6 -4
  22. package/dist/boot/stage-08-reapers.d.ts +1 -1
  23. package/dist/boot/stage-09-leader.d.ts +1 -1
  24. package/dist/boot/stage-10-http-server.d.ts +1 -1
  25. package/dist/boot/stage-10-http-server.js +13 -4
  26. package/dist/brain.d.ts +28 -8
  27. package/dist/brain.js +20 -4
  28. package/dist/capabilities/builtin-tools.d.ts +14 -1
  29. package/dist/capabilities/builtin-tools.js +3 -0
  30. package/dist/capabilities/center-plugins.d.ts +9 -3
  31. package/dist/capabilities/center-plugins.js +5 -10
  32. package/dist/capabilities/scenarios.d.ts +1 -1
  33. package/dist/capabilities/scenarios.js +3 -3
  34. package/dist/center-credential-renewer.d.ts +76 -0
  35. package/dist/center-credential-renewer.js +359 -0
  36. package/dist/center-credential.d.ts +76 -22
  37. package/dist/center-credential.js +119 -43
  38. package/dist/config-catalog.js +17 -8
  39. package/dist/config-center/apply-effective.d.ts +16 -0
  40. package/dist/config-center/apply-effective.js +26 -21
  41. package/dist/config-center/center-request.d.ts +13 -0
  42. package/dist/config-center/center-request.js +23 -3
  43. package/dist/config-center/facade.d.ts +1 -1
  44. package/dist/config-center/http-client.js +2 -5
  45. package/dist/config-center/mcp-revocation.d.ts +2 -2
  46. package/dist/config-center/mcp-revocation.js +1 -1
  47. package/dist/config-center/read-warnings.d.ts +19 -48
  48. package/dist/config-center/read-warnings.js +7 -49
  49. package/dist/config-center/restart-signal.d.ts +8 -21
  50. package/dist/config-center/restart-signal.js +2 -12
  51. package/dist/config-center/types.d.ts +4 -2
  52. package/dist/config-provider.js +1 -48
  53. package/dist/config-types.d.ts +107 -17
  54. package/dist/config-types.js +7 -2
  55. package/dist/config.d.ts +7 -0
  56. package/dist/config.js +91 -29
  57. package/dist/device-store.d.ts +1 -1
  58. package/dist/env-name-allowlist-knobs.d.ts +21 -7
  59. package/dist/env-name-allowlist-knobs.js +7 -2
  60. package/dist/fleet-client.d.ts +27 -7
  61. package/dist/fleet-client.js +43 -12
  62. package/dist/fleet-lease.d.ts +1 -1
  63. package/dist/host-lsp-manager.d.ts +4 -3
  64. package/dist/host-lsp-manager.js +2 -6
  65. package/dist/http/approval-sweeps.js +5 -20
  66. package/dist/http/dispatch.js +24 -14
  67. package/dist/http/readiness.d.ts +87 -0
  68. package/dist/http/readiness.js +54 -0
  69. package/dist/http/resume-legs.js +5 -4
  70. package/dist/http/route-table.d.ts +7 -0
  71. package/dist/http/route-table.js +6 -0
  72. package/dist/http/routes/a2a-serve.js +6 -14
  73. package/dist/http/routes/approvals-assistant.js +1 -0
  74. package/dist/http/routes/background.d.ts +80 -0
  75. package/dist/http/routes/background.js +94 -0
  76. package/dist/http/routes/capabilities.js +2 -0
  77. package/dist/http/server.d.ts +25 -8
  78. package/dist/http/server.js +2 -2
  79. package/dist/leader/wire.d.ts +8 -1
  80. package/dist/leader/wire.js +4 -3
  81. package/dist/model-entry-refusal.d.ts +42 -19
  82. package/dist/model-entry-refusal.js +34 -19
  83. package/dist/model-route-endpoint.d.ts +10 -0
  84. package/dist/model-route-endpoint.js +6 -2
  85. package/dist/observability/fail-open.d.ts +2 -2
  86. package/dist/observability/fail-open.js +2 -2
  87. package/dist/observability/secret-env-scrub.d.ts +12 -0
  88. package/dist/observability/secret-env-scrub.js +12 -0
  89. package/dist/outbound-dispatcher.d.ts +125 -0
  90. package/dist/outbound-dispatcher.js +211 -0
  91. package/dist/parked-decide.d.ts +11 -5
  92. package/dist/parked-decide.js +4 -1
  93. package/dist/parked-revive-surface.d.ts +47 -0
  94. package/dist/parked-revive-surface.js +20 -0
  95. package/dist/plugins/background-shell-support.d.ts +20 -1
  96. package/dist/plugins/background-shell-support.js +26 -0
  97. package/dist/plugins/host-platform.d.ts +16 -1
  98. package/dist/plugins/host-platform.js +11 -0
  99. package/dist/plugins/remote-env-host.d.ts +14 -1
  100. package/dist/plugins/remote-env-host.js +15 -10
  101. package/dist/project-memory.js +3 -6
  102. package/dist/run-local.js +2 -0
  103. package/dist/server-secret-env.d.ts +97 -24
  104. package/dist/server-secret-env.js +37 -1
  105. package/dist/tool-approval.d.ts +30 -1
  106. package/dist/tool-approval.js +31 -8
  107. package/dist/trace/core-keyset-guard.d.ts +9 -4
  108. package/dist/trace/engine-notice-wire.d.ts +1 -1
  109. package/dist/trace/engine-notice-wire.js +2 -0
  110. package/dist/trace/project.js +13 -0
  111. package/dist/trace/redact.d.ts +11 -0
  112. package/dist/trace/redact.js +1 -0
  113. package/package.json +4 -3
package/dist/config.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Model, type PathRuleBases, type RuleBehavior } from "@sema-agent/core";
2
2
  import { type RemoteExecProvider } from "./execution-lane-caps.js";
3
3
  import { type Autonomy } from "./runtime-governance.js";
4
+ import { type RunnerEnvNameAllowlistKnob, type ServerEnvNameAllowlistKnob } from "./env-name-allowlist-knobs.js";
4
5
  import type { ServiceConfig, ServiceConfigFlat, ServiceConfigGroups } from "./config-types.js";
5
6
  /** [ref] A1: the config TYPE face lives in the leaf module config-types.ts; re-exported here so
6
7
  * every existing `from "./config.js"` importer compiles unchanged (pure-type consumers should prefer
@@ -31,6 +32,8 @@ export declare function parseUsageWindows(raw: string | undefined): readonly {
31
32
  maxCostUsd?: number;
32
33
  anchor: "first-use" | "rolling";
33
34
  }[] | undefined;
35
+ /** 墓碑拒启码(机读;拒句方括号里)。 */
36
+ export declare const RETIRED_ENV_REFUSAL_CODE = "config.env_key_retired";
34
37
  /** Numeric env with validation: a non-numeric value FAILS at startup instead of silently becoming `NaN`
35
38
  * (which e.g. slips past the `runStaleSec` liveness guard and silently disables the reaper). */
36
39
  export declare function numEnv(name: string, fallback: string): number;
@@ -231,6 +234,10 @@ export declare const hostExecSpoolEnabled: () => boolean;
231
234
  * 门 = `test/deploy-lanes-contract.test.ts`,它读的是本表而不是任何副本。
232
235
  */
233
236
  export declare const REQUIRED_ENVS_BY_LANE: Record<RemoteExecProvider, readonly string[]>;
237
+ /** 名单型 env 旋钮的**唯一**解析器(规则全文见上方 `ENV_NAME_ALLOWLIST_KNOBS` 头注)。`undefined` = 没配(未设 / 空串)。
238
+ * 服务侧旋钮要交本进程的姿态(③);runner 旋钮没有姿态,签名上就不收 —— 读者与行形由叶表的类型一起定,传错是编译红。 */
239
+ export declare function parseEnvNameAllowlist(knob: ServerEnvNameAllowlistKnob, singleUserTurnkey: boolean): readonly string[] | undefined;
240
+ export declare function parseEnvNameAllowlist(knob: RunnerEnvNameAllowlistKnob): readonly string[] | undefined;
234
241
  /** [ref]① 1M dual-window (core 1.289 `Model.autoCompactTokens`): an EXPLICIT positive value wins (env
235
242
  * `MODEL_AUTO_COMPACT_TOKENS` / center roster `autoCompactTokens`); else the **model-id table** applies —
236
243
  * sonnet-5-family id ∧ contextWindow>=1e6 ⇒ 967000 (the faithful shape of CC's single-entry per-model
package/dist/config.js CHANGED
@@ -4,13 +4,13 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { BUILTIN_COMPLIANCE_DENIES, COMPLIANCE_CAPABILITIES, CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES, compileReadDeny, resolveReadDenyBuiltins, CODE_AGENT_PROMPT, formatUserScope, isModelPriced, PROTOCOL_TABLE, protocolOf, RECOMMENDED_SENSITIVE_PATTERNS, RETIRED_TOOL_NAMES, resolveFileHistoryRetention, resolveLockedKeys, validatePermissionRules, WRITE_PROTECTED_DEFAULT_TABLE, resolveWriteProtectedTable, parseRuleText } from "@sema-agent/core";
7
- import { ROSTER_PRIMARY_ROLES, ROSTER_CHEAP_ROLES } from "@sema-agent/settings-schema";
7
+ import { ROSTER_PRIMARY_ROLES, ROSTER_CHEAP_ROLES, CENTER_CREDENTIAL_ENV } from "@sema-agent/settings-schema";
8
8
  import { parseApprovalHmacKeys, parsePrincipalJwks } from "./auth-keys.js";
9
9
  import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
10
10
  import { isV2ScopeKey, memoryReplicationCoverage } from "./memory-scope.js";
11
11
  import { DEFAULT_QUESTION_THROTTLE } from "./question.js";
12
12
  import { GIT_API_KINDS, isGitApiKind } from "./git-api-kind.js";
13
- import { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS } from "./config-types.js";
13
+ import { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS, MAX_TIMER_DELAY_MS, AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS, DRAIN_GRACE_FLOOR_MS, DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS, FIRST_BYTE_TIMEOUT_BOUNDS } from "./config-types.js";
14
14
  import { buildWriteProtectionPosture } from "./write-protection.js";
15
15
  import { normalizeSshFingerprint } from "./ssh-host-key.js";
16
16
  import { readFaceForPosture } from "./read-face-posture.js";
@@ -20,12 +20,13 @@ import { CONTROL_AND_BIDI_STRIP_RE } from "./text-bidi.js";
20
20
  import { REMOTE_EXEC_PROVIDER_WORDS, executionLaneOf } from "./execution-lane-caps.js";
21
21
  import { AUTONOMY_MODES } from "./runtime-governance.js";
22
22
  import { createCenterCredentialSource, readCenterCredentialDeclaration } from "./center-credential.js";
23
+ import { parseCenterCredentialRenewLeadMs } from "./center-credential-renewer.js";
23
24
  import { modelProviderRefusal } from "./model-provider.js";
24
25
  import { modelRouteUrlRefusal } from "./model-route-url.js";
25
26
  import { modelRouteEndpointRefusal } from "./model-route-endpoint.js";
26
27
  import { buildDisplaySafeUrl, redactSecrets } from "./trace/redact.js";
27
28
  import { allowlistedEnv } from "./server-secret-env.js";
28
- import { ENV_NAME_ALLOWLIST_KNOBS, describeAllowlistPostures } from "./env-name-allowlist-knobs.js";
29
+ import { ENV_NAME_ALLOWLIST_KNOBS, ENV_NAME_ALLOWLIST_KNOB_NAMES, describeAllowlistPostures } from "./env-name-allowlist-knobs.js";
29
30
  export { RETENTION_MODES, MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC } from "./config-types.js";
30
31
  function csv(name) {
31
32
  return (process.env[name] ?? "")
@@ -103,10 +104,11 @@ function defaultSkillsDir() {
103
104
  return "skills";
104
105
  }
105
106
  }
107
+ export const RETIRED_ENV_REFUSAL_CODE = "config.env_key_retired";
106
108
  const RETIRED_ENVS = [
107
109
  ["TIDB_HOST", "MYSQL_HOST", "renamed"], ["TIDB_PORT", "MYSQL_PORT", "renamed"], ["TIDB_USER", "MYSQL_USER", "renamed"],
108
110
  ["TIDB_PASSWORD", "MYSQL_PASSWORD", "renamed"], ["TIDB_DATABASE", "MYSQL_DATABASE", "renamed"], ["TIDB_POOL_SIZE", "MYSQL_POOL_SIZE", "renamed"],
109
- ["CONFIG_CENTER_URL", "SEMA_REGISTRY_URL", "renamed"], ["CONFIG_CENTER_TOKEN", "SEMA_REGISTRY_TOKEN", "renamed"],
111
+ ["CONFIG_CENTER_URL", "SEMA_REGISTRY_URL", "renamed"], ["CONFIG_CENTER_TOKEN", CENTER_CREDENTIAL_ENV.token, "renamed"],
110
112
  ["CONFIG_CENTER_DRY_RUN", "SEMA_REGISTRY_DRY_RUN", "renamed"], ["CONFIG_CENTER_WORKER", "SEMA_REGISTRY_WORKER", "renamed"],
111
113
  ["ANTHROPIC_BASEURL", "ANTHROPIC_BASE_URL", "renamed"],
112
114
  ["REGISTRY_ADMIN_TOKEN", "SEMA_REGISTRY_ADMIN_TOKEN", "renamed"],
@@ -119,22 +121,33 @@ const RETIRED_ENVS = [
119
121
  ["OA_ISSUE_LABELS", "the OA scenario was removed in 5.0.0", "removed"],
120
122
  ["APPROVAL_POLL_MS", "the OA poll-approval lane was removed in 5.0.0; the durable checkpoint lane is the only gate", "removed"],
121
123
  ["LEADER_CONFLICT_ROUNDS", "the bounded merge-conflict-resolution agent was deleted in 7.83.0 按 C-R63 删除,合不上交用户:leader 交回 result.conflict(分支 + 冲突文件),run 状态 needs_human", "removed"],
124
+ [
125
+ "MODEL_CONNECT_TIMEOUT_MS",
126
+ "MODEL_FIRST_BYTE_TIMEOUT_MS",
127
+ "replaced",
128
+ "since core 7.33.0 the TCP + TLS connect clock is the transport's own; the wait from sending the request to its response headers — " +
129
+ "what this key really bounded — is MODEL_FIRST_BYTE_TIMEOUT_MS (unset = core's own default, plus an upload allowance and one " +
130
+ "widened retry; 0 = no engine clock on that segment and the transport's header / body clocks off too — the TCP + TLS connect " +
131
+ "keeps the transport's own clock)",
132
+ ],
122
133
  ];
134
+ const RETIRED_ENV_SENTENCE = {
135
+ renamed: (hits) => `${hits.map(([old, to]) => `${old} → use ${to}`).join("; ")} — rename the env key(s); the value semantics are unchanged`,
136
+ replaced: (hits) => `${hits.map(([old, to, , why]) => `${old} → ${to} (${why})`).join("; ")} — set the replacement deliberately: ` +
137
+ `its value semantics differ from the retired key's, so do not copy the old value across unread`,
138
+ removed: (hits) => `${hits.map(([old, why]) => `${old} (${why})`).join("; ")} — remove the env key(s): the capability itself was retired, ` +
139
+ `there is no replacement name and no rename that restores it`,
140
+ };
141
+ function retiredEnvSentence(kind, hit) {
142
+ const ofKind = hit.filter((h) => h[2] === kind);
143
+ return ofKind.length > 0 ? [RETIRED_ENV_SENTENCE[kind](ofKind)] : [];
144
+ }
123
145
  function rejectRetiredEnvs() {
124
146
  const hit = RETIRED_ENVS.filter(([o]) => process.env[o] !== undefined && process.env[o] !== "");
125
147
  if (hit.length === 0)
126
148
  return;
127
- const renamed = hit.filter(([, , k]) => k === "renamed");
128
- const removed = hit.filter(([, , k]) => k === "removed");
129
- const parts = [];
130
- if (renamed.length > 0) {
131
- parts.push(`${renamed.map(([o, n]) => `${o} → use ${n}`).join("; ")} — rename the env key(s); the value semantics are unchanged`);
132
- }
133
- if (removed.length > 0) {
134
- parts.push(`${removed.map(([o, n]) => `${o} (${n})`).join("; ")} — remove the env key(s): the capability itself was retired, ` +
135
- `there is no replacement name and no rename that restores it`);
136
- }
137
- throw new Error(`retired env name(s) set ([2354] 兼容面全清,5.0.0 起拒启): ${parts.join(" | ")}.`);
149
+ const parts = Object.keys(RETIRED_ENV_SENTENCE).flatMap((kind) => retiredEnvSentence(kind, hit));
150
+ throw new Error(`retired env name(s) set [${RETIRED_ENV_REFUSAL_CODE}] ([2354] 兼容面全清,5.0.0 起拒启): ${parts.join(" | ")}.`);
138
151
  }
139
152
  export function numEnv(name, fallback) {
140
153
  return parseNumOrFail(name, env(name, fallback));
@@ -490,9 +503,8 @@ function parseNameListEnv(name, raw, opts) {
490
503
  return names;
491
504
  }
492
505
  const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
493
- const ENV_NAME_ALLOWLIST_KNOB_NAMES = new Set(Object.keys(ENV_NAME_ALLOWLIST_KNOBS));
494
506
  const ENV_NAME_ALLOWLIST_WITHHELD = "<withheld: entries are not echoed — a value pasted in place of a name can pass the name check>";
495
- function parseEnvNameAllowlist(knob, singleUserTurnkey) {
507
+ export function parseEnvNameAllowlist(knob, singleUserTurnkey) {
496
508
  const raw = process.env[knob];
497
509
  if (raw === undefined || raw === "")
498
510
  return undefined;
@@ -512,7 +524,7 @@ function parseEnvNameAllowlist(knob, singleUserTurnkey) {
512
524
  " — refusing to start rather than silently allowing a partial list (#157). The value is not echoed (a pasted NAME=value pair may carry a secret)." +
513
525
  ` Write ${knob}=NAME,NAME with bare variable names, e.g. ${spec.example}.`);
514
526
  });
515
- if (!singleUserTurnkey) {
527
+ if ("multiTenant" in spec && singleUserTurnkey !== true) {
516
528
  const posture = spec.multiTenant;
517
529
  switch (posture.kind) {
518
530
  case "ignored":
@@ -664,6 +676,36 @@ function optionalEnumKey(key, envName, raw, allowed) {
664
676
  }
665
677
  return { [key]: raw };
666
678
  }
679
+ function parseAutoModeClassifierTimeoutMs(raw) {
680
+ if (raw === undefined || raw === "")
681
+ return {};
682
+ const { minMs, maxMs } = AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS;
683
+ const n = /^[1-9]\d*$/.test(raw) ? Number(raw) : Number.NaN;
684
+ if (!Number.isSafeInteger(n) || n < minMs || n > maxMs) {
685
+ const shown = raw.length > 40 ? `${raw.slice(0, 40)}…(${raw.length} chars)` : raw;
686
+ throw new Error(`env AUTO_MODE_CLASSIFIER_TIMEOUT_MS=${JSON.stringify(shown)} must be a whole number of milliseconds in [${minMs}, ${maxMs}] ` +
687
+ `(decimal digits only; unset = core's own default). It caps ONE rung of the auto-mode classifier ladder — a rung past the cap ` +
688
+ `did not rule, and with no rung left the gated call is DENIED. "0" has no "off" meaning on this seat; a sub-second value is the ` +
689
+ `seconds-for-milliseconds slip; above ${maxMs} Node collapses the timer to 1ms. Refusing to boot rather than running the core ` +
690
+ `default while the operator believes their value is in force (#210).`);
691
+ }
692
+ return { autoModeClassifierTimeoutMs: n };
693
+ }
694
+ function parseFirstByteTimeoutMs(raw) {
695
+ if (raw === undefined || raw === "")
696
+ return {};
697
+ const { minMs, maxMs } = FIRST_BYTE_TIMEOUT_BOUNDS;
698
+ const n = /^(0|[1-9]\d*)$/.test(raw) ? Number(raw) : Number.NaN;
699
+ if (!Number.isSafeInteger(n) || n < minMs || n > maxMs) {
700
+ const shown = raw.length > 40 ? `${raw.slice(0, 40)}…(${raw.length} chars)` : raw;
701
+ throw new Error(`env MODEL_FIRST_BYTE_TIMEOUT_MS=${JSON.stringify(shown)} must be a number of milliseconds: a whole number in [${minMs}, ${maxMs}] ` +
702
+ `(decimal digits only; unset = core's own default). It bounds the wait from sending the request to its response headers; "0" = no engine clock on ` +
703
+ `that segment and the transport's header / body clocks off too (the TCP + TLS connect keeps the transport's own clock); ` +
704
+ `above ${maxMs} Node collapses the timer to 1ms. Refusing to boot rather ` +
705
+ `than running the core default while the operator believes their value is in force (#210).`);
706
+ }
707
+ return { firstByteTimeoutMs: n };
708
+ }
667
709
  function headerNameEnv(name, fallback) {
668
710
  const v = env(name, fallback);
669
711
  if (!/^[A-Za-z0-9_-]+$/.test(v))
@@ -1029,7 +1071,7 @@ function parseModelDomain() {
1029
1071
  }
1030
1072
  : undefined,
1031
1073
  resilience: {
1032
- connectTimeoutMs: numEnv("MODEL_CONNECT_TIMEOUT_MS", String(RESILIENCE_DEFAULTS.connectTimeoutMs.value)),
1074
+ ...parseFirstByteTimeoutMs(process.env.MODEL_FIRST_BYTE_TIMEOUT_MS),
1033
1075
  firstTokenTimeoutMs: numEnv("MODEL_FIRST_TOKEN_TIMEOUT_MS", String(RESILIENCE_DEFAULTS.firstTokenTimeoutMs.value)),
1034
1076
  idleTimeoutMs: numEnv("MODEL_IDLE_TIMEOUT_MS", String(RESILIENCE_DEFAULTS.idleTimeoutMs.value)),
1035
1077
  circuitBreaker: boolEnv("MODEL_CIRCUIT_BREAKER", RESILIENCE_DEFAULTS.circuitBreaker.value),
@@ -1167,7 +1209,6 @@ export function formatUnmatchableToolNames(source, bad) {
1167
1209
  return (`${source} names tool(s) that can never match a live tool (these lists are RAW whole-name comparisons — core 5.0.0 RB-476 removed the alias/auto-prefix folds): ` +
1168
1210
  bad.map((b) => `${presentToolName(b.name)} → ${b.guidance}`).join("; ")).replace(CONTROL_AND_BIDI_STRIP_RE, "");
1169
1211
  }
1170
- const MAX_TIMER_DELAY_MS = 2_147_483_647;
1171
1212
  function streamApprovalConfig(gates) {
1172
1213
  const cfg = {
1173
1214
  enabled: boolEnv("STREAM_APPROVAL_ENABLED", true),
@@ -1284,6 +1325,7 @@ function parseApprovalDomain(ctx) {
1284
1325
  })(process.env.MANUAL_MODE_SHELL_GATE),
1285
1326
  permissionsDisableAutoMode: boolEnv("PERMISSIONS_DISABLE_AUTO_MODE", false),
1286
1327
  ...optionalEnumKey("autoModeClassifierFallback", "AUTO_MODE_CLASSIFIER_FALLBACK", process.env.AUTO_MODE_CLASSIFIER_FALLBACK, ["main", "none"]),
1328
+ ...parseAutoModeClassifierTimeoutMs(process.env.AUTO_MODE_CLASSIFIER_TIMEOUT_MS),
1287
1329
  ...optionalEnumKey("crossSessionInbound", "CROSS_SESSION_INBOUND", process.env.CROSS_SESSION_INBOUND, CROSS_SESSION_INBOUND_SETTINGS),
1288
1330
  ...optionalEnumKey("crossSessionDialogExpiry", "CROSS_SESSION_DIALOG_EXPIRY", process.env.CROSS_SESSION_DIALOG_EXPIRY, CROSS_SESSION_DIALOG_EXPIRY_VALUES),
1289
1331
  mcpElicitation: {
@@ -1400,6 +1442,22 @@ function lingerMsEnv(name, raw, defaultMs, disableHint) {
1400
1442
  }
1401
1443
  return n;
1402
1444
  }
1445
+ function parseDrainSecondSignalMinGapMs(raw) {
1446
+ if (raw === undefined || raw === "")
1447
+ return DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS;
1448
+ const { minMs, maxMs } = DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS;
1449
+ const n = /^(0|[1-9]\d*)$/.test(raw) ? Number(raw) : Number.NaN;
1450
+ if (!Number.isSafeInteger(n) || n < minMs || n > maxMs) {
1451
+ const shown = raw.length > 40 ? `${raw.slice(0, 40)}…(${raw.length} chars)` : raw;
1452
+ throw new Error(`env DRAIN_SECOND_SIGNAL_MIN_GAP_MS=${JSON.stringify(shown)} must be a whole number of milliseconds in [${minMs}, ${maxMs}] ` +
1453
+ `(decimal digits only; unset = ${DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS}). During a drain, a second SIGTERM / SIGINT hard-stops ` +
1454
+ `only when it arrives at least this long after the drain began — closer ones are the same beat delivered twice and are ` +
1455
+ `ignored. "0" turns that filter off (every second signal hard-stops, the 7.101.x behavior); above ${maxMs} (the ` +
1456
+ `DRAIN_GRACE_MS floor) a second signal could never count inside the shortest drain window. Refusing to boot rather ` +
1457
+ `than running the default while the operator believes their value is in force (#210).`);
1458
+ }
1459
+ return n;
1460
+ }
1403
1461
  function parseMemoryEmbedder(ctx) {
1404
1462
  const endpoint = process.env.MEMORY_EMBEDDER_ENDPOINT || undefined;
1405
1463
  const model = process.env.MEMORY_EMBEDDER_MODEL || undefined;
@@ -1926,7 +1984,7 @@ function parseOrchestrationDomain(ctx) {
1926
1984
  export const LIMITS_HTTP_FLOORS = {
1927
1985
  attachmentMaxBytes: 1024,
1928
1986
  attachmentUnboundTtlMs: 60_000,
1929
- drainGraceMs: 10_000,
1987
+ drainGraceMs: DRAIN_GRACE_FLOOR_MS,
1930
1988
  };
1931
1989
  function parseLimitsHttpDomain() {
1932
1990
  return {
@@ -1957,6 +2015,7 @@ function parseLimitsHttpDomain() {
1957
2015
  })(),
1958
2016
  },
1959
2017
  drainGraceMs: clampEnvWithWarn("DRAIN_GRACE_MS", String(10 * 60 * 1000), LIMITS_HTTP_FLOORS.drainGraceMs),
2018
+ drainSecondSignalMinGapMs: parseDrainSecondSignalMinGapMs(process.env.DRAIN_SECOND_SIGNAL_MIN_GAP_MS),
1960
2019
  sighupIdleGraceMs: clampEnvWithWarn("SIGHUP_IDLE_GRACE_MS", String(120_000), 5_000),
1961
2020
  ...parentPidEnv(),
1962
2021
  engineAutoExit: boolEnv("ENGINE_AUTO_EXIT", false),
@@ -2021,7 +2080,8 @@ function parseGitApiKind(raw, baseUrl) {
2021
2080
  }
2022
2081
  return kind;
2023
2082
  }
2024
- function parseIntegrationsDomain() {
2083
+ function parseIntegrationsDomain(ctx) {
2084
+ const { requirePrincipal } = ctx;
2025
2085
  return {
2026
2086
  pluginsAllowHosts: (process.env.PLUGINS_ALLOW_HOSTS ?? "github.com").split(",").map((h) => h.trim()).filter(Boolean),
2027
2087
  configBootFetchBudgetMs: clampEnvWithWarn("CONFIG_BOOT_FETCH_BUDGET_MS", "1500", 0),
@@ -2030,17 +2090,19 @@ function parseIntegrationsDomain() {
2030
2090
  gitApiKind: parseGitApiKind(process.env.GIT_API_KIND, process.env.GIT_API_BASEURL),
2031
2091
  defaultScenario: env("DEFAULT_SCENARIO", "code"),
2032
2092
  skillsDir: env("SKILLS_DIR", defaultSkillsDir()),
2033
- configCenter: process.env.SEMA_REGISTRY_URL ? parseConfigCenter(env("SEMA_REGISTRY_URL")) : undefined,
2093
+ centerCredentialRenewLeadMs: parseCenterCredentialRenewLeadMs(process.env.CENTER_CREDENTIAL_RENEW_LEAD_MS),
2094
+ configCenter: process.env.SEMA_REGISTRY_URL ? parseConfigCenter(env("SEMA_REGISTRY_URL"), requirePrincipal) : undefined,
2034
2095
  configProvider: process.env.CONFIG_PROVIDER || undefined,
2035
2096
  configLocalDir: process.env.CONFIG_LOCAL_DIR || undefined,
2036
2097
  a2aServe: parseA2aServe(),
2037
2098
  };
2038
2099
  }
2039
- function parseConfigCenter(baseUrl) {
2100
+ function parseConfigCenter(baseUrl, requirePrincipal) {
2040
2101
  const worker = process.env.SEMA_REGISTRY_WORKER || undefined;
2102
+ const declared = readCenterCredentialDeclaration();
2041
2103
  return {
2042
2104
  baseUrl,
2043
- credential: createCenterCredentialSource(readCenterCredentialDeclaration(), worker),
2105
+ credential: createCenterCredentialSource(declared.from === "file" ? { ...declared, multiTenant: requirePrincipal } : declared, worker),
2044
2106
  dryRun: boolEnv("SEMA_REGISTRY_DRY_RUN", false),
2045
2107
  worker,
2046
2108
  };
@@ -2141,7 +2203,7 @@ const APPROVAL_GROUP_KEYS = [
2141
2203
  "approvalRequire", "approvalDeny", "approvalTimeoutSec", "approvalAutoBudget", "approvalNeverAuto",
2142
2204
  "approvalHmacKeys", "durableApproval", "durableApprovalSource", "streamAskWindowMsSource", "directApprovalDoor", "directDoorActive", "resourceSuspend",
2143
2205
  "resourceSuspendTtlSec", "resourceSuspendTotalTokens", "resourceSuspendTotalBudgetUsd", "resourceSuspendMaxSlices", "askQuestionEnabled", "questionThrottle", "toolApprovalEnabled", "permissionRulesEnabled", "permissionRulesEnabledExplicit", "streamAskWindowMarginMs", "streamApproval", "unattendedApprovalPolicy", "mcpElicitation",
2144
- "sensitiveWritePatterns", "writeProtectedPaths", "manualModeShellGate", "permissionsDisableAutoMode", "autoModeClassifierFallback", "crossSessionInbound", "crossSessionDialogExpiry",
2206
+ "sensitiveWritePatterns", "writeProtectedPaths", "manualModeShellGate", "permissionsDisableAutoMode", "autoModeClassifierFallback", "autoModeClassifierTimeoutMs", "crossSessionInbound", "crossSessionDialogExpiry",
2145
2207
  ];
2146
2208
  const MEMORY_GROUP_KEYS = [
2147
2209
  "memoryEngineEnabled", "memoryEngineDir", "memoryEngineRemoteLaneAllowed", "memoryEngineBackend", "memoryScope",
@@ -2166,7 +2228,7 @@ const ORCHESTRATION_GROUP_KEYS = [
2166
2228
  ];
2167
2229
  const LIMITS_HTTP_GROUP_KEYS = [
2168
2230
  "port", "attachmentOrphanGraceMs", "workspaceFileMaxBytes", "attachmentMaxBytes", "attachmentMimeAllowlist",
2169
- "attachmentUnboundTtlMs", "infraCostRates", "drainGraceMs", "sighupIdleGraceMs", "parentPid",
2231
+ "attachmentUnboundTtlMs", "infraCostRates", "drainGraceMs", "drainSecondSignalMinGapMs", "sighupIdleGraceMs", "parentPid",
2170
2232
  "engineAutoExit", "engineLingerMs", "engineOrphanLingerMs",
2171
2233
  "rateLimitPerMin", "maxTaskCostUsd",
2172
2234
  "maxTaskTokens", "maxPrincipalCostUsd", "costQuotaWindowSec", "reapIntervalSec", "runStaleSec", "toolResultTtlSec",
@@ -2174,7 +2236,7 @@ const LIMITS_HTTP_GROUP_KEYS = [
2174
2236
  const OBSERVABILITY_GROUP_KEYS = ["metricsToken", "traceToken", "toolTrace", "traceThinking", "logLevel", "otel"];
2175
2237
  const INTEGRATIONS_GROUP_KEYS = [
2176
2238
  "pluginsAllowHosts", "mcpServers", "a2aPeers", "a2aServe", "configBootFetchBudgetMs", "gitApiBaseUrl", "gitApiToken", "gitApiKind", "defaultScenario",
2177
- "skillsDir", "configCenter", "configProvider", "configLocalDir",
2239
+ "skillsDir", "centerCredentialRenewLeadMs", "configCenter", "configProvider", "configLocalDir",
2178
2240
  ];
2179
2241
  export const CONFIG_GROUP_KEYS = {
2180
2242
  store: STORE_GROUP_KEYS,
@@ -2234,7 +2296,7 @@ export function loadConfig() {
2234
2296
  const orchestration = parseOrchestrationDomain({ postureOn, singleUserTurnkey });
2235
2297
  const limitsHttp = parseLimitsHttpDomain();
2236
2298
  const observability = parseObservabilityDomain();
2237
- const integrations = parseIntegrationsDomain();
2299
+ const integrations = parseIntegrationsDomain({ requirePrincipal });
2238
2300
  const { directApprovalDoor, durableApproval: dgDurable, approvalHmacKeys: dgHmacKeys } = approval;
2239
2301
  const { principalJwtPubkeys, principalJwtIss, principalJwtAud, operatorPrincipals: dgOperators, authTokens } = auth;
2240
2302
  const { enabled: imageBakesEnabled, runnerPrincipal: bakeRunnerPrincipal } = orchestration.imageBakes;
@@ -50,7 +50,7 @@ export declare const DEVICE_RATE_LIMIT_CODE: "limit.rate_exceeded";
50
50
  * 排空/停机拒绝复用**既有**的通用码(`draining`,`docs/ASSISTANT-WIRE-CONTRACT.md` 附录 A 已收录,503)。
51
51
  *
52
52
  * 🔴 刻意不铸 `device.draining`([ref]/[ref]):§4.8 的 device 闭集**冻结**且无 draining 成员,而排空
53
- * 语义面全部署早有属主词 —— HTTP 计费提交拒臂(`sendError(res, 503, "draining", …)`,SDK spec 逐字引用
53
+ * 语义面全部署早有属主词 —— HTTP 计费提交拒臂(`http/readiness.ts` 的 `billableRefusalOf` 唯一铸点,S-739 前是 `sendError(res, 503, "draining", …)`;SDK spec 逐字引用
54
54
  * 的冻结字面)、drain 期新 upgrade 拒(503)、WS hello 拒帧 / bye 帧的 `draining` 分支键。为同一语义再铸
55
55
  * `device.*` 同义码 = 消费端要认两套(上面 rate-limit 条的同一判据);且 `device.draining` 在 HTTP 层
56
56
  * 零发送点,写进附录 A 即幽灵行(`error-code-catalog-live` 的「文档多码」臂当场红)。
@@ -4,31 +4,45 @@
4
4
  * 解析规则(未设 / 形错拒启 / 姿态 / 生效名单)的全文在 `config.ts` `parseEnvNameAllowlist` 的头注;本表只装**各旋钮自己的**事实,
5
5
  * 不是解析规则:
6
6
  * · `example` 进拒句;
7
- * · `multiTenant` = `REQUIRE_PRINCIPAL=true` 下名单作不作数。
7
+ * · `multiTenant` = `REQUIRE_PRINCIPAL=true` 下名单作不作数 —— **只有服务进程读的旋钮有这一列**(姿态是服务进程的概念):
8
8
  * - `LSP_ENV_ALLOW` ⇒ ignored:host 腿语言服务器在多租户姿态下本来就不起,名单无处作数,设了要说出来(S-699)。
9
9
  * - 两只沙箱名单 ⇒ honored:S-713 照录修前语义(任何姿态下都转发),S-715 主会话裁定维持 —— 沙箱 env 是部署方为隔离沙箱显式配的
10
10
  * 带外凭据(e2b / docker 车道本来就是多租户 fleet 的主形),与 LSP 那条「本机子进程」的形不同;多租户下起服说一句。
11
- * 表是 `Record<旋钮, …>`:闭集加一只旋钮而不补行 = 编译红。
11
+ * · `BAKE_ENV_ALLOW`(S-684 残余,7.103.0)由 **bake-runner 进程**读(构建宿主守护进程:没有主体、没有租户 ⇒ 没有姿态列,
12
+ * 不进 {@link describeAllowlistPostures} 的姿态句)。它与三只服务侧旋钮同一只解析器、同一只「点名放行」谓词、同一条「原文按凭据类」律。
13
+ * 表是 `Record<旋钮, …>`:闭集加一只旋钮而不补行 = 编译红;行形随读它的进程定(服务进程 ⇒ 必须写姿态列)。
12
14
  *
13
15
  * 🔴 **姿态列只在这里写一次**([ref];7.101.0 合并复审 O9):凡要对人说「多租户下哪只旋钮忽略、哪只照转发」的句子,一律取
14
16
  * {@link describeAllowlistPostures} 的读数 —— 今天三处:`config_env_forwarded_multi_tenant.note`(`config.ts` 解析器)、LSP 剥除现场的
15
17
  * 出路句(`observability/secret-env-scrub.ts`)、配置目录 `LSP_ENV_ALLOW` 行(`config-catalog.ts`)。修前三处各手抄一遍,表改一格它们
16
18
  * 当场撒谎。为什么是纯叶:`config-catalog.ts` 被 `config.ts` 值引,表留在 `config.ts` 会成装载环。
17
19
  */
18
- export type EnvNameAllowlistKnob = "LSP_ENV_ALLOW" | "E2B_SANDBOX_ENV" | "DOCKER_SANDBOX_ENV";
20
+ /** 服务进程(`config.ts` `loadConfig`)读的名单型旋钮 —— 有多租户姿态。 */
21
+ export type ServerEnvNameAllowlistKnob = "LSP_ENV_ALLOW" | "E2B_SANDBOX_ENV" | "DOCKER_SANDBOX_ENV";
22
+ /** bake-runner 进程(`bake-runner/main.ts` `loadEnv`)读的名单型旋钮 —— 构建宿主守护进程没有主体 / 租户,没有姿态。 */
23
+ export type RunnerEnvNameAllowlistKnob = "BAKE_ENV_ALLOW";
24
+ export type EnvNameAllowlistKnob = ServerEnvNameAllowlistKnob | RunnerEnvNameAllowlistKnob;
19
25
  export type EnvNameAllowlistPosture = {
20
26
  readonly kind: "honored";
21
27
  } | {
22
28
  readonly kind: "ignored";
23
29
  readonly why: string;
24
30
  };
31
+ /** 服务侧旋钮一行:拒句示例 + 多租户姿态。 */
32
+ export interface ServerEnvNameAllowlistKnobRow {
33
+ readonly example: string;
34
+ readonly multiTenant: EnvNameAllowlistPosture;
35
+ }
36
+ /** runner 旋钮一行:只有拒句示例(没有姿态可言)。 */
37
+ export interface RunnerEnvNameAllowlistKnobRow {
38
+ readonly example: string;
39
+ }
25
40
  export type EnvNameAllowlistKnobTable = {
26
- readonly [K in EnvNameAllowlistKnob]: {
27
- readonly example: string;
28
- readonly multiTenant: EnvNameAllowlistPosture;
29
- };
41
+ readonly [K in EnvNameAllowlistKnob]: K extends ServerEnvNameAllowlistKnob ? ServerEnvNameAllowlistKnobRow : RunnerEnvNameAllowlistKnobRow;
30
42
  };
31
43
  export declare const ENV_NAME_ALLOWLIST_KNOBS: EnvNameAllowlistKnobTable;
44
+ /** 名单型旋钮的全部名字(四只;表键派生)—— 两处用:解析器的「点名名单型旋钮 = 形错」、剥除的「名单原文按凭据类」臂。 */
45
+ export declare const ENV_NAME_ALLOWLIST_KNOB_NAMES: ReadonlySet<string>;
32
46
  /**
33
47
  * 多租户姿态句 —— **由表派生**,不许在别处手写。形:`under REQUIRE_PRINCIPAL=true: <旋钮> is ignored; <旋钮> / <旋钮> are forwarded as configured`
34
48
  * (按 {@link POSTURE_PHRASE} 的 kind 序分组、组内按表序;空组不出现)。只有旋钮名与姿态,不带任何名单内容。
@@ -5,15 +5,20 @@ export const ENV_NAME_ALLOWLIST_KNOBS = {
5
5
  },
6
6
  E2B_SANDBOX_ENV: { example: "E2B_SANDBOX_ENV=GIT_TOKEN,NPM_TOKEN", multiTenant: { kind: "honored" } },
7
7
  DOCKER_SANDBOX_ENV: { example: "DOCKER_SANDBOX_ENV=GIT_TOKEN,NPM_TOKEN", multiTenant: { kind: "honored" } },
8
+ BAKE_ENV_ALLOW: { example: "BAKE_ENV_ALLOW=REGISTRY_PASSWORD,NPM_TOKEN" },
8
9
  };
10
+ export const ENV_NAME_ALLOWLIST_KNOB_NAMES = new Set(Object.keys(ENV_NAME_ALLOWLIST_KNOBS));
9
11
  const POSTURE_PHRASE = {
10
12
  ignored: { one: "is ignored", many: "are ignored" },
11
13
  honored: { one: "is forwarded as configured", many: "are forwarded as configured" },
12
14
  };
13
15
  export function describeAllowlistPostures(table = ENV_NAME_ALLOWLIST_KNOBS) {
14
- const knobs = Object.keys(table);
16
+ const postured = Object.keys(table).flatMap((k) => {
17
+ const row = table[k];
18
+ return "multiTenant" in row ? [{ knob: k, kind: row.multiTenant.kind }] : [];
19
+ });
15
20
  const groups = Object.keys(POSTURE_PHRASE).flatMap((kind) => {
16
- const named = knobs.filter((k) => table[k].multiTenant.kind === kind);
21
+ const named = postured.filter((p) => p.kind === kind).map((p) => p.knob);
17
22
  if (named.length === 0)
18
23
  return [];
19
24
  const phrase = POSTURE_PHRASE[kind];
@@ -4,16 +4,28 @@
4
4
  * - **可选分层(D3)**:仅当 center 已配置(sema-registry lane 的 baseUrl+token+worker)∧ `FLEET_ADVERTISE_ADDRESS`
5
5
  * 显式声明时才启动 —— 不配 center 的部署零行为变化。address 不自猜(k8s pod IP / docker alias / 127.0.0.1:port
6
6
  * 只有部署环境自己知道,故用显式 env)。
7
- * - **announce=注册=心跳同形状幂等 upsert**:boot 后 POST 一次 + 每 heartbeatMs 续;`ready` 今 = `!draining`(实现只读
8
- * `isDraining()`,announce 本身在 boot 之后才起;S-740 起与 /health 就绪同一只谓词 —— 车OC 6b 注释改真,实现归 S-740)。
9
- * - **draining 翻转即刻再 announce**(第一时间自摘流,比任何观测都快);退出前 DELETE(显式注销,幂等)。
7
+ * - **announce=注册=心跳同形状幂等 upsert**:boot 后(listen 之后)POST 一次 + 每 heartbeatMs 续。
8
+ * `ready` / `draining` 两字段由**同一只**就绪谓词的同一次读铸(S-740:`http/readiness.ts` 的 `readinessOf`,
9
+ * 与 `/health.ready`、`GET /readyz`、计费提交门同源):`ready = verdict.ready`(= roster 已落 ∧ ¬draining),
10
+ * `draining = reasons ∋ "draining"`。修前 `ready = !isDraining()`,不含 roster ⇒ roster 未落时心跳说「就绪」
11
+ * 而计费提交在 503。
12
+ * - **`restart?`(S-741)**:在场 ⇔ 本副本必须重启才能应用配置 —— 取自 `/health.restart` **同一只**取值口(装配层接
13
+ * `deployment.restartState`,config-center 的 `restartState()`),同一个对象,形 = settings-schema `RestartSignal`;
14
+ * 缺席即键缺席(中心按行整体替换,不合并 —— 一条不带它的心跳就是「不再需要」)。
15
+ * - **`modelRefusals?`(S-768)**:在场 ⇔ 本副本对最近一次被判的中心候选目录有可报的拒(逐条名 → 码 / 模型面组拒码)—— 取自
16
+ * `/health.modelRefusals` **同一只**取值口(装配层接 `deployment.modelRefusalsState`),同一个对象,形 = settings-schema
17
+ * `ModelRefusalsSignal`;缺席即键缺席(同 `restart`)。
18
+ * - **自报状态翻转即刻再 announce**(第一时间自摘流 / 自挂流 / 报重启 / 报拒因,比任何观测都快):draining 翻转由收尾链直调
19
+ * `announceNow`;roster 落地、restart 信号与模型拒因变化由装配层把 config-center 的 `onSelfReportChange` 接到 `announceNow`
20
+ * (两处 = 两类输入各自的唯一写者,触发都是 `announceNow`)。退出前 DELETE(显式注销,幂等)。
10
21
  * - **usage 批报**:tracer 的 `brain.call` 已带 ALS principal —— FleetUsageAccumulator 按 principal×model
11
22
  * 累计,每窗口(默认 60s,半开 [windowStartMs, windowEndMs))POST /api/fleet/usage。幂等键
12
23
  * (worker,instanceId,windowStartMs) 重发安全;**发送失败 → merge 回累计器**,并入下一窗(失败窗从未入账,
13
24
  * 向前合并不会双计 —— 只延迟,不丢量)。
14
25
  * - 全部 fire-and-forget + swallow-guarded:fleet 面是运维旁路,绝不影响任务面;网络错误只 warn 一次/连败类。
15
26
  */
16
- import { UsageReport } from "@sema-agent/settings-schema/fleet";
27
+ import { UsageReport, type ModelRefusalsSignal, type RestartSignal } from "@sema-agent/settings-schema/fleet";
28
+ import type { ReadinessVerdict } from "./http/readiness.js";
17
29
  import { type CenterEndpoint } from "./config-center/center-request.js";
18
30
  /** Bound on distinct principal×model keys per window — a runaway/multi-tenant burst collapses into
19
31
  * `__other__` instead of growing unbounded (same posture as budget.ts cardinalityGuard). */
@@ -65,8 +77,15 @@ export interface FleetClientOpts {
65
77
  /** Reachable base URL of THIS instance — the operator's explicit declaration (`FLEET_ADVERTISE_ADDRESS`). */
66
78
  advertiseAddress: string;
67
79
  version: string;
68
- /** Live drain flag (drainState) — read at every announce. */
69
- isDraining: () => boolean;
80
+ /** S-740:就绪判(装配层接 `readinessOf(deployment)` —— HTTP 面读的同一个 deps 组)。每条公告现读**一次**,
81
+ * `ready` 与 `draining` 两字段都由这一次读铸(不会撕裂成 `ready:true` + `draining:true`)。 */
82
+ readiness: () => ReadinessVerdict;
83
+ /** S-741:restart 信号(装配层接 `/health` 同一只取值口 `deployment.restartState`)。每条公告现读一次;`undefined` ⇒ 公告不带
84
+ * `restart` 键。必填:心跳面没有「不知道要不要重启」这一形。 */
85
+ restart: () => RestartSignal | undefined;
86
+ /** S-768:模型拒因信号(装配层接 `/health` 同一只取值口 `deployment.modelRefusalsState`)。每条公告现读一次;`undefined` ⇒ 公告不带
87
+ * `modelRefusals` 键。必填:与 `restart` 同 —— 心跳面没有「不知道有没有拒」这一形(老引擎不发这个键,中心按 `version` 判「不可知」)。 */
88
+ modelRefusals: () => ModelRefusalsSignal | undefined;
70
89
  usage?: FleetUsageAccumulator;
71
90
  heartbeatMs?: number;
72
91
  usageWindowMs?: number;
@@ -78,7 +97,8 @@ export interface FleetClientOpts {
78
97
  fetchImpl?: typeof fetch;
79
98
  }
80
99
  export interface FleetClient {
81
- /** Immediate out-of-cycle announce — call when `draining` flips (self-remove from routing FIRST). */
100
+ /** Immediate out-of-cycle announce — call when a self-reported input flips (draining: self-remove from routing FIRST;
101
+ * roster landed: become routable without waiting a heartbeat; restart signal changed: S-741). */
82
102
  announceNow: () => Promise<void>;
83
103
  /** Stop timers + best-effort DELETE deregistration (bounded; never throws). */
84
104
  stop: () => Promise<void>;
@@ -81,17 +81,24 @@ export function startFleetClient(opts) {
81
81
  const send = (route, method, json) => centerFetch(opts.center, { kind: route }, { method, json, timeoutMs: FLEET_CLIENT_FETCH_TIMEOUT_MS, fetchImpl: f });
82
82
  let stopped = false;
83
83
  let failStreak = 0;
84
- const announceNow = async () => {
85
- const body = {
86
- instanceId: opts.instanceId,
87
- worker: opts.center.worker,
88
- address: opts.advertiseAddress,
89
- version: opts.version,
90
- ready: !opts.isDraining(),
91
- draining: opts.isDraining(),
92
- heartbeatMs,
93
- };
84
+ const announceOnce = async () => {
85
+ if (stopped)
86
+ return;
94
87
  try {
88
+ const verdict = opts.readiness();
89
+ const restart = opts.restart();
90
+ const modelRefusals = opts.modelRefusals();
91
+ const body = {
92
+ instanceId: opts.instanceId,
93
+ worker: opts.center.worker,
94
+ address: opts.advertiseAddress,
95
+ version: opts.version,
96
+ ready: verdict.ready,
97
+ draining: !verdict.ready && verdict.reasons.includes("draining"),
98
+ heartbeatMs,
99
+ ...(restart !== undefined ? { restart } : {}),
100
+ ...(modelRefusals !== undefined ? { modelRefusals } : {}),
101
+ };
95
102
  const { res } = await send("fleet-instances", "POST", body);
96
103
  if (!res.ok)
97
104
  throw new Error(`announce ${res.status}${await rejectionSnippet(res)}`);
@@ -105,6 +112,26 @@ export function startFleetClient(opts) {
105
112
  opts.logger?.warn("fleet_announce_failed", { error: err instanceof Error ? err.message : String(err) });
106
113
  }
107
114
  };
115
+ let inFlight;
116
+ let again = false;
117
+ const announceNow = () => {
118
+ if (inFlight !== undefined) {
119
+ again = true;
120
+ return inFlight;
121
+ }
122
+ inFlight = (async () => {
123
+ try {
124
+ do {
125
+ again = false;
126
+ await announceOnce();
127
+ } while (again && !stopped);
128
+ }
129
+ finally {
130
+ inFlight = undefined;
131
+ }
132
+ })();
133
+ return inFlight;
134
+ };
108
135
  let windowStartMs = Date.now();
109
136
  const flushUsage = async () => {
110
137
  if (!opts.usage)
@@ -131,13 +158,17 @@ export function startFleetClient(opts) {
131
158
  hb.unref?.();
132
159
  const uw = opts.usage ? setInterval(() => void flushUsage(), usageWindowMs) : undefined;
133
160
  uw?.unref?.();
161
+ let stopping = false;
134
162
  const stop = async () => {
135
- if (stopped)
163
+ if (stopping)
136
164
  return;
137
- stopped = true;
165
+ stopping = true;
138
166
  clearInterval(hb);
139
167
  if (uw)
140
168
  clearInterval(uw);
169
+ while (inFlight !== undefined)
170
+ await inFlight;
171
+ stopped = true;
141
172
  await flushUsage();
142
173
  try {
143
174
  await send("fleet-instances", "DELETE", { instanceId: opts.instanceId, worker: opts.center.worker });
@@ -116,7 +116,7 @@ export declare class FleetLeaseManager {
116
116
  /**
117
117
  * Env/config gate(与 startFleetClientFromEnv 同 D3 分层姿势,但 lease 不需要 FLEET_ADVERTISE_ADDRESS ——
118
118
  * 它是出站消费面,不是入站可达性声明):sema-registry lane 配齐(baseUrl + **声明了**凭证 + worker)即启;任一缺失 →
119
- * undefined,非 fleet 部署零行为变化。S-651:按**声明**装、不按内容装 —— `SEMA_REGISTRY_TOKEN_FILE` 在场而
119
+ * undefined,非 fleet 部署零行为变化。S-651:按**声明**装、不按内容装 —— `SEMA_REGISTRY_TOKEN_FILE`(`CENTER_CREDENTIAL_ENV.tokenFile`)在场而
120
120
  * 文件此刻不可读,管理器照样装上(每次协商取值,失败走响亮的鉴权失败臂;文件恢复即恢复)。
121
121
  */
122
122
  export declare function createFleetLeaseFromEnv(cfg: {
@@ -9,7 +9,7 @@
9
9
  * `scrubServerSecrets` 的结果)。
10
10
  *
11
11
  * ## 规则(一句)
12
- * 语言服务器子进程的 env = {@link scrubServerSecrets}(process.env) ∪ 部署方点名放回的键(S-699,见下节;缺省空名单)——
12
+ * 语言服务器子进程的 env = `scrubServerSecrets`(process.env) ∪ 部署方点名放回的键(S-699,见下节;缺省空名单)——
13
13
  * 剥除与模型可驱动的 host shell(`inheritEnv:"scrub"`)
14
14
  * 和 project-memory 的 git 子进程**同一只函数**:目录 `type:"secret"` 键按名剥 ∘ core 形状规则按形剥。只交 core 的默认
15
15
  * 会漏掉形状规则认不出的本仓凭据键(今天是 `OTEL_EXPORTER_OTLP_HEADERS`),那正是 S-684 当初的病形。
@@ -23,8 +23,9 @@
23
23
  * ## 点名放行(S-699,`LSP_ENV_ALLOW`)
24
24
  * 剥除会连语言服务器自己的真实依赖一起剥掉(`.npmrc` 的 `${NPM_TOKEN}` 插值、rust-analyzer `cargo metadata` 私有 registry 的
25
25
  * `CARGO_REGISTRIES_<名>_TOKEN` —— 形状规则按名判,分不出「给语言服务器用的令牌」与「编排器的令牌」)。出路 = 部署方点名:
26
- * env = {@link scrubServerSecrets}(process.env) 之上把 `config.lspEnvAllow` 点到的键经 {@link allowlistedEnv}(与 host shell
27
- * `inheritEnv:[names]` 同一只谓词)从原 env 原样放回;没点到的照旧剥。名单由 `config.ts` 解析(形错拒启;
26
+ * env = `scrubServerSecrets`(process.env) 之上把 `config.lspEnvAllow` 点到的键经 `allowlistedEnv`(与 host shell
27
+ * `inheritEnv:[names]` 同一只谓词)从原 env 原样放回;没点到的照旧剥 —— 7.103.0 起这件组合只有一只:{@link hostSubprocessEnv}
28
+ * (`server-secret-env.ts`,bake-runner 的 `BAKE_ENV_ALLOW` 调同一只)。名单由 `config.ts` 解析(形错拒启;
28
29
  * `REQUIRE_PRINCIPAL=true` 下落空名单并 warn 一次),本函数只读 `config.lspEnvAllow`,不在这里再判一次姿态。
29
30
  * 放回的键不是剥除 ⇒ 不进 `secret_env_scrubbed` 的键表与计数;剥掉的键经既有现场 `server.lsp.spawn-env` 一行 warn
30
31
  * (带当前放行名单与出路句),这一行就是「剥了什么」的用户可见面 —— 语言服务器侧从不报。
@@ -1,11 +1,7 @@
1
1
  import { NodeLspManager } from "@sema-agent/core";
2
- import { recordSecretEnvScrub } from "./observability/secret-env-scrub.js";
3
- import { allowlistedEnv, scrubServerSecrets } from "./server-secret-env.js";
2
+ import { hostSubprocessEnv } from "./server-secret-env.js";
4
3
  export function createHostLspManager(config, log) {
5
- const scrubFindings = [];
6
- const composed = { ...scrubServerSecrets(process.env, scrubFindings), ...allowlistedEnv(config.lspEnvAllow, process.env) };
7
- const findings = scrubFindings.filter((f) => composed[f.key] === undefined);
8
- recordSecretEnvScrub("server.lsp.spawn-env", findings, config.lspEnvAllow);
4
+ const composed = hostSubprocessEnv({ site: "server.lsp.spawn-env", allow: config.lspEnvAllow });
9
5
  const env = {};
10
6
  for (const [k, v] of Object.entries(composed))
11
7
  if (v !== undefined)
@@ -6,6 +6,7 @@ import { APPROVAL_GATE_KINDS } from "../tool-approval.js";
6
6
  import { createWallClockJumpGuard } from "../wall-clock-jump-guard.js";
7
7
  import { hasServiceAuth } from "./dispatch.js";
8
8
  import { gatedPrincipal } from "./principal-gate.js";
9
+ import { readinessOf, billableRefusalOf } from "./readiness.js";
9
10
  export function createApprovalSweeps(ctx) {
10
11
  const { deps, resumeCheckpoint, mayDiscloseRowError } = ctx;
11
12
  const denySweepClockGuard = createWallClockJumpGuard({
@@ -71,26 +72,10 @@ export function createApprovalSweeps(ctx) {
71
72
  if (!hasServiceAuth(deps.config) && !deps.config.allowUnauthedWrites) {
72
73
  return { status: 503, body: { error: "this worker requires a service auth token (set SERVICE_AUTH_TOKEN) before accepting task submissions", errorCode: "auth.service_token_required" } };
73
74
  }
74
- if (deps.drainState?.draining) {
75
- return {
76
- status: 503,
77
- body: {
78
- error: "draining",
79
- errorCode: "draining",
80
- message: "this instance is draining for shutdown/upgrade — retry against the replacement instance",
81
- ...(deps.drainState.reason ? { reason: deps.drainState.reason } : {}),
82
- },
83
- };
84
- }
85
- if (deps.modelReady && !deps.modelReady()) {
86
- return {
87
- status: 503,
88
- body: {
89
- error: "model_roster_pending",
90
- errorCode: "state.model_roster_pending",
91
- message: "this worker has no model yet (waiting for the first effective-config pull to land the roster) — retry shortly",
92
- },
93
- };
75
+ const readiness = readinessOf(deps);
76
+ if (!readiness.ready) {
77
+ const refusal = billableRefusalOf(readiness, deps.drainState?.reason);
78
+ return { status: refusal.status, body: refusal.body };
94
79
  }
95
80
  if (deps.costQuota && req.httpReq) {
96
81
  const quotaPrincipal = gatedPrincipal(req.httpReq, deps.config);