@sema-agent/server 7.102.0 → 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 (55) hide show
  1. package/MIGRATION.md +61 -0
  2. package/README.md +3 -3
  3. package/README.zh-CN.md +3 -3
  4. package/USAGE.md +99 -6
  5. package/dist/background-session-reap.d.ts +4 -1
  6. package/dist/bake-runner/main.d.ts +21 -3
  7. package/dist/bake-runner/main.js +18 -11
  8. package/dist/boot/config-center.d.ts +7 -2
  9. package/dist/boot/config-center.js +18 -4
  10. package/dist/boot/stage-01-config.js +2 -0
  11. package/dist/boot/stage-10-http-server.js +6 -1
  12. package/dist/brain.d.ts +28 -8
  13. package/dist/brain.js +20 -4
  14. package/dist/capabilities/center-plugins.d.ts +9 -3
  15. package/dist/capabilities/center-plugins.js +5 -10
  16. package/dist/config-catalog.js +9 -5
  17. package/dist/config-center/apply-effective.d.ts +16 -0
  18. package/dist/config-center/apply-effective.js +23 -11
  19. package/dist/config-center/mcp-revocation.d.ts +2 -2
  20. package/dist/config-center/mcp-revocation.js +1 -1
  21. package/dist/config-center/restart-signal.d.ts +0 -10
  22. package/dist/config-center/restart-signal.js +2 -12
  23. package/dist/config-types.d.ts +35 -17
  24. package/dist/config-types.js +3 -3
  25. package/dist/config.d.ts +7 -0
  26. package/dist/config.js +43 -17
  27. package/dist/env-name-allowlist-knobs.d.ts +21 -7
  28. package/dist/env-name-allowlist-knobs.js +7 -2
  29. package/dist/fleet-client.d.ts +9 -3
  30. package/dist/fleet-client.js +2 -0
  31. package/dist/host-lsp-manager.d.ts +4 -3
  32. package/dist/host-lsp-manager.js +2 -6
  33. package/dist/http/dispatch.js +4 -0
  34. package/dist/http/server.d.ts +4 -0
  35. package/dist/leader/wire.d.ts +8 -1
  36. package/dist/leader/wire.js +4 -3
  37. package/dist/model-entry-refusal.d.ts +31 -9
  38. package/dist/model-entry-refusal.js +22 -10
  39. package/dist/model-route-endpoint.d.ts +10 -0
  40. package/dist/model-route-endpoint.js +6 -2
  41. package/dist/observability/secret-env-scrub.d.ts +12 -0
  42. package/dist/observability/secret-env-scrub.js +12 -0
  43. package/dist/outbound-dispatcher.d.ts +125 -0
  44. package/dist/outbound-dispatcher.js +211 -0
  45. package/dist/plugins/host-platform.d.ts +6 -1
  46. package/dist/plugins/remote-env-host.js +3 -11
  47. package/dist/project-memory.js +3 -6
  48. package/dist/run-local.js +2 -0
  49. package/dist/server-secret-env.d.ts +97 -24
  50. package/dist/server-secret-env.js +37 -1
  51. package/dist/trace/core-keyset-guard.d.ts +6 -1
  52. package/dist/trace/project.js +13 -0
  53. package/dist/trace/redact.d.ts +11 -0
  54. package/dist/trace/redact.js +1 -0
  55. package/package.json +4 -3
package/dist/brain.d.ts CHANGED
@@ -12,12 +12,14 @@ export declare const FAST_FAIL_MAX_RETRIES = 2;
12
12
  * Build the external "Brain" (LLM) from config.
13
13
  *
14
14
  * Composition (A1 + the 1.38 resilience stack, ordered timeout-innermost → breaker → failover-outermost):
15
- * - **Tiered timeouts** (1.38, `connectTimeoutMs`/`firstTokenTimeoutMs`) are passed into each
16
- * openai-compatible brain at creation — they bound "gateway never accepts the connection" and
17
- * "stream opened but hangs without a token" as retryable `[network]` failures. Default ON
18
- * (connect 30s per the fleet-audit recommendation; first-token 600s since S-202 — a self-hosted backend's long
19
- * prefill legitimately takes minutes to the first byte, and 120s turned that into a retry handing the
20
- * same prompt back to the same queue. env =0 disables; env =120000 restores the old posture).
15
+ * - **Tiered timeouts** (1.38; re-cut by core 7.33.0 along the segments of a call) are passed into each
16
+ * brain at creation: `firstByteTimeoutMs` (fetch() → response headers; S-753: **passed only when the
17
+ * operator set `MODEL_FIRST_BYTE_TIMEOUT_MS`** — absent = no key, core's own default, which carries an
18
+ * upload allowance and one widened retry of its own), `firstTokenTimeoutMs` (headers → first delta,
19
+ * 600s since S-202 — a self-hosted backend's long prefill legitimately takes minutes, and 120s turned that
20
+ * into a retry handing the same prompt back to the same queue) and `idleTimeoutMs`. The TCP + TLS connect
21
+ * is the transport's own clock (core 7.33.0), not a knob here. env =0 = no engine clock on that segment
22
+ * and the transport's header / body clocks off too (the connect keeps the transport's own clock).
21
23
  * - **Circuit breaker** (`createCircuitBreakerBrain`) wraps the *primary* gateway route(s) when
22
24
  * `MODEL_CIRCUIT_BREAKER=true`: after N transient failures it opens and fast-fails, so failover
23
25
  * switches to the bare backup immediately instead of waiting out each timeout. The last gateway
@@ -30,14 +32,32 @@ export declare const FAST_FAIL_MAX_RETRIES = 2;
30
32
  * ↔ vLLM is a *routing* choice, not failover.)
31
33
  *
32
34
  * When no fallback URL, no Anthropic key, and no resilience thresholds are configured, this returns the
33
- * bare openai brain (carrying the default-on watchdog timeouts — set the three env knobs to 0 for the
34
- * pre-[ref] fully-bare shape). The Model itself (id / contextWindow / maxTokens / provider) flows
35
+ * bare openai brain (carrying the watchdogs: core's own first-byte default unless `MODEL_FIRST_BYTE_TIMEOUT_MS`
36
+ * is set, plus the server's first-token / idle defaults; a knob at 0 = no engine clock on that segment and the
37
+ * transport's header / body clocks off too). The Model itself (id / contextWindow / maxTokens / provider) flows
35
38
  * per-request through `Runner({ models })`.
36
39
  */
37
40
  export declare function createBrain(config: ServiceConfig, deps?: {
38
41
  fetchImpl?: typeof fetch;
39
42
  breakerState?: BreakerState;
40
43
  }): Brain;
44
+ /**
45
+ * S-753 复审(opus F2 + codex C1)—— **failover 拓扑 × 首字节段没有部署窗**的启动期披露(纯数据,`build*`;告警由 boot 出)。
46
+ *
47
+ * 缺省下首字节段不铸键(core 自缺省当家,[ref])、`0` = 该段不设引擎钟;而切备只发生在一次调用**失败之后**。于是主网关接了连接
48
+ * 却不回响应头时:缺省 ⇒ 要等 core 的首字节 lane 走完(缺省首窗 + 一次加宽重试;本仓不铸 `firstByteRetries`,S-753 车PA)才切备,
49
+ * 时间预算短于那一段的调用在备网关被尝试之前就被取消 —— 以 core 7.33.x 缺省计 lane ≈ 15 min,code-review council 每个镜头的 180 s 与 team
50
+ * discuss 的 600 s 都在其内(车PA codex r1:600 s 在主网关的加宽重试窗里到点,不是「只剩余量」);
51
+ * `0` ⇒ 首字节 lane 永不切备。启动日志里同行的 `gatewayRetryCap:"fast-fail:…"` 只管共享重试梯子,读起来却像「会快切」—— 这条披露把它补齐。
52
+ * 显式设了正数窗 ⇒ `undefined`(部署已表态);单路由 ⇒ `undefined`(没有备可切)。**不替运维铸窗**:出路是旋钮,不是本仓的数。
53
+ */
54
+ export declare function buildFailoverFirstByteNotice(config: ServiceConfig): {
55
+ readonly knob: "MODEL_FIRST_BYTE_TIMEOUT_MS";
56
+ readonly fallbacks: number;
57
+ readonly firstByteTimeoutMs: number | null;
58
+ readonly effect: string;
59
+ readonly fix: string;
60
+ } | undefined;
41
61
  /** One-line summary of the active brain composition, for the startup log. */
42
62
  export declare function brainSummary(config: ServiceConfig): Record<string, unknown>;
43
63
  //# sourceMappingURL=brain.d.ts.map
package/dist/brain.js CHANGED
@@ -38,7 +38,7 @@ export function createBrain(config, deps = {}) {
38
38
  const { fetchImpl, breakerState } = deps;
39
39
  const r = config.resilience ?? DEFAULT_RESILIENCE;
40
40
  const timeouts = {
41
- connectTimeoutMs: r.connectTimeoutMs,
41
+ ...(r.firstByteTimeoutMs !== undefined ? { firstByteTimeoutMs: r.firstByteTimeoutMs } : {}),
42
42
  firstTokenTimeoutMs: r.firstTokenTimeoutMs,
43
43
  idleTimeoutMs: r.idleTimeoutMs,
44
44
  };
@@ -135,6 +135,22 @@ export function createBrain(config, deps = {}) {
135
135
  }
136
136
  return brain;
137
137
  }
138
+ export function buildFailoverFirstByteNotice(config) {
139
+ const r = config.resilience ?? DEFAULT_RESILIENCE;
140
+ const fallbacks = config.gatewayFallbackUrls.length;
141
+ if (fallbacks === 0 || (r.firstByteTimeoutMs !== undefined && r.firstByteTimeoutMs > 0))
142
+ return undefined;
143
+ const zero = r.firstByteTimeoutMs === 0;
144
+ return {
145
+ knob: "MODEL_FIRST_BYTE_TIMEOUT_MS",
146
+ fallbacks,
147
+ firstByteTimeoutMs: r.firstByteTimeoutMs ?? null,
148
+ effect: zero
149
+ ? "the first-byte lane never fails over a primary that accepts the connection but sends no response headers (0 = no engine clock on that segment); only a run-level limit ends that wait"
150
+ : "a primary that accepts the connection but sends no response headers is failed over only after core's default first-byte lane runs out (the first window, then one widened retry); a call with a time budget shorter than that — the code-review council's 180 s lenses and team discuss's 600 s, with core 7.33.x defaults — is cancelled before the backup is ever tried",
151
+ fix: "set MODEL_FIRST_BYTE_TIMEOUT_MS to the longest response-header wait a healthy primary needs (queue + prefill), so a hung primary is cut over after that window and its one widened retry",
152
+ };
153
+ }
138
154
  export function brainSummary(config) {
139
155
  const r = config.resilience ?? DEFAULT_RESILIENCE;
140
156
  const hasFailover = config.gatewayFallbackUrls.length > 0;
@@ -147,9 +163,9 @@ export function brainSummary(config) {
147
163
  failover: hasFailover,
148
164
  anthropicRoute: Boolean(config.anthropic),
149
165
  anthropicCacheBreakpoints: config.anthropic?.cacheBreakpoints ?? null,
150
- connectTimeoutMs: r.connectTimeoutMs || null,
151
- firstTokenTimeoutMs: r.firstTokenTimeoutMs || null,
152
- idleTimeoutMs: r.idleTimeoutMs || null,
166
+ firstByteTimeoutMs: r.firstByteTimeoutMs ?? null,
167
+ firstTokenTimeoutMs: r.firstTokenTimeoutMs,
168
+ idleTimeoutMs: r.idleTimeoutMs,
153
169
  circuitBreaker: breakerActive,
154
170
  circuitBreakerRequested: r.circuitBreaker,
155
171
  gatewayRetryCap: config.gatewayMaxRetries !== undefined
@@ -39,14 +39,19 @@ export type PluginValidation = {
39
39
  };
40
40
  /** 条款 1/2/4:https-only、host 全等白名单、sha 40hex。纯函数,worker 侧纵深复验(不信任 wire 已验过)。 */
41
41
  export declare function validatePluginDecl(decl: PluginDeclWire, allowHosts: readonly string[]): PluginValidation;
42
- /** 条款 3/5:clone 计划(argv 序列 + 隔离 env)。纯数据,让"姿势"可被锚定。 */
42
+ /**
43
+ * 条款 3:clone 计划(argv 序列)。纯数据,让"步骤"可被锚定。
44
+ *
45
+ * 7.103.0(S-684 残余):计划**只**产 argv。隔离 env(收紧③:GIT_CONFIG_GLOBAL/SYSTEM=/dev/null、GIT_TERMINAL_PROMPT=0)的唯一属主是
46
+ * `gitSubprocessEnv({ isolateConfig: true })` —— 修前这三键写在这里、由 `runGit` 叠在 `{ ...process.env }` 上,一件姿势两个写者,
47
+ * 而剥密恰好落在两者之外。锚点随之移到真 spawn 的 env 上(`test/secret-env-scrub-host-legs.test.ts` R2:假 git 读到的整份 env)。
48
+ */
43
49
  export declare function gitClonePlan(o: {
44
50
  url: string;
45
51
  dest: string;
46
52
  sha?: string;
47
53
  }): {
48
54
  steps: string[][];
49
- env: Record<string, string>;
50
55
  };
51
56
  /** manifest 本体尺寸上限(#97 redesign⑥ V2-⑦:**新的本地输入上限**,非既有格式事实——manifest 只被
52
57
  * 消费一个 `skillsPath` 字段,几行 JSON;超界视为畸形输入整条拒装)。 */
@@ -101,6 +106,7 @@ export declare function applyCenterPlugins(baseline: LoadedSkill[], eff: {
101
106
  /** 测试口:跳过 validate(materialize 只接受已验声明;真 git 腿用本地 bare repo,validate 层的
102
107
  * https-only 单独测——两层分开,谁也不掩护谁)。生产恒 false/缺席。 */
103
108
  skipValidate?: boolean;
104
- runGit?: (args: string[], env: Record<string, string>) => Promise<void>;
109
+ /** 测试口:替换真 git(生产恒缺席)。env 不在这一格上 —— 缺省腿自己经唯一构造取(替身腿本来就不起 git)。 */
110
+ runGit?: (args: string[]) => Promise<void>;
105
111
  }): Promise<ApplyPluginsResult>;
106
112
  //# sourceMappingURL=center-plugins.d.ts.map
@@ -4,6 +4,7 @@ import { promises as fsp, realpathSync, existsSync, readFileSync, lstatSync, sta
4
4
  import { join, resolve, isAbsolute, sep } from "node:path";
5
5
  import { assertConfined, loadSkills } from "./skills.js";
6
6
  import { buildDisplaySafeUrl, redactSecrets } from "../trace/redact.js";
7
+ import { gitSubprocessEnv } from "../server-secret-env.js";
7
8
  const execFileP = promisify(execFile);
8
9
  export const MAX_PLUGIN_SOURCE_URL_CHARS = 2048;
9
10
  export const DEFAULT_PLUGIN_ALLOW_HOSTS = ["github.com"];
@@ -53,13 +54,8 @@ export function validatePluginDecl(decl, allowHosts) {
53
54
  return { ok: true, name, url };
54
55
  }
55
56
  export function gitClonePlan(o) {
56
- const env = {
57
- GIT_CONFIG_GLOBAL: "/dev/null",
58
- GIT_CONFIG_SYSTEM: "/dev/null",
59
- GIT_TERMINAL_PROMPT: "0",
60
- };
61
57
  if (o.sha === undefined) {
62
- return { steps: [["clone", "--depth", "1", "--quiet", o.url, o.dest]], env };
58
+ return { steps: [["clone", "--depth", "1", "--quiet", o.url, o.dest]] };
63
59
  }
64
60
  return {
65
61
  steps: [
@@ -67,7 +63,6 @@ export function gitClonePlan(o) {
67
63
  ["-C", o.dest, "fetch", "--depth", "1", "--quiet", o.url, o.sha],
68
64
  ["-C", o.dest, "checkout", "--quiet", o.sha],
69
65
  ],
70
- env,
71
66
  };
72
67
  }
73
68
  export const PLUGIN_MANIFEST_MAX_BYTES = 64 * 1024;
@@ -130,8 +125,8 @@ export async function applyCenterPlugins(baseline, eff, opts) {
130
125
  const out = { skills: [...baseline], failures: [], letGo: [], lkgUsed: [] };
131
126
  if (declared.length === 0)
132
127
  return out;
133
- const runGit = opts.runGit ?? (async (args, env) => {
134
- await execFileP("git", args, { env: { ...process.env, ...env }, timeout: 120_000, maxBuffer: 4 * 1024 * 1024 });
128
+ const runGit = opts.runGit ?? (async (args) => {
129
+ await execFileP("git", args, { env: gitSubprocessEnv({ site: "server.center-plugins.git-clone", isolateConfig: true }), timeout: 120_000, maxBuffer: 4 * 1024 * 1024 });
135
130
  });
136
131
  const taken = new Set(baseline.map((s) => s.spec.name));
137
132
  for (const decl of declared) {
@@ -155,7 +150,7 @@ export async function applyCenterPlugins(baseline, eff, opts) {
155
150
  await fsp.mkdir(staging, { recursive: true });
156
151
  const plan = gitClonePlan({ url: v.url, dest: staging, sha: v.sha });
157
152
  for (const step of plan.steps)
158
- await runGit(step, plan.env);
153
+ await runGit(step);
159
154
  await fsp.rm(liveDir, { recursive: true, force: true });
160
155
  await fsp.mkdir(join(opts.cacheRoot, v.name), { recursive: true });
161
156
  await fsp.rename(staging, liveDir);
@@ -2,7 +2,7 @@ import { CROSS_SESSION_INBOUND_SETTINGS, CROSS_SESSION_DIALOG_EXPIRY_VALUES } fr
2
2
  import { CENTER_CREDENTIAL_ENV } 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, DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS, DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS, RESILIENCE_DEFAULTS, TASK_WRITE_FACE_ESCALATIONS } from "./config-types.js";
5
+ import { AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS, 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";
@@ -10,6 +10,7 @@ import { CENTER_READ_FACE_LIVE_KEYS } from "./config-center/read-face.js";
10
10
  import { MODEL_PROVIDER_WORDS } from "./model-provider.js";
11
11
  import { buildDisplaySafeUrl } from "./trace/redact.js";
12
12
  import { describeAllowlistPostures } from "./env-name-allowlist-knobs.js";
13
+ import { buildOutboundProxyPosture } from "./outbound-dispatcher.js";
13
14
  export const CATALOG_VALUE_TYPES = ["boolean", "number", "string", "enum", "csv", "json", "path", "url", "secret", "opaque"];
14
15
  export const CATALOG_TIMINGS = ["hot", "restart", "deploy", "inert"];
15
16
  export const CATALOG_DANGER_AXES = ["security", "billing", "data"];
@@ -172,6 +173,8 @@ export const CONFIG_CATALOG = [
172
173
  r("HOST_FS_DRAIN_WAIT_MS", "orchestration", "number", "host 执行环境拆除时等在飞文件写落地的上界(ms;到点 destroy 返回,rm 仍排在那批写之后;下限 1)", { staticDefault: "10000" }),
173
174
  r("HOST_SHELL_PATH", "orchestration", "path", "host 车道 shell 覆写口(POSIX 与 win32 同源解析)"),
174
175
  r("HOST_WORKSPACE_BASE", "orchestration", "path", "host 车道工作区 base"),
176
+ r("HTTPS_PROXY", "limitsHttp", "url", "https: 目标的出网代理 —— 平台标准变量(小写 `https_proxy` 同认且优先 —— 与 undici 读 env 逐字同序);server 进程**所有走 undici 的出网跳**都认:引擎 → 网关、中心拉取 / 公告 / 凭证续签、WebSearch、对象存储、OTLP …(e2b / k8s SDK、ws、git 子进程不在此列,见 USAGE「出网代理」);**回环 / 未指定地址目标恒直连,不看 NO_PROXY**;userinfo 译成 `Proxy-Authorization`,回显 display-safe;Node 运行时下 undici 用不了的值(无 scheme、带路径 / 查询)拒启,机读码 `config.outbound_proxy_invalid`(Bun 运行时不校验);回显只出 scheme//host[:port],形不干净整只占位;boot 冻结;只设小写拼法时本行 envHeld 读 false(目录按大写名判在场),值照实回显", { danger: SEC, derivedDefaultNote: "缺席 / 空 ⇒ https 目标走 HTTP_PROXY(undici 的回落,不是本仓的发明);两者都缺 ⇒ 直连", resolve: (_c, env) => buildOutboundProxyPosture(env).httpsProxy }),
177
+ r("HTTP_PROXY", "limitsHttp", "url", "http: 目标的出网代理(https 目标在 HTTPS_PROXY 缺席时也走它)—— 平台标准变量(小写 `http_proxy` 同认且优先 —— 与 undici 读 env 逐字同序);server 进程**所有走 undici 的出网跳**都认:引擎 → 网关、中心拉取 / 公告 / 凭证续签、WebSearch、对象存储、OTLP …(e2b / k8s SDK、ws、git 子进程不在此列,见 USAGE「出网代理」);**回环 / 未指定地址目标恒直连,不看 NO_PROXY**;userinfo 译成 `Proxy-Authorization`,回显 display-safe;Node 运行时下 undici 用不了的值(无 scheme、带路径 / 查询)拒启,机读码 `config.outbound_proxy_invalid`(Bun 运行时不校验);回显只出 scheme//host[:port],形不干净整只占位;boot 冻结;只设小写拼法时本行 envHeld 读 false(目录按大写名判在场),值照实回显", { danger: SEC, derivedDefaultNote: "缺席 ⇒ http 目标直连", resolve: (_c, env) => buildOutboundProxyPosture(env).httpProxy }),
175
178
  r("IMAGE_BAKES_ENABLED", "orchestration", "boolean", "烤镜像 job API(/v1/images/bakes*;需 SQL 池 + 非空 operator 名单,boot 断言)", { staticDefault: "false", danger: SEC }),
176
179
  r("INFRA_COST_EGRESS_GB", "limitsHttp", "number", "出网计费费率(per-run 出网计量未接,恒乘 0 —— 设非零会 boot 点名)", { staticDefault: "0", danger: BILL, envTiming: "inert", center: cLimits("infraCostRates.egressGbMicroUsd", { timing: "inert" }) }),
177
180
  r("INFRA_COST_SANDBOX_SEC", "limitsHttp", "number", "沙箱秒基建费率(0=不计价)", { staticDefault: "0", danger: BILL, center: cLimits("infraCostRates.sandboxSecMicroUsd") }),
@@ -214,7 +217,7 @@ export const CONFIG_CATALOG = [
214
217
  r("LOCKED_CONFIG_KEYS", "auth", "csv", "管理员锁定层声明(词表=core 闭集注册表,未知键拒启)", { danger: SEC, configKey: "lockedConfigKeys" }),
215
218
  r("LOG_LEVEL", "observability", "string", "日志档位", { staticDefault: "info", configKey: "logLevel" }),
216
219
  r("LSP_ENABLED", "orchestration", "boolean", "LSP 沙箱腿 opt-in(需烤好的 LSP 模板;host 腿另有 LSP_HOST_ENABLED)", { staticDefault: "false", configKey: "lspEnabled" }),
217
- r("LSP_ENV_ALLOW", "orchestration", "secret", `host 腿语言服务器子进程 env 的点名放行名单(点到的键从服务 env 原样放回、部署方显式承担;没点到的照剥;与 host shell inheritEnv:[names] 同一只谓词;形错 / 点名名单型旋钮(自己或两只沙箱名单)拒启且不回显值;本进程环境里没有的名按序号 warn、不放行、不回显;多租户姿态(忽略时起服 warn 一次):${describeAllowlistPostures()};原文按凭据类对待:只回在场位、不进任何子进程,生效名单见起服披露行 allowed)`, { danger: SEC, derivedDefaultNote: "缺席 / 空串 ⇒ 空名单 = 全剥(7.100.0 行为)" }),
220
+ r("LSP_ENV_ALLOW", "orchestration", "secret", `host 腿语言服务器子进程 env 的点名放行名单(点到的键从服务 env 原样放回、部署方显式承担;没点到的照剥;与 host shell inheritEnv:[names] 同一只谓词;形错 / 点名任一只名单型旋钮(含自己)拒启且不回显值;本进程环境里没有的名按序号 warn、不放行、不回显;多租户姿态(忽略时起服 warn 一次):${describeAllowlistPostures()};原文按凭据类对待:只回在场位、不进任何子进程,生效名单见起服披露行 allowed)`, { danger: SEC, derivedDefaultNote: "缺席 / 空串 ⇒ 空名单 = 全剥(7.100.0 行为)" }),
218
221
  r("LSP_HOST_ENABLED", "orchestration", "boolean", "LSP host 腿开关(本机 language server,优雅降级)", { staticDefault: "true", configKey: "lspHostEnabled" }),
219
222
  r("MANUAL_MODE_SHELL_GATE", "approval", "enum", "手动模式 shell 门收紧腿(off=no-op 非放松 —— 本键只有收紧半场;拼错拒启)", { danger: SEC, enumValues: ["always", "classify", "off"], echoWords: ["always", "classify"], resolve: (c) => c.manualModeShellGate ?? null, derivedDefaultNote: "off/未设 ⇒ 本键不贡献任何东西(归一成键缺席)⇒ 回显 null;真姿态只有 always/classify 两个" }),
220
223
  r("MAX_PRINCIPAL_COST_USD", "limitsHttp", "number", "per-principal 窗内成本天花板(0=关)", { staticDefault: "0", danger: BILL, configKey: "maxPrincipalCostUsd", center: cLimits("maxPrincipalCostUsd") }),
@@ -277,7 +280,6 @@ export const CONFIG_CATALOG = [
277
280
  r("MODEL_CHEAP_VISION", "modelPlane", "boolean", "cheap 槽 vision 声明(三态:缺席继承主模型;text-only cheap 必设 false 让预检真拦)", { derivedDefaultNote: "缺席 ⇒ 继承主模型" }),
278
281
  r("MODEL_CIRCUIT_BREAKER", "modelPlane", "boolean", "模型腿熔断开关", { staticDefault: String(RESILIENCE_DEFAULTS.circuitBreaker.value) }),
279
282
  r("MODEL_CODE_ROLES", "modelPlane", "csv", "给点名 role 附编码 persona(role 表内名字才生效)"),
280
- r("MODEL_CONNECT_TIMEOUT_MS", "modelPlane", "number", "连接超时(ms;0=关)", { staticDefault: String(RESILIENCE_DEFAULTS.connectTimeoutMs.value) }),
281
283
  r("MODEL_CONTEXT_WINDOW", "modelPlane", "number", "主模型上下文窗声明", { staticDefault: "262144", configKey: "model", resolve: (c) => c.model.contextWindow ?? null, center: cModels("models[].contextWindow") }),
282
284
  r("MODEL_COST_CACHE_READ", "modelPlane", "number", "主模型 cache-read 单价(per-1M USD;显式 0=声明免费,不是「没配」)", { derivedDefaultNote: "四键全缺席 ⇒ 未定价(不铸 cost 键,成本读面缺席);任一设 ⇒ 四键在场,未设的按 0 显式", danger: BILL, resolve: (c) => c.model.cost?.cacheRead ?? null, center: cModels("models[].cost.cacheRead") }),
283
285
  r("MODEL_COST_CACHE_WRITE", "modelPlane", "number", "主模型 cache-write 单价", { derivedDefaultNote: "四键全缺席 ⇒ 未定价(不铸 cost 键,成本读面缺席);任一设 ⇒ 四键在场,未设的按 0 显式", danger: BILL, resolve: (c) => c.model.cost?.cacheWrite ?? null, center: cModels("models[].cost.cacheWrite") }),
@@ -290,12 +292,13 @@ export const CONFIG_CATALOG = [
290
292
  r("MODEL_DEGRADE_TO", "modelPlane", "string", "降级目标模型(设了=开降级;外部网关名不必在目录)", { danger: BILL }),
291
293
  r("MODEL_DEGRADE_TO_VISION", "modelPlane", "boolean", "降级目标的 vision 声明(false ⇒ 带图任务丢降级不丢图)", { staticDefault: "false" }),
292
294
  r("MODEL_EXTRA_BODY", "modelPlane", "opaque", "生成参数直通 JSON(top_k/logit_bias 等;必须静态 —— 前缀缓存键序)", { danger: SEC }),
293
- r("MODEL_FIRST_TOKEN_TIMEOUT_MS", "modelPlane", "number", "首 token 超时(ms;0=关 —— 开流不吐字的唯一看门狗;自托管长 prefill 后端按此档留足)", { staticDefault: String(RESILIENCE_DEFAULTS.firstTokenTimeoutMs.value) }),
295
+ r("MODEL_FIRST_BYTE_TIMEOUT_MS", "modelPlane", "number", `请求发出 → 响应头的等待(ms;缺席 = core 缺省 + 上传宽限;到点 core 自有一次加宽重试,配了 MODEL_GATEWAY_FALLBACK_URLS 也一样(之后才切备;server 不铸 firstByteRetries);0 = 该段不设引擎钟、传输层的响应头 / 响应体钟也关(TCP + TLS 建连仍归传输层自己的钟);自托管慢网关按排队 + prefill 留足;failover 拓扑想快切须显式设小;须为 [${FIRST_BYTE_TIMEOUT_BOUNDS.minMs}, ${FIRST_BYTE_TIMEOUT_BOUNDS.maxMs}] 内十进制整数,其余拒启)`, { derivedDefaultNote: "未设/空串 ⇒ 不铸键,跟随 core 缺省(本仓不复制上游数字;core 改缺省本旋钮照映不改)", resolve: (c) => c.resilience.firstByteTimeoutMs ?? null }),
296
+ r("MODEL_FIRST_TOKEN_TIMEOUT_MS", "modelPlane", "number", "首 token 超时(ms;0 = 该段不设引擎钟、传输层的响应体钟也关 —— 开流不吐字的唯一看门狗;自托管长 prefill 后端按此档留足)", { staticDefault: String(RESILIENCE_DEFAULTS.firstTokenTimeoutMs.value) }),
294
297
  r("MODEL_FREQUENCY_PENALTY", "modelPlane", "number", "frequency_penalty 直通(压过 EXTRA_BODY 同键)"),
295
298
  r("MODEL_GATEWAY_BASEURL", "modelPlane", "url", "openai 兼容网关 base URL(S-623:**无出厂缺省** —— 配置能派发到的每条 openai 兼容路由都必须解析到显式端点(条目自己的 baseUrl,或本键),否则拒启 / 热路径整批拒,机读码 `config.model_route_endpoint_missing`,拒因点名条目并给出本机写法 `MODEL_GATEWAY_BASEURL=<本机网关>/v1`;anthropic 单路由部署不设本键合法;S-475:带 userinfo 拒启,机读码 `config.gateway_url_userinfo` —— WHATWG 禁止用带凭据的 URL 构造 fetch Request,凭据放 MODEL_API_KEY;S-483:解析不动/不透明形拒启,机读码 `config.model_route_url_invalid`;S-514:带 `#fragment` 拒启,机读码 `config.model_route_url_fragment`)", { derivedDefaultNote: "缺席 ⇒ 本部署没有网关(不铸键);有 openai 路由落不到端点 ⇒ 拒启", configKey: "gatewayBaseUrl", center: cModels("models[].baseUrl") }),
296
299
  r("MODEL_GATEWAY_FALLBACK_URLS", "modelPlane", "url", "网关故障转移地址表(CSV;S-623:主网关 MODEL_GATEWAY_BASEURL 未设而本键设了 ⇒ 拒启,机读码 `config.gateway_fallback_without_primary` —— 备用是主网关的镜像;S-475:**逐项**判 userinfo,任一项带 ⇒ 拒启,机读码 `config.gateway_url_userinfo`;S-483:逐项判可解析性,机读码 `config.model_route_url_invalid`;S-514:**逐项**判 `#fragment`,机读码 `config.model_route_url_fragment`)", { configKey: "gatewayFallbackUrls" }),
297
300
  r("MODEL_ID", "modelPlane", "string", "主模型 id(3.0.0 起无出厂缺省,缺 ⇒ 拒启指路)", { configKey: "model", resolve: (c) => c.model.id, center: cModels("models[].id") }),
298
- r("MODEL_IDLE_TIMEOUT_MS", "modelPlane", "number", "流中静默看门狗(ms;0=关)", { staticDefault: String(RESILIENCE_DEFAULTS.idleTimeoutMs.value) }),
301
+ r("MODEL_IDLE_TIMEOUT_MS", "modelPlane", "number", "流中静默看门狗(ms;0 = 该段不设引擎钟、传输层的响应体钟也关)", { staticDefault: String(RESILIENCE_DEFAULTS.idleTimeoutMs.value) }),
299
302
  r("MODEL_MAX_TOKENS", "modelPlane", "number", "主模型输出上限", { derivedDefaultNote: "缺席 ⇒ min(32000, contextWindow)", resolve: (c) => c.model.maxTokens ?? null, center: cModels("models[].maxTokens") }),
300
303
  r("MODEL_PRESENCE_PENALTY", "modelPlane", "number", "presence_penalty 直通"),
301
304
  r("MODEL_PROVIDER", "modelPlane", "enum", "主模型 provider(路由选 brain;S-461 起是闭集,词表外的值启动期拒启并点名机读码 `config.model_provider_invalid`)", { enumValues: MODEL_PROVIDER_WORDS, derivedDefaultNote: "缺席:ANTHROPIC base+cred 同在 ⇒ anthropic,否则 gateway", resolve: (c) => c.model.provider, center: cModels("models[].provider") }),
@@ -328,6 +331,7 @@ export const CONFIG_CATALOG = [
328
331
  r("MYSQL_POOL_SIZE", "store", "number", "MySQL 连接池上限(坏值回 driver 默认+warn)", { derivedDefaultNote: "缺席 ⇒ driver 默认池" }),
329
332
  r("MYSQL_PORT", "store", "number", "MySQL 协议端口", { staticDefault: "3306" }),
330
333
  r("MYSQL_USER", "store", "string", "MySQL 协议用户", { danger: DATA }),
334
+ r("NO_PROXY", "limitsHttp", "csv", "不走代理的目标(小写 `no_proxy` 同认且优先);语法 = undici 的:逗号或空白分隔,`host` 精确且含其子域、`.host` / `*.host` 后缀、`host:port` 限端口、`*` 全直连;**回环 / 未指定地址目标无论写没写都直连**(本仓的无条件规则,不是 NO_PROXY 的一条);boot 冻结", { danger: SEC, derivedDefaultNote: "缺席 ⇒ 除回环外一律按 HTTP(S)_PROXY 走", resolve: (_c, env) => buildOutboundProxyPosture(env).noProxy }),
331
335
  r("OPERATOR_PRINCIPALS", "auth", "csv", "operator 名单(operator lane 全族门;审批队列上空名单=旧行为 token 即 operator,explicitOperatorOk 族空名单=谁都不是)", { danger: SEC, configKey: "operatorPrincipals" }),
332
336
  r("OTEL_EXPORTER_OTLP_ENDPOINT", "observability", "url", "OTLP 出口(设了=开 otel)"),
333
337
  r("OTEL_EXPORTER_OTLP_HEADERS", "observability", "secret", "OTLP 出口头(k=v CSV;常载鉴权头 ⇒ 按凭据处置)", { danger: SEC }),
@@ -10,6 +10,7 @@ import type { ServiceConfig } from "../config-types.js";
10
10
  import { type SealedKeyOpener } from "../sealed-key.js";
11
11
  import type { Logger } from "../observability/logger.js";
12
12
  import type { EffectiveConfig } from "./types.js";
13
+ import { type ModelRefusalVerdict } from "../model-entry-refusal.js";
13
14
  import { type ConfigApplyReport } from "./apply-ledger.js";
14
15
  /** Apply center config OVER the env-derived config (mutates `config`). Empty domains keep env defaults. */
15
16
  /** Replace a record's contents IN PLACE (clear keys, copy source) so holders of the SAME reference see the
@@ -41,6 +42,10 @@ export interface ApplyReport extends ConfigApplyReport {
41
42
  /** 🔴 codex R2-F1:本候选是否**完整**应用 —— 有任何一组被拒 ⇒ false。调用方据此决定要不要把它发布成
42
43
  * LKG / 推进 etag(一个「其余面提交了但某组被拒」的候选覆盖掉好 LKG,重启后就再也回不去了)。 */
43
44
  readonly fullyApplied: boolean;
45
+ /** S-768:本候选目录的模型拒因判决 —— 逐条(`entries`,stage 判出的那份候选的,键已过名筛)+ 组拒码(`plane`,模型面组被拒时在场,
46
+ * 判决那刻当数据带出)。落地与组拒同一只来源:两者都描述**这一份**候选(组拒时引擎自己的拒因表不换代,这里仍报被拒的那份)。
47
+ * 候选不管模型面(零启用条目、零墓碑)⇒ `entries` 空、无 `plane`。`/health.modelRefusals` 与心跳的唯一数据源(config-center 投影)。 */
48
+ readonly modelRefusals: ModelRefusalVerdict;
44
49
  }
45
50
  /**
46
51
  * S-668 波 2 —— 一次 apply 在**哪条腿**上跑,以及这条腿知道的**部署事实**。必填(没有缺省腿):安全轴上「忘了说是哪条
@@ -92,6 +97,17 @@ export type ModelPlaneOf = Pick<EffectiveConfig, ModelPlaneDomain>;
92
97
  export declare function withModelPlaneOf<E extends EffectiveConfig>(target: E, source: ModelPlaneOf): E;
93
98
  /** 「没有中心模型面」—— 组拒且无 LKG 时进程真正服务的那一代(env 表:没有目录、没有中心 collab 模板)。纯数据,每次新铸。 */
94
99
  export declare function buildEnvOnlyModelPlane(): ModelPlaneOf;
100
+ /**
101
+ * S-768:组拒码 → 铸它的那条模型面不变量 —— **以码为键**(组拒码闭集的属主是 settings-schema `MODEL_PLANE_REFUSAL_CODES`;那边加一个码
102
+ * ⇒ 本表缺键 ⇒ 本仓 tsc 红)。模型面 stage 的组拒按**码**铸(码是判决那刻的数据),不变量 id 由本表派生 —— 一个 id 两个码
103
+ * (`model-route-endpoint`)时码不可能从 id 反推,反过来查才是全函数。
104
+ */
105
+ export declare const PLANE_REFUSAL_INVARIANT_OF: {
106
+ readonly "config.default_model_missing": "default-model-resolvable";
107
+ readonly "config.model_refused_name_minted": "refused-names-not-minted";
108
+ readonly "config.model_route_endpoint_missing": "model-route-endpoint";
109
+ readonly "config.gateway_fallback_without_primary": "model-route-endpoint";
110
+ };
95
111
  /**
96
112
  * UNIFORM presence semantics across the center gates (center `f95438a` adopted this from our sentinel flag —
97
113
  * SERVICE-INTEGRATION「Runtime 治理/限额闸」): every gate field is center `.optional()`, so
@@ -8,7 +8,8 @@ import { stageLimits } from "./stage-limits.js";
8
8
  import { APPROVAL_GROUP_KEYS, BATCH1_LIMIT_KEYS } from "./hot-keys-registry.js";
9
9
  import { buildDisplaySafeUrl } from "../trace/redact.js";
10
10
  import { crossInvariant } from "../config-invariants.js";
11
- import { judgeCatalogEntries, mintedRefusedNames, refusalEventOf, refusalWarnFields, malformedDroppedCarrierRefusal } from "../model-entry-refusal.js";
11
+ import { DEFAULT_MODEL_MISSING_CODE, REFUSED_NAME_MINTED_CODE, judgeCatalogEntries, mintedRefusedNames, refusalEventOf, refusalWarnFields, malformedDroppedCarrierRefusal } from "../model-entry-refusal.js";
12
+ import { judgeModelRouteEndpoint } from "../model-route-endpoint.js";
12
13
  import { gateIntentUnserviceableReason } from "../approval.js";
13
14
  import { refusedGroups } from "./apply-ledger.js";
14
15
  const charsPerTokenOf = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
@@ -136,7 +137,7 @@ export function applyEffective(config, eff, logger, opts) {
136
137
  modelPlane: modelPlaneGroupReport(staged.modelPlaneGroup),
137
138
  },
138
139
  };
139
- opts.report({ ...report, fullyApplied: refusedGroups(report).length === 0 });
140
+ opts.report({ ...report, fullyApplied: refusedGroups(report).length === 0, modelRefusals: modelRefusalVerdictOf(staged.modelPlaneGroup) });
140
141
  }
141
142
  if (staged.limits.changed.length > 0)
142
143
  logger?.info("sema_registry_limits", { version: staged.version, keys: staged.limits.changed });
@@ -206,6 +207,16 @@ function takeModelPlaneDomain(out, source, d) {
206
207
  export function buildEnvOnlyModelPlane() {
207
208
  return { models: { models: [], roles: {} } };
208
209
  }
210
+ function modelRefusalVerdictOf(group) {
211
+ const entries = group.refused ?? {};
212
+ return group.rejected !== undefined ? { entries, plane: group.rejected.code } : { entries };
213
+ }
214
+ export const PLANE_REFUSAL_INVARIANT_OF = {
215
+ "config.default_model_missing": "default-model-resolvable",
216
+ "config.model_refused_name_minted": "refused-names-not-minted",
217
+ "config.model_route_endpoint_missing": "model-route-endpoint",
218
+ "config.gateway_fallback_without_primary": "model-route-endpoint",
219
+ };
209
220
  function modelPlaneGroupReport(group) {
210
221
  return {
211
222
  applied: MODEL_PLANE_DOMAINS.flatMap((d) => (group.stages[d] !== undefined ? MODEL_PLANE_SURFACES[d] : [])),
@@ -221,7 +232,8 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
221
232
  {
222
233
  for (const [name, r] of Object.entries(verdict.refused))
223
234
  logger?.warn(refusalEventOf(r.reason), refusalWarnFields(name, r));
224
- const refuse = (invariant, reason, detail = {}) => {
235
+ const refuse = (code, reason, detail = {}) => {
236
+ const invariant = PLANE_REFUSAL_INVARIANT_OF[code];
225
237
  logger?.warn("sema_registry_model_plane_refused", {
226
238
  version: typeof eff.version === "number" ? eff.version : 0,
227
239
  invariant,
@@ -229,10 +241,10 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
229
241
  ...detail,
230
242
  kept: "the model plane of this candidate is refused as a group — the current model plane (catalog / roles / tiers / refusals / allowlist, and the collab workflows that reference it) keeps serving and the other groups of this candidate still apply; the candidate is not fully applied (LKG / ETag stay on the last fully applied generation) and is re-judged next pull",
231
243
  });
232
- return { stages: {}, rejected: { keys: MODEL_PLANE_GROUP_KEYS, reason, invariant }, refused: verdict.refused };
244
+ return { stages: {}, rejected: { keys: MODEL_PLANE_GROUP_KEYS, reason, invariant, code }, refused: verdict.refused };
233
245
  };
234
246
  if (verdict.malformed.length > 0)
235
- return refuse("refused-names-not-minted", malformedDroppedCarrierRefusal(verdict.malformed), { entries: [...verdict.malformed] });
247
+ return refuse(REFUSED_NAME_MINTED_CODE, malformedDroppedCarrierRefusal(verdict.malformed), { entries: [...verdict.malformed] });
236
248
  const usable = verdict.usable;
237
249
  const models = {};
238
250
  const modelApiKeyEnv = {};
@@ -311,13 +323,13 @@ function stageModelPlane(config, eff, logger, sealedKeys) {
311
323
  const nextView = { ...config, models, tiers: activeTiers, modelRefusals: verdict.refused };
312
324
  const noDefault = crossInvariant("default-model-resolvable").check({ live: config, next: nextView });
313
325
  if (noDefault !== undefined)
314
- return refuse("default-model-resolvable", noDefault);
315
- const endpointRefusal = crossInvariant("model-route-endpoint").check({ live: config, next: nextView });
316
- if (endpointRefusal !== undefined)
317
- return refuse("model-route-endpoint", endpointRefusal);
326
+ return refuse(DEFAULT_MODEL_MISSING_CODE, noDefault);
327
+ const endpoint = judgeModelRouteEndpoint(nextView);
328
+ if (endpoint !== undefined)
329
+ return refuse(endpoint.code, endpoint.reason);
318
330
  const minted = crossInvariant("refused-names-not-minted").check({ live: config, next: nextView });
319
331
  if (minted !== undefined)
320
- return refuse("refused-names-not-minted", minted, { minted: mintedRefusedNames(nextView) });
332
+ return refuse(REFUSED_NAME_MINTED_CODE, minted, { minted: mintedRefusedNames(nextView) });
321
333
  for (const [role, spec] of Object.entries(roles)) {
322
334
  const target = typeof spec === "string" ? spec : spec !== null && typeof spec === "object" && "model" in spec && typeof spec.model === "string" ? spec.model : undefined;
323
335
  const refusal = target !== undefined && Object.hasOwn(verdict.refused, target) ? verdict.refused[target] : undefined;
@@ -597,7 +609,7 @@ export function logEffectiveDiff(config, eff, logger) {
597
609
  wouldOverrideModels: enabled.length > 0,
598
610
  wouldDefaultModel: plane.defaultName ?? null,
599
611
  ...(Object.keys(refused).length > 0 ? { wouldRefuse: Object.fromEntries(Object.entries(refused).map(([n, r]) => [n, r.code])) } : {}),
600
- ...(plane.rejected !== undefined ? { wouldRefuseModelPlane: { invariant: plane.rejected.invariant, reason: plane.rejected.reason } } : {}),
612
+ ...(plane.rejected !== undefined ? { wouldRefuseModelPlane: { invariant: plane.rejected.invariant, code: plane.rejected.code, reason: plane.rejected.reason } } : {}),
601
613
  currentDefaultModel: config.model.id,
602
614
  models: { center: centerModels, envDerived: envModels },
603
615
  roles: { center: centerRoles, current: config.roles },
@@ -46,8 +46,8 @@ export declare function createMcpRevocationLedger(onChange?: (ev: {
46
46
  }) => void): McpRevocationLedger;
47
47
  /** 一拍 EffectiveConfig 的 ENABLED MCP 服务器条目表(`resolveMcpServers` 同判据:`enabled !== false`;
48
48
  * 值=整条目的 key 排序 JSON 指纹,Map 插入序=发布序);mcp 域缺席 ⇒ `null`(台账不动)。
49
- * 指纹序列化器 = restart-signal 的 `stableStringify`([ref] 2026-08-31 起同源:此前本文件持一份**逐字相同**的
50
- * 私有复刻 `fingerprintOf`,两份在全部输入上字节恒等,合并零行为差,golden 钉 test/canonical-json-golden.test.ts)。
49
+ * 指纹序列化器 = `@sema-agent/settings-schema/hash` 的 `stableStringify`(S-778 起;[ref] 2026-08-31 起与 restart-signal 同源:此前本文件
50
+ * 持一份**逐字相同**的私有复刻 `fingerprintOf`;JSON 形输入上字节恒等,golden 钉 test/canonical-json-golden.test.ts)。
51
51
  * 两处指纹仍只在各自域内自比、不跨域比对——同源只是去掉一份多余的复刻,不引入跨域约束;也**不**改用
52
52
  * `src/canonical-json.ts`(undefined 值键语义不同,见 restart-signal.ts 该函数顶注)。 */
53
53
  export declare function enabledMcpEntries(mcp: {
@@ -1,4 +1,4 @@
1
- import { stableStringify } from "./restart-signal.js";
1
+ import { stableStringify } from "@sema-agent/settings-schema/hash";
2
2
  export function createMcpRevocationLedger(onChange) {
3
3
  let baseline;
4
4
  let seen;
@@ -40,16 +40,6 @@ export interface RestartSliceCtx {
40
40
  export type EngineRestartSignal = RestartSignal & {
41
41
  reasons: RestartSlice[];
42
42
  };
43
- /** Canonical, key-sorted JSON (array order preserved) so two semantically-equal effective slices fingerprint
44
- * identically regardless of object key order from the center serializer.
45
- *
46
- * ⚠️ [ref](2026-08-31)如实登记:这**不是** `src/canonical-json.ts#canonicalJson`(core 逐字港,全仓单源)——
47
- * 两者在 JSON 形输入上逐字节相同(golden 交叉读数已证),但本函数把 `undefined` 值键**保留为 `"k":null`**,
48
- * 共享件按 `JSON.stringify` 语义**省略**。restart 指纹的输入是进程内构造的切片对象(zod 解析产物 + rest 展开/
49
- * 挑字段),`{k: undefined}` 形原则上可达,而它与 `{}` 是否同指纹 = 本轴上的**行为**(多签/少签一次 fleet 滚动
50
- * 重启),不是序列化器的私事 ⇒ 语义不恒等不合并;字节钉 test/canonical-json-golden.test.ts,K→O 统一另立件
51
- * 走三问。导出只为 mcp-revocation 同源改调(两处此前各持一份逐字相同的复刻)与 golden 直钉,不是公共 API。 */
52
- export declare function stableStringify(v: unknown): string;
53
43
  /** Restart-to-apply slices that DIFFER between this process's BOOT config and a freshly-pulled one. Empty =
54
44
  * nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
55
45
  * can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
@@ -1,19 +1,9 @@
1
1
  import { resolveActiveTiers } from "@sema-agent/settings-schema";
2
+ import { stableStringify } from "@sema-agent/settings-schema/hash";
2
3
  import { DEFAULT_PLUGIN_ALLOW_HOSTS, materializablePlugins } from "../capabilities/center-plugins.js";
3
4
  import { resolveDefaultModelName } from "./apply-effective.js";
4
5
  import { CENTER_READ_FACE_LIVE_KEYS } from "./read-face.js";
5
6
  const RESTART_SLICES = ["skills", "mcp", "a2a", "scenarios", "degrade-route", "read-face", "memory-consolidation"];
6
- export function stableStringify(v) {
7
- if (v === null || typeof v !== "object")
8
- return JSON.stringify(v) ?? "null";
9
- if (Array.isArray(v))
10
- return `[${v.map(stableStringify).join(",")}]`;
11
- const obj = v;
12
- return `{${Object.keys(obj)
13
- .sort()
14
- .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
15
- .join(",")}}`;
16
- }
17
7
  function enabledOnly(rows) {
18
8
  if (!rows)
19
9
  return null;
@@ -73,7 +63,7 @@ export function restartReasons(boot, current, ctx) {
73
63
  if (stableStringify(b) === stableStringify(c))
74
64
  return false;
75
65
  if (s === "mcp" && ctx?.mcpRevocationLive === true && Array.isArray(b) && Array.isArray(c) && c.length < b.length) {
76
- const bootFps = b.map(stableStringify);
66
+ const bootFps = b.map((row) => stableStringify(row));
77
67
  let bi = 0;
78
68
  const pureRemoval = c.every((row) => {
79
69
  const fp = stableStringify(row);
@@ -18,6 +18,7 @@ import type { InfraCostRates } from "./observability/cost-taxonomy.js";
18
18
  import type { Autonomy, CommandRule } from "./runtime-governance.js";
19
19
  import type { CenterCredentialSource } from "./center-credential.js";
20
20
  import type { GitApiKind } from "./git-api-kind.js";
21
+ import { DRAIN_GRACE_FLOOR_MS } from "@sema-agent/settings-schema/fleet";
21
22
  /** [ref]:记忆向量面的 embedder 坐标(env `MEMORY_EMBEDDER_*` 解析结果;单一属主 = 本类型,
22
23
  * `plugins/memory-embedder.ts` 的工厂直接消费它,不复制形状)。语义与三问见
23
24
  * {@link ServiceConfigFlat.memoryEmbedder}。纯数据 —— 端点原文照存(拼 `/embeddings` 是 transport
@@ -88,12 +89,12 @@ export declare const AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS: {
88
89
  };
89
90
  /**
90
91
  * `DRAIN_GRACE_MS` 的**地板**(10s)—— 夹取(env,[ref] B 档)与中心 stage 校验(`stage-limits.ts` 的 `atLeast`)两条写入腿
91
- * 都以它为下界,于是「排空窗」在任何写入通道上都不会短于它。S-752 起从 `config.ts` 的 `LIMITS_HTTP_FLOORS` 字面提到本
92
- * 纯叶:{@link DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS} 的上界与配置目录的合法域句共读这一个数([ref]:边界不手抄;
93
- * 目录模块不能值引 `config.ts` —— `config → server-secret-env → config-catalog` 已成链)。`LIMITS_HTTP_FLOORS` 仍是
94
- * 那张表,只是这一格改为引用本常量,零语义变化。
92
+ * 都以它为下界,于是「排空窗」在任何写入通道上都不会短于它。**属主在 settings-schema**(`@sema-agent/settings-schema/fleet` 的
93
+ * `DRAIN_GRACE_FLOOR_MS`,drain 协议合同值;S-778 起本叶只转出口,不再本地铸同一个数)。S-752 起它从 `config.ts` 的
94
+ * `LIMITS_HTTP_FLOORS` 字面提到本叶:{@link DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS} 的上界与配置目录的合法域句共读这一个数([ref]:
95
+ * 边界不手抄;目录模块不能值引 `config.ts` —— `config → server-secret-env → config-catalog` 已成链)。
95
96
  */
96
- export declare const DRAIN_GRACE_FLOOR_MS = 10000;
97
+ export { DRAIN_GRACE_FLOOR_MS };
97
98
  /**
98
99
  * S-752(test [ref])—— `DRAIN_SECOND_SIGNAL_MIN_GAP_MS` 的**合法域**(闭区间、整数 ms)与缺省。解析器(`config.ts`)
99
100
  * 与目录行(`config-catalog.ts`)共读这一处。
@@ -112,6 +113,21 @@ export declare const DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS: {
112
113
  readonly maxMs: 10000;
113
114
  };
114
115
  export declare const DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS = 250;
116
+ /**
117
+ * S-753(core 7.33.0 #1121)—— `MODEL_FIRST_BYTE_TIMEOUT_MS`(→ core `BrainTimeoutConfig.firstByteTimeoutMs`,`fetch()` → 响应头
118
+ * 的等待)的**合法域**,闭区间、整数 ms。解析器(`config.ts`)与目录行(`config-catalog.ts`)共读这一处。
119
+ *
120
+ * · **下界 0**:`0` 在这一座有真含义 —— 该段**不设引擎钟,传输层的响应头 / 响应体钟也关**(TCP + TLS 建连仍归传输层自己的钟;core 7.33.0 起每请求带引擎 dispatcher,把传输层的
121
+ * headers / body 钟置 0;在此之前 `0` 只关引擎的钟,传输层 300 s 仍在)。亚秒正值不拒:它是合法的「很快就切」表态
122
+ * (failover 拓扑真会这么配;窗上还有 core 按 body 大小加的上传宽限)。
123
+ * · **上界 = {@link MAX_TIMER_DELAY_MS}**:core 对超过它的值构造期拒(`config.stall_timeout_invalid`);本仓在 boot 就拒,
124
+ * 拒句点名 env 键,而不是等到 brain 装配时报一个运维没写过的 config 键名。
125
+ * · **没有缺省常量**:缺席 = 不铸键,core 自缺省当家([ref];本仓不复制上游数字 —— S-753 的病根正是本仓自带的 30 000)。
126
+ */
127
+ export declare const FIRST_BYTE_TIMEOUT_BOUNDS: {
128
+ readonly minMs: 0;
129
+ readonly maxMs: 2147483647;
130
+ };
115
131
  /**
116
132
  * S-220 —— **`resilience` 块缺省的唯一属主**(S-202 的单源从一根扩到整族)。
117
133
  *
@@ -131,7 +147,9 @@ export declare const DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS = 250;
131
147
  * `satisfies Readonly<Record<keyof ServiceConfigFlat["resilience"], …>>` 双向咬:块上加字段而表里没行
132
148
  * ⇒ tsc 红(加字段的人被迫当场回答「它的出厂缺省是多少、归哪个 env」);表里多一行块上没有的键同样红。
133
149
  *
134
- * ⚠️ 语义面**一个数字都没动**:`0 = 关`(三个看门狗)与「熔断默认关」照旧,env 恒赢。
150
+ * ⚠️ 语义面**一个数字都没动**:「熔断默认关」照旧,env 恒赢。看门狗的 `0` 自 core 7.33.0 起 = 该段**不设引擎钟,传输层的响应头 / 响应体钟
151
+ * 也关**(TCP + TLS 建连仍归传输层自己的钟;此前 = 只关引擎的钟)。S-753 起本表**不再有首字节那一行**:`MODEL_CONNECT_TIMEOUT_MS`(自带 30 000)退役,替代键
152
+ * `MODEL_FIRST_BYTE_TIMEOUT_MS` 缺席 = 不铸键(见 {@link ServerDefaultedResilienceKey})。
135
153
  * 首 token 那一行的 600_000 由来(S-202 clay 裁 A):自托管后端在长 prefill(大上下文 / 冷缓存 /
136
154
  * 排队)下首字合理地要等几分钟,120s 会把一次**正常**的慢启动判成 `[network]` 失败去重试,而重试
137
155
  * 再交同一份 prompt 给同一个排队中的后端只会更慢更贵;依赖旧姿态的部署显式写回
@@ -142,10 +160,6 @@ export declare const DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS = 250;
142
160
  * 判「字面量」这一类写法为红(下一份镜像出生即红)。
143
161
  */
144
162
  export declare const RESILIENCE_DEFAULTS: {
145
- readonly connectTimeoutMs: {
146
- readonly env: "MODEL_CONNECT_TIMEOUT_MS";
147
- readonly value: 30000;
148
- };
149
163
  readonly firstTokenTimeoutMs: {
150
164
  readonly env: "MODEL_FIRST_TOKEN_TIMEOUT_MS";
151
165
  readonly value: 600000;
@@ -467,16 +481,20 @@ export interface ServiceConfigFlat {
467
481
  * immediately instead of waiting out timeouts). BreakerState is core's in-process Map for now —
468
482
  * cross-replica shared state (TiDB adapter) is a documented follow-up. */
469
483
  resilience: {
470
- /** Abort fetch if response headers don't arrive within this many ms → retryable [network].
471
- * Default 30s ([ref] fleet audit). 0 = off. */
472
- connectTimeoutMs: number;
484
+ /** S-753(core 7.33.0 #1121)—— `fetch()` → 响应头的等待上限(ms;env `MODEL_FIRST_BYTE_TIMEOUT_MS`,合法域
485
+ * {@link FIRST_BYTE_TIMEOUT_BOUNDS})。**缺席 = 不铸键** ⇒ core 自缺省当家(另加 core 按 body 大小给的上传宽限,
486
+ * 到点后 core 自有一次加宽重试,不吃 `maxRetries`)—— 本仓不复制上游数字([ref])。`0` = 该段不设引擎钟,传输层的响应头 / 响应体钟
487
+ * 也关(TCP + TLS 建连仍归传输层自己的钟)。取代退役的 `connectTimeoutMs`(名义建连、实际就是计到响应头;本仓自带的 30 000 缺省正是 S-753 的病根)。 */
488
+ firstByteTimeoutMs?: number;
473
489
  /** Cancel an open SSE stream if no content/thinking delta arrives within this many ms.
474
- * Default 120s ([ref] fleet audit — core's idle timer only arms after the first token,
475
- * so this is the only guard for a stream that opens and never emits). 0 = off. */
490
+ * Default 600s (S-202; core's idle timer only arms after the first token,
491
+ * so this is the only guard for a stream that opens and never emits). 0 = no engine clock on this
492
+ * segment and the transport's body clock off too (core ≥7.33.0). */
476
493
  firstTokenTimeoutMs: number;
477
494
  /** Mid-stream stall watchdog (1.40.1): after output starts, re-armed on every delta; silence for
478
- * this many ms → cancel + retryable [network]. Catches a gateway that hangs AFTER the first token
479
- * (firstTokenTimeoutMs only covers the first). 0 = off. */
495
+ * this many ms → cancel + retryable. Catches a gateway that hangs AFTER the first token
496
+ * (firstTokenTimeoutMs only covers the first). 0 = no engine clock on this segment and the transport's
497
+ * body clock off too (core ≥7.33.0). */
480
498
  idleTimeoutMs: number;
481
499
  /** Wrap the primary gateway route(s) in a circuit breaker (in-process state, per model+provider). */
482
500
  circuitBreaker: boolean;
@@ -1,13 +1,14 @@
1
+ import { DRAIN_GRACE_FLOOR_MS } from "@sema-agent/settings-schema/fleet";
1
2
  export const TASK_WRITE_FACE_ESCALATIONS = ["allow", "refuse"];
2
3
  export const RETENTION_MODES = ["audit-only", "enforce"];
3
4
  export const MEMORY_CONSOLIDATION_MIN_INTERVAL_SEC = 300;
4
5
  export const MAX_TIMER_DELAY_MS = 2_147_483_647;
5
6
  export const AUTO_MODE_CLASSIFIER_TIMEOUT_BOUNDS = { minMs: 1_000, maxMs: MAX_TIMER_DELAY_MS };
6
- export const DRAIN_GRACE_FLOOR_MS = 10_000;
7
+ export { DRAIN_GRACE_FLOOR_MS };
7
8
  export const DRAIN_SECOND_SIGNAL_MIN_GAP_BOUNDS = { minMs: 0, maxMs: DRAIN_GRACE_FLOOR_MS };
8
9
  export const DRAIN_SECOND_SIGNAL_MIN_GAP_DEFAULT_MS = 250;
10
+ export const FIRST_BYTE_TIMEOUT_BOUNDS = { minMs: 0, maxMs: MAX_TIMER_DELAY_MS };
9
11
  export const RESILIENCE_DEFAULTS = {
10
- connectTimeoutMs: { env: "MODEL_CONNECT_TIMEOUT_MS", value: 30_000 },
11
12
  firstTokenTimeoutMs: { env: "MODEL_FIRST_TOKEN_TIMEOUT_MS", value: 600_000 },
12
13
  idleTimeoutMs: { env: "MODEL_IDLE_TIMEOUT_MS", value: 300_000 },
13
14
  circuitBreaker: { env: "MODEL_CIRCUIT_BREAKER", value: false },
@@ -17,7 +18,6 @@ export const RESILIENCE_DEFAULTS = {
17
18
  export function buildDefaultResilience() {
18
19
  const d = RESILIENCE_DEFAULTS;
19
20
  return {
20
- connectTimeoutMs: d.connectTimeoutMs.value,
21
21
  firstTokenTimeoutMs: d.firstTokenTimeoutMs.value,
22
22
  idleTimeoutMs: d.idleTimeoutMs.value,
23
23
  circuitBreaker: d.circuitBreaker.value,