@sema-agent/server 3.0.0 → 3.2.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 (45) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/USAGE.md +13 -9
  4. package/dist/approval-hmac.js +1 -2
  5. package/dist/bench/s1/live-deps.js +1 -1
  6. package/dist/boot/config-center.d.ts +3 -2
  7. package/dist/boot/config-center.js +33 -19
  8. package/dist/boot/resolve-spec.js +5 -5
  9. package/dist/brain.js +42 -9
  10. package/dist/config-center/apply-effective.d.ts +1 -1
  11. package/dist/config-center/apply-effective.js +4 -4
  12. package/dist/config-center/facade.d.ts +47 -0
  13. package/dist/config-center/facade.js +46 -0
  14. package/dist/config-center/http-client.d.ts +1 -1
  15. package/dist/config-center/http-client.js +6 -6
  16. package/dist/config-center/restart-signal.d.ts +11 -3
  17. package/dist/config-center/restart-signal.js +51 -7
  18. package/dist/config-center/skills-mcp.js +1 -1
  19. package/dist/config-center/types.d.ts +2 -2
  20. package/dist/config-provider.d.ts +7 -7
  21. package/dist/config-provider.js +9 -9
  22. package/dist/config.d.ts +1 -1
  23. package/dist/config.js +5 -5
  24. package/dist/http/routes/approvals-assistant.js +3 -3
  25. package/dist/http/routes/runs.js +7 -7
  26. package/dist/http/routes/sessions.js +1 -1
  27. package/dist/http/routes/trace-usage.js +2 -2
  28. package/dist/http/server.d.ts +1 -1
  29. package/dist/http/server.js +3 -3
  30. package/dist/key-resolver.d.ts +14 -0
  31. package/dist/key-resolver.js +27 -11
  32. package/dist/plugins/remote-env-host.d.ts +32 -14
  33. package/dist/plugins/remote-env-host.js +30 -10
  34. package/dist/plugins/remote-env-local-docker.d.ts +14 -0
  35. package/dist/plugins/remote-env-local-docker.js +10 -3
  36. package/dist/plugins/run-store-sql.d.ts +8 -3
  37. package/dist/plugins/run-store-sql.js +8 -3
  38. package/dist/run-local.js +2 -2
  39. package/dist/runtime-caps-resolver.d.ts +1 -1
  40. package/dist/runtime-caps-resolver.js +1 -1
  41. package/dist/runtime-governance.d.ts +1 -1
  42. package/dist/runtime-governance.js +1 -1
  43. package/dist/sema-registry.d.ts +18 -4
  44. package/dist/sema-registry.js +17 -3
  45. package/package.json +2 -2
@@ -3,11 +3,11 @@
3
3
  * `restart`), computed against the process's BOOT config (not presence, else a refresh would re-fire the same
4
4
  * diff and loop-restart). Also owns the model-plane deferral predicates (`planeHasActiveTiers`/
5
5
  * `modelPlaneChanged`) main.ts uses to decide whether a hot-apply is safe under a tier-frozen Runner. Split out
6
- * of `sema-registry.ts` (design/158 A13, internal-lossless — the facade re-exports every symbol below unchanged).
6
+ * of `facade.ts` (design/158 A13, internal-lossless — the facade re-exports every symbol below unchanged).
7
7
  */
8
8
  import { resolveActiveTiers } from "@sema-agent/registry-core";
9
9
  import { RUNTIME_GATE_KEYS, runtimeGatePresent, resolveDefaultModelName } from "./apply-effective.js";
10
- const RESTART_SLICES = ["skills", "mcp", "scenarios", "runtime-gates", "models-tiers"];
10
+ const RESTART_SLICES = ["skills", "mcp", "scenarios", "runtime-gates", "models-tiers", "degrade-route"];
11
11
  /** Canonical, key-sorted JSON (array order preserved) so two semantically-equal effective slices fingerprint
12
12
  * identically regardless of object key order from the center serializer. */
13
13
  function stableStringify(v) {
@@ -31,7 +31,38 @@ function enabledOnly(rows) {
31
31
  return null;
32
32
  return rows.filter((r) => r.enabled !== false);
33
33
  }
34
- function restartSliceValue(eff, slice) {
34
+ /** One catalog entry, reduced to the fields whose change is genuinely restart-to-apply. Consumers of a
35
+ * boot-frozen catalog entry read almost every field on the FULLY-DERIVED Model (`toModel`) — id → body.model,
36
+ * baseUrl → the endpoint, provider → which brain, maxTokens/compat/extraBody/input → the request body,
37
+ * apiKeyEnv/sealedApiKey → the credential — so we fingerprint the WHOLE entry minus the three provably HOT
38
+ * fields:
39
+ * - `cost` — refreshed by `mutateInPlace(pricing, buildPricing(config.models))`, and core prefers
40
+ * `deps.pricing[model.id]` over the (possibly frozen) `Model.cost`; `toModel` always emits
41
+ * a cost object, so every center-lane model has a live pricing entry.
42
+ * - `quotaWeight` — refreshed by `mutateInPlace(config.modelQuotaWeights, …)`, read live per burn.
43
+ * - `enabled` — `enabledOnly` already filtered on it, so among the entries that reach here it is
44
+ * `true`-or-absent: two encodings of ONE state. Keeping it would let a center serializer
45
+ * that starts/stops emitting the explicit `true` manufacture a restart out of nothing.
46
+ * Every real enable/disable FLIP changes the filtered SET, so it still signals.
47
+ * A DENY-list (not an allow-list) on purpose: a catalog field added later rides the fingerprint automatically,
48
+ * so the failure mode of drift is a spurious restart, never a silently missed one.
49
+ *
50
+ * Shared by BOTH catalog-entry fingerprints (`degrade-route`, `models-tiers` + `modelPlaneChanged`) since the
51
+ * 复审 2026-07-29 留档: models-tiers had kept all three, so on a tiers-active deployment a pure re-pricing /
52
+ * weight tweak / serializer writing `enabled:true` rolling-restarted the fleet. The two fingerprints must
53
+ * subtract the SAME set — under active tiers `modelPlaneChanged` defers the plane and the refresh loop then
54
+ * FORCE-pushes the `models-tiers` reason for a deferred candidate (boot/config-center.ts R15/R22), so
55
+ * subtracting in only one of them is either no-op (restart still fires) or incoherent (applied yet signalled). */
56
+ function catalogEntryFingerprint(entry) {
57
+ const { cost: _cost, quotaWeight: _quotaWeight, enabled: _enabled, ...route } = entry;
58
+ return route;
59
+ }
60
+ /** The enabled entries of a model plane, each reduced by `catalogEntryFingerprint` (null = no plane published). */
61
+ function planeEntriesFingerprint(rows) {
62
+ const enabled = enabledOnly(rows);
63
+ return enabled ? enabled.map((m) => catalogEntryFingerprint(m)) : null;
64
+ }
65
+ function restartSliceValue(eff, slice, ctx) {
35
66
  if (!eff)
36
67
  return null;
37
68
  switch (slice) {
@@ -67,7 +98,17 @@ function restartSliceValue(eff, slice) {
67
98
  // dangling candidates fall through silently here — applyEffective owns the warn.
68
99
  const names = new Set((enabled ?? []).map((m) => m.name));
69
100
  const def = resolveDefaultModelName(eff, (n) => names.has(n), enabled?.[0]?.name ?? "");
70
- return { models: enabled, tiers: active, default: def.name };
101
+ // Entries reduced by the shared deny-list (cost/quotaWeight/enabled are hot see catalogEntryFingerprint).
102
+ return { models: planeEntriesFingerprint(eff.models?.models), tiers: active, default: def.name };
103
+ }
104
+ case "degrade-route": {
105
+ const to = ctx?.reactiveDegradeTo;
106
+ if (to === undefined)
107
+ return null; // lane off — no degrading brain exists to go stale
108
+ // Present↔absent is itself a change: with no enabled entry named `to`, createBrain composes NO
109
+ // degrading shell at all, so the flip only takes effect at the next boot.
110
+ const target = (enabledOnly(eff.models?.models) ?? []).find((m) => m.name === to);
111
+ return target ? catalogEntryFingerprint(target) : null;
71
112
  }
72
113
  }
73
114
  }
@@ -75,8 +116,8 @@ function restartSliceValue(eff, slice) {
75
116
  * nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
76
117
  * can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
77
118
  * against boot (a refresh that re-fires the same diff is idempotent, not a fresh trigger). */
78
- export function restartReasons(boot, current) {
79
- return RESTART_SLICES.filter((s) => stableStringify(restartSliceValue(boot, s)) !== stableStringify(restartSliceValue(current, s)));
119
+ export function restartReasons(boot, current, ctx) {
120
+ return RESTART_SLICES.filter((s) => stableStringify(restartSliceValue(boot, s, ctx)) !== stableStringify(restartSliceValue(current, s, ctx)));
80
121
  }
81
122
  /** codex R10 (models-tiers 窗收口): TRUE when the MODEL PLANE (enabled models + active tier table + resolved
82
123
  * default) of `next` differs from the last-APPLIED plane `prev`. Under a tier-frozen Runner (tiers non-empty at
@@ -102,7 +143,10 @@ export function modelPlaneChanged(prev, next) {
102
143
  const active = e.models ? (resolveActiveTiers(e.models) ?? null) : null;
103
144
  const names = new Set((enabled ?? []).map((m) => m.name));
104
145
  const def = resolveDefaultModelName(e, (n) => names.has(n), enabled?.[0]?.name ?? "");
105
- return stableStringify({ models: enabled, tiers: active, default: def.name });
146
+ // Same reduction as the models-tiers slice the two MUST subtract the same hot fields (see
147
+ // catalogEntryFingerprint): a deferred candidate force-pushes that slice's reason, so any divergence
148
+ // either re-manufactures the restart we just subtracted or signals a plane that was applied hot.
149
+ return stableStringify({ models: planeEntriesFingerprint(e.models?.models), tiers: active, default: def.name });
106
150
  };
107
151
  return fp(prev) !== fp(next);
108
152
  }
@@ -2,7 +2,7 @@
2
2
  * Two small consumption lanes that both project a center manifest domain onto a per-request runtime shape:
3
3
  * skills application (`applyCenterSkills` — merge center skills OVER the image baseline, content-addressed
4
4
  * fetch+verify+cache) and MCP server resolution (`resolveMcpServers`/`mcpForScenario` — env-NAME ref
5
- * resolution + per-scenario filtering). Split out of `sema-registry.ts` (design/158 A13, internal-lossless —
5
+ * resolution + per-scenario filtering). Split out of `facade.ts` (design/158 A13, internal-lossless —
6
6
  * the facade re-exports every symbol below unchanged).
7
7
  */
8
8
  import { skillContentHash } from "@sema-agent/registry-core";
@@ -1,8 +1,8 @@
1
1
  /**
2
- * config-center wire types — the sema-registry `/api/config/effective` payload shapes (`CenterModel`,
2
+ * config-center wire types — the config-center `/api/config/effective` payload shapes (`CenterModel`,
3
3
  * `CenterTeam`, `CenterSkillManifest`, `CenterMcpServer`, `EffectiveConfig`) plus the per-principal
4
4
  * execution/session-mirror ruling shapes riding the caps view (`ExecutionRuling`/`SessionMirrorRuling`).
5
- * Pure type/interface declarations — no runtime logic. Split out of `sema-registry.ts` (design/158 A13,
5
+ * Pure type/interface declarations — no runtime logic. Split out of `facade.ts` (design/158 A13,
6
6
  * internal-lossless: the facade re-exports every symbol below unchanged).
7
7
  */
8
8
  import type { CollabTemplateWire } from "../capabilities/collab-wire.js";
@@ -1,7 +1,7 @@
1
1
  import { FileConfigStore } from "@sema-agent/registry-core/node";
2
2
  import { type EffectiveConfig as AgentConfigEffective } from "@sema-agent/registry-core";
3
- import { fetchEffective as remoteFetchEffective, fetchSkillContent as remoteFetchSkillContent, type EffectiveConfig } from "./sema-registry.js";
4
- /** Result of a `fetchEffective` — EXACTLY sema-registry's return: the effective config + its etag, or
3
+ import { fetchEffective as remoteFetchEffective, fetchSkillContent as remoteFetchSkillContent, type EffectiveConfig } from "./config-center/facade.js";
4
+ /** Result of a `fetchEffective` — EXACTLY the config-center's return: the effective config + its etag, or
5
5
  * `null` for "unchanged" (remote 304; local: the version matched the caller's prior etag).
6
6
  * `domainErrors`([898] registry-core 0.10.12 tolerant seam,local lane only):catalog 域坏文件不再
7
7
  * 连坐全包回落 env——坏域按该域 schema default 落+错误单列,好域照常生效;caller(main.ts)对每条打
@@ -29,12 +29,12 @@ export declare const BOOT_FETCH_DEFERRED: unique symbol;
29
29
  export declare function raceBootFetch<T>(fetch: Promise<T>, budgetMs: number): Promise<T | typeof BOOT_FETCH_DEFERRED>;
30
30
  /**
31
31
  * The two transport calls the service makes to obtain config, behind a backend-selectable seam. Both
32
- * signatures and return shapes match `sema-registry.fetchEffective` / `sema-registry.fetchSkillContent`
32
+ * signatures and return shapes match `facade.fetchEffective` / `facade.fetchSkillContent`
33
33
  * EXACTLY so that, ONCE WIRED, the caller (main.ts) could swap remote↔local without touching applyEffective
34
34
  * et al. (main.ts does not swap on this seam yet — see the NOT-YET-WIRED note at the top of this module).
35
35
  */
36
36
  export interface ConfigProvider {
37
- /** "remote" = sema-registry HTTP; "local" = on-disk FileConfigStore. */
37
+ /** "remote" = config-center HTTP; "local" = on-disk FileConfigStore. */
38
38
  readonly kind: "remote" | "local";
39
39
  /**
40
40
  * Pull the effective config. `etag` is the caller's last-seen version token (`if-none-match` on remote;
@@ -72,9 +72,9 @@ export interface ConfigProviderInput {
72
72
  localDir?: string;
73
73
  }
74
74
  /**
75
- * RemoteConfigProvider — DELEGATES to the exported sema-registry HTTP functions. No HTTP is reimplemented;
75
+ * RemoteConfigProvider — DELEGATES to the exported config-center HTTP functions. No HTTP is reimplemented;
76
76
  * baseUrl/token/worker are bound once and passed through. The two delegate fns are injectable (defaulting
77
- * to the real sema-registry exports) so a unit test can assert delegation without module-mocking.
77
+ * to the real config-center exports) so a unit test can assert delegation without module-mocking.
78
78
  */
79
79
  export declare class RemoteConfigProvider implements ConfigProvider {
80
80
  private readonly cc;
@@ -120,7 +120,7 @@ export declare class LocalConfigProvider implements ConfigProvider {
120
120
  export declare function mapToServiceEffective(eff: AgentConfigEffective, version: number): EffectiveConfig;
121
121
  /**
122
122
  * Pick the backend off a CLOSED `provider` enum (`local` | `remote` | undefined): LOCAL when
123
- * `CONFIG_PROVIDER=local`, OR when no sema-registry URL is configured (a lone box with config.d/ on disk
123
+ * `CONFIG_PROVIDER=local`, OR when no config-center URL is configured (a lone box with config.d/ on disk
124
124
  * and no control plane). REMOTE otherwise (a configured center URL, the default fleet posture). A
125
125
  * `CONFIG_PROVIDER=remote` with no URL is a misconfiguration — we fall through to local rather than
126
126
  * constructing a remote provider with no endpoint (the caller would have skipped the remote pull entirely
@@ -2,7 +2,7 @@
2
2
  * ConfigProvider — the dual-mode seam over WHERE the effective config comes from (see
3
3
  * sema-registry docs/DUAL-MODE-DESIGN.md §4).
4
4
  *
5
- * The service consumes sema-registry through EXACTLY two transport calls — `fetchEffective` (pull the
5
+ * The service consumes the config-center through EXACTLY two transport calls — `fetchEffective` (pull the
6
6
  * effective config, ETag-conditional) and `fetchSkillContent` (lazy-pull one skill body by content hash).
7
7
  * Everything downstream (applyEffective / applyRuntimeGates / resolveMcpServers / mcpForScenario / the
8
8
  * skill overlay) is transport-agnostic: it operates on the returned `EffectiveConfig` / skill string.
@@ -17,7 +17,7 @@
17
17
  * This module factors those two calls behind a `ConfigProvider` interface with two backends:
18
18
  *
19
19
  * - {@link RemoteConfigProvider} — the existing behaviour. DELEGATES verbatim to the exported
20
- * `sema-registry.fetchEffective` / `sema-registry.fetchSkillContent` (HTTP + Bearer + ETag + hash
20
+ * `facade.fetchEffective` / `facade.fetchSkillContent` (HTTP + Bearer + ETag + hash
21
21
  * verify). No HTTP is reimplemented here; this is a thin wrapper that binds baseUrl/token/worker.
22
22
  *
23
23
  * - {@link LocalConfigProvider} — reads the SAME config contract from the local filesystem via
@@ -30,7 +30,7 @@
30
30
  *
31
31
  * SECRET BOUNDARY (unchanged in both modes): the config plane carries only env-NAME refs — `apiKeyEnv`,
32
32
  * `envRefs`/`headerRefs`, `tokenEnv`. The real secret VALUE is never stored in config.d/<domain>.json and
33
- * never read by this module; it is resolved from `process.env` downstream (sema-registry.ts
33
+ * never read by this module; it is resolved from `process.env` downstream (config-center/facade.ts
34
34
  * `applyEffective`/`resolveMcpServers`). The local store is symmetric with the remote center on this: it
35
35
  * ships NAMEs only. (Unit test asserts the local path resolves env-NAME→value via process.env and never
36
36
  * persists a value.)
@@ -39,7 +39,7 @@ import { createHmac, randomBytes } from "node:crypto";
39
39
  import { FileConfigStore } from "@sema-agent/registry-core/node";
40
40
  import { findSkillContent, skillContentHash, refIntegrityIssues, siblingResolver, } from "@sema-agent/registry-core";
41
41
  import { redactSecrets } from "./trace/redact.js";
42
- import { fetchEffective as remoteFetchEffective, fetchSkillContent as remoteFetchSkillContent, ConfigCenterHttpError, } from "./sema-registry.js";
42
+ import { fetchEffective as remoteFetchEffective, fetchSkillContent as remoteFetchSkillContent, ConfigCenterHttpError, } from "./config-center/facade.js";
43
43
  import { createLogger } from "./observability/logger.js";
44
44
  const logger = createLogger();
45
45
  /**
@@ -79,9 +79,9 @@ export function raceBootFetch(fetch, budgetMs) {
79
79
  return Promise.race([fetch.finally(() => clearTimeout(timer)), budget]);
80
80
  }
81
81
  /**
82
- * RemoteConfigProvider — DELEGATES to the exported sema-registry HTTP functions. No HTTP is reimplemented;
82
+ * RemoteConfigProvider — DELEGATES to the exported config-center HTTP functions. No HTTP is reimplemented;
83
83
  * baseUrl/token/worker are bound once and passed through. The two delegate fns are injectable (defaulting
84
- * to the real sema-registry exports) so a unit test can assert delegation without module-mocking.
84
+ * to the real config-center exports) so a unit test can assert delegation without module-mocking.
85
85
  */
86
86
  export class RemoteConfigProvider {
87
87
  cc;
@@ -93,7 +93,7 @@ export class RemoteConfigProvider {
93
93
  }
94
94
  fetchEffective(etag) {
95
95
  const fn = this.deps.fetchEffective ?? remoteFetchEffective;
96
- // 5th arg = worker scope; transport (fetchImpl) stays sema-registry's default.
96
+ // 5th arg = worker scope; transport (fetchImpl) stays the config-center's default.
97
97
  return fn(this.cc.baseUrl, this.cc.token, etag, undefined, this.cc.worker);
98
98
  }
99
99
  async fetchSkillContent(contentHash) {
@@ -219,7 +219,7 @@ export function mapToServiceEffective(eff, version) {
219
219
  // [874] registry-core 0.10.11:池结构级 `default?`(catalog name 目录键,与 roles.default 角色词两回事)。
220
220
  // 本 mapper 是 CLOSED 投影——不直通则本地 lane 静默丢键(autoCompactTokens 同类病),applyEffective 的
221
221
  // resolveDefaultModelName 第一优先级在本地 lane 永远点不亮。写面悬空 ref 由 registry-core superRefine
222
- // fail-loud;存量/未经 schema 的路径仍由消费方 warn 降级(sema-registry.ts resolveDefaultModelName)。
222
+ // fail-loud;存量/未经 schema 的路径仍由消费方 warn 降级(config-center/facade.ts resolveDefaultModelName)。
223
223
  ...(eff.models.default !== undefined ? { default: eff.models.default } : {}),
224
224
  ...(eff.models.atModelAllowlist !== undefined ? { atModelAllowlist: eff.models.atModelAllowlist } : {}),
225
225
  // registry-core 0.9.0 档位组: verbatim passthrough — applyEffective's resolveActiveTiers reads
@@ -322,7 +322,7 @@ export function mapToServiceEffective(eff, version) {
322
322
  }
323
323
  /**
324
324
  * Pick the backend off a CLOSED `provider` enum (`local` | `remote` | undefined): LOCAL when
325
- * `CONFIG_PROVIDER=local`, OR when no sema-registry URL is configured (a lone box with config.d/ on disk
325
+ * `CONFIG_PROVIDER=local`, OR when no config-center URL is configured (a lone box with config.d/ on disk
326
326
  * and no control plane). REMOTE otherwise (a configured center URL, the default fleet posture). A
327
327
  * `CONFIG_PROVIDER=remote` with no URL is a misconfiguration — we fall through to local rather than
328
328
  * constructing a remote provider with no endpoint (the caller would have skipped the remote pull entirely
package/dist/config.d.ts CHANGED
@@ -8,7 +8,7 @@ export type { ServiceConfig, ScopedMcpServer, ImageBakeConfig, ServiceConfigFlat
8
8
  /** Parse the AUTONOMY env into a validated autonomy mode. Unset/empty → undefined (unmanaged → no extra
9
9
  * tightening). An UNKNOWN value FAILS at startup rather than silently becoming a no-op (a typo'd `AUTONOMY=readonly`
10
10
  * must not silently leave a deployment ungoverned — fail-loud, same discipline as numEnv). Exported so the HOT
11
- * config overlay (sema-registry.ts `applyRuntimeHot`) can re-derive the ENV BASELINE to revert to when center
11
+ * config overlay (config-center/facade.ts `applyRuntimeHot`) can re-derive the ENV BASELINE to revert to when center
12
12
  * stops managing `autonomy` (a stale center override must not stick — see applyRuntimeHot). */
13
13
  export declare function parseAutonomy(raw: string | undefined): Autonomy | undefined;
14
14
  export declare function drainConfigWarnings(): Array<{
package/dist/config.js CHANGED
@@ -15,7 +15,7 @@ const AUTONOMY_MODES = ["read-only", "ask", "plan", "auto"];
15
15
  /** Parse the AUTONOMY env into a validated autonomy mode. Unset/empty → undefined (unmanaged → no extra
16
16
  * tightening). An UNKNOWN value FAILS at startup rather than silently becoming a no-op (a typo'd `AUTONOMY=readonly`
17
17
  * must not silently leave a deployment ungoverned — fail-loud, same discipline as numEnv). Exported so the HOT
18
- * config overlay (sema-registry.ts `applyRuntimeHot`) can re-derive the ENV BASELINE to revert to when center
18
+ * config overlay (config-center/facade.ts `applyRuntimeHot`) can re-derive the ENV BASELINE to revert to when center
19
19
  * stops managing `autonomy` (a stale center override must not stick — see applyRuntimeHot). */
20
20
  export function parseAutonomy(raw) {
21
21
  const v = raw?.trim();
@@ -534,8 +534,8 @@ function parseModelDomain() {
534
534
  // when body.images hits a text-only model) AND the vision flag on GET /v1/models. The env-lane default model
535
535
  // declares vision via MODEL_VISION — DEFAULT "true" keeps the historical image-capable behavior, so set
536
536
  // MODEL_VISION=false for a text-only model (e.g. deepseek) to make the precheck actually fire instead of letting
537
- // images透传 to an opaque downstream-gateway 400. (The centralized sema-registry lane derives input from its own
538
- // per-model vision flag — see sema-registry.ts.)
537
+ // images透传 to an opaque downstream-gateway 400. (The centralized config-center lane derives input from its own
538
+ // per-model vision flag — see config-center/facade.ts.)
539
539
  input: env("MODEL_VISION", "true") === "true" ? ["text", "image"] : ["text"],
540
540
  // Per-1M-token USD pricing (core `modelCostToPricing`: input→inputPer1M, …). Default 0 keeps the
541
541
  // historical behavior (spend reads $0) — set MODEL_COST_* on a deploy to make `model_cost_micro_usd`
@@ -624,7 +624,7 @@ function parseModelDomain() {
624
624
  // Static catalog audit for this batch: ZERO baked 1M entries exist (env lane defaults 262144; center lane
625
625
  // inherits/declares per roster) — so this is a DERIVATION on the declared window, not a data edit: any
626
626
  // deployment that sets MODEL_CONTEXT_WINDOW>=1e6 (or a center roster with a 1M contextWindow —
627
- // sema-registry.ts applies the same helper) gets the field automatically. <1M ⇒ field ABSENT (core's plain
627
+ // config-center/facade.ts applies the same helper) gets the field automatically. <1M ⇒ field ABSENT (core's plain
628
628
  // W-33000/0.7W geometry already matches CC there).
629
629
  applyAutoCompactWindow(model);
630
630
  if (cheapModel)
@@ -849,7 +849,7 @@ function parseMemoryDomain(ctx) {
849
849
  memoryEngineBackend,
850
850
  memoryScope, // hoisted above (the 142-S2.5-W1 sync-scope default consumes it)
851
851
  ...(memorySync ? { memorySync } : {}),
852
- projectMemoryEnabled, // design/113 C4 opt-out, flipped positive in design/158 B4 (legacy PROJECT_MEMORY_DISABLED still honored)
852
+ projectMemoryEnabled, // design/113 C4 opt-out, flipped positive in design/158 B4 (3.0.0 起 legacy PROJECT_MEMORY_DISABLED = fail-loud 墓碑)
853
853
  syncImportLeaseStaleSec: Math.max(0, numEnv("SYNC_IMPORT_LEASE_STALE_SEC", "600")),
854
854
  };
855
855
  }
@@ -209,7 +209,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
209
209
  // (yields a task, runs no model — see the billable-route classifier) → NO lease gate, else an exhausted
210
210
  // tenant could not stop its own spending.
211
211
  if (!deps.runStore) {
212
- sendError(res, 501, "capability.run_store_required", "preemption requires the TiDB run store");
212
+ sendError(res, 501, "capability.run_store_required", "preemption requires a durable run store (DB_BACKEND=mysql|pg)");
213
213
  return;
214
214
  }
215
215
  // requirePrincipal parity with the sibling mutating endpoints (runOwnerOk / cancel / runs): 401 before the
@@ -282,7 +282,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
282
282
  if (rateLimited(req, res) || quotaExceeded(req, res))
283
283
  return; // 🔴 复审 C2:lease admitted INSIDE driveResumeIntoRunLog on the CHECKPOINT-OWNER principal (the billed tenant), not the request principal — a cross-tenant operator resume must charge the owner's lease, not the operator's. resume hits TiDB + runs the model
284
284
  if (!deps.runStore) {
285
- sendError(res, 501, "capability.run_store_required", "resume requires the TiDB run store");
285
+ sendError(res, 501, "capability.run_store_required", "resume requires a durable run store (DB_BACKEND=mysql|pg)");
286
286
  return;
287
287
  }
288
288
  if (deps.config.requirePrincipal && principal === undefined) {
@@ -319,7 +319,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
319
319
  if (rateLimited(req, res) || quotaExceeded(req, res))
320
320
  return; // 🔴 复审 C2:lease admitted in driveResumeIntoRunLog (owner principal). resume hits TiDB + runs the model (approve/edit)
321
321
  if (!deps.runStore) {
322
- sendError(res, 501, "capability.run_store_required", "plan_review requires the TiDB run store");
322
+ sendError(res, 501, "capability.run_store_required", "plan_review requires a durable run store (DB_BACKEND=mysql|pg)");
323
323
  return;
324
324
  }
325
325
  if (deps.config.requirePrincipal && principal === undefined) {
@@ -93,7 +93,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
93
93
  // Async run: create + return ids immediately, execute in the background.
94
94
  if (req.method === "POST" && url === "/v1/runs") {
95
95
  if (!deps.runStore) {
96
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store (SESSION_BACKEND=tidb)");
96
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
97
97
  return;
98
98
  }
99
99
  // Idempotency-Key dedup (center blocker): a retried create returns the SAME taskId instead of starting a
@@ -222,7 +222,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
222
222
  const runMatch = req.method === "GET" ? RUN_ID_RE.exec(url) : null;
223
223
  if (runMatch) {
224
224
  if (!deps.runStore) {
225
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
225
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
226
226
  return;
227
227
  }
228
228
  const taskId = runMatch[1];
@@ -310,7 +310,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
310
310
  const cancelMatch = req.method === "POST" ? RUN_CANCEL_RE.exec(url) : null;
311
311
  if (cancelMatch) {
312
312
  if (!deps.runStore) {
313
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
313
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
314
314
  return;
315
315
  }
316
316
  // Principal check BEFORE the lookup (parity with GET /v1/runs/:id): no 404-vs-401 existence oracle.
@@ -496,7 +496,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
496
496
  if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
497
497
  return; // mutating + hits TiDB / runs the model
498
498
  if (!deps.runStore) {
499
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
499
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
500
500
  return;
501
501
  }
502
502
  // Per-tenant identity MUST come from gatedPrincipal (direct-door secure single point), NOT principalFrom:
@@ -713,7 +713,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
713
713
  if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
714
714
  return; // mutating + runs the model (compaction summarizes)
715
715
  if (!deps.runStore) {
716
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
716
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
717
717
  return;
718
718
  }
719
719
  const principal = gatedPrincipal(req, deps.config); // direct-door-secure identity, never the spoofable header
@@ -818,7 +818,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
818
818
  if (rateLimited(req, res))
819
819
  return; // mutating, but runs no model (no quota gate — parity with cancel, not steer)
820
820
  if (!deps.runStore) {
821
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
821
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
822
822
  return;
823
823
  }
824
824
  const principal = gatedPrincipal(req, deps.config); // direct-door-secure identity, never the spoofable header
@@ -1183,7 +1183,7 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1183
1183
  if (rateLimited(req, res) || quotaExceeded(req, res) || (await leaseDenied(req, res)))
1184
1184
  return; // mutating + drives a model agent
1185
1185
  if (!deps.runStore) {
1186
- sendError(res, 501, "capability.run_store_required", "async runs require the TiDB run store");
1186
+ sendError(res, 501, "capability.run_store_required", "async runs require a durable run store (DB_BACKEND=mysql|pg)");
1187
1187
  return;
1188
1188
  }
1189
1189
  // Identity from gatedPrincipal (direct-door-secure single point), NEVER the spoofable header — `trusted`
@@ -1,5 +1,5 @@
1
1
  import { materializeMcpTools, sanitizePathComponent, SessionPolicyError } from "@sema-agent/core";
2
- import { mcpForScenario } from "../../sema-registry.js";
2
+ import { mcpForScenario } from "../../config-center/facade.js";
3
3
  import { isUuidV7, isUuidShape } from "../../security.js";
4
4
  import { windowMessages, truncateMessageBlobs } from "../../audit.js";
5
5
  import { redactSecrets } from "../../trace/redact.js";
@@ -36,7 +36,7 @@ async function handleTraceUsageBody(req, res, url, ctx, miss) {
36
36
  return;
37
37
  }
38
38
  if (!deps.runStore) {
39
- sendError(res, 501, "capability.run_store_required", "trace API requires the TiDB run store (SESSION_BACKEND=tidb)");
39
+ sendError(res, 501, "capability.run_store_required", "trace API requires a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
40
40
  return;
41
41
  }
42
42
  const q = new URL(req.url ?? "", "http://x").searchParams;
@@ -56,7 +56,7 @@ async function handleTraceUsageBody(req, res, url, ctx, miss) {
56
56
  return;
57
57
  }
58
58
  if (!deps.runStore) {
59
- sendError(res, 501, "capability.run_store_required", "trace API requires the TiDB run store (SESSION_BACKEND=tidb)");
59
+ sendError(res, 501, "capability.run_store_required", "trace API requires a durable run store (DB_BACKEND=mysql|pg) (SESSION_BACKEND=tidb)");
60
60
  return;
61
61
  }
62
62
  const query = new URL(req.url ?? "", "http://x").searchParams;
@@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http";
3
3
  import { type Runner, type TaskSpec, type TaskResult, type WorkflowRunStore, type MemoryEntry } from "@sema-agent/core";
4
4
  import type { TaskRequestBody } from "./wire-types.js";
5
5
  import type { ServiceConfig } from "../config-types.js";
6
- import { type RestartSignal, type SessionMirrorRuling } from "../sema-registry.js";
6
+ import { type RestartSignal, type SessionMirrorRuling } from "../config-center/facade.js";
7
7
  import { type OwnerAwareSessionStore } from "../security.js";
8
8
  import type { RunStore, ApprovalStore, ImageIndex, ImageBake, CheckpointStoreFull, ResumeAnchorStore, ApprovalExemptionStore, ServiceSessionPolicyStore, ServiceFileSnapshotStore, StoreBackend } from "../plugins/store-backend.js";
9
9
  import { type MemorySyncRequest, type MemorySyncResponse } from "../memory-sync.js";
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import { uuidv7, isThinkingLevel, expandTiers, resumeWithVerification, CheckpointError, HAND_TOOL_EFFECTS, canonicalToolName, defaultTaskRegistry, validatePendingSteer, subscribeWorkflow } from "@sema-agent/core"; // canonicalToolName = core single-source (1.162; replaced the transitional service mirror)
5
5
  import { decideParkedAgent, findParkedAgentForCheckpoint } from "../parked-decide.js";
6
6
  import { matchCatalogModel } from "../model-select.js";
7
- import {} from "../sema-registry.js";
7
+ import {} from "../config-center/facade.js";
8
8
  import { HttpError, principalFrom, verifiedPrincipal, setSsoPrincipal, ssoVerifiedPrincipal, setSsoScope, isUuidV7, verifyDirectDoorProof } from "../security.js";
9
9
  import { exportSession, importSession } from "../session-sync.js";
10
10
  import {} from "../memory-sync.js";
@@ -407,7 +407,7 @@ export function createHttpServer(rawDeps) {
407
407
  return;
408
408
  }
409
409
  // Metrics (read-only): authorized by EITHER the full authToken OR a read-only metricsToken — so a
410
- // control plane (sema-registry) can pull metrics fleet-wide with one token, never holding each
410
+ // control plane (config-center) can pull metrics fleet-wide with one token, never holding each
411
411
  // worker's full authToken. Handled BEFORE the global gate so metricsToken-only callers aren't 401'd.
412
412
  if (req.method === "GET" && (url === "/metrics" || url === "/metrics/summary" || url === "/metrics/plan-cache")) {
413
413
  if (!deps.metrics) {
@@ -495,7 +495,7 @@ export function createHttpServer(rawDeps) {
495
495
  reqState.source = source;
496
496
  // Mandatory service token on submission endpoints (center: a worker MUST validate a service-to-service
497
497
  // token on task creation — not "if auth is configured" — else anything in-cluster can submit & bill
498
- // directly, bypassing sema-registry's auth/audit). Fail-closed: the POST task/run/leader endpoints refuse
498
+ // directly, bypassing config-center's auth/audit). Fail-closed: the POST task/run/leader endpoints refuse
499
499
  // when no authToken is set, unless explicitly opted out for local dev (ALLOW_UNAUTHED_WRITES=true).
500
500
  // The bake door (POST /v1/images/bakes*) is build-host-RCE-capable and authed by the Bearer-token-no-cookie
501
501
  // model (§P2.4b) — it MUST also refuse when no service token is configured (else a forged principal header
@@ -26,4 +26,18 @@ import { type SealedKeyPoison } from "./sealed-key.js";
26
26
  export declare function createKeyResolver(modelApiKeyEnv: Record<string, string>, env?: NodeJS.ProcessEnv, modelApiKeys?: Record<string, string | SealedKeyPoison>): ((model: Model) => Promise<{
27
27
  apiKey: string;
28
28
  } | undefined>) | undefined;
29
+ /**
30
+ * The ONE per-model key chain, synchronous: **sealed custody → env-NAME reference → (caller's) gateway key**,
31
+ * with a poisoned sealed entry THROWING instead of returning undefined. `createKeyResolver` is the async
32
+ * `getApiKeyAndHeaders` face of exactly this function — every other consumer must call it rather than
33
+ * re-implement a subset of the chain.
34
+ *
35
+ * 复审 2026-07-29(同族缺口):`brain.ts` 的反应式降级 hop 曾只查 `modelApiKeyEnv` —— sealed-box 托管密钥的
36
+ * 降级目标(按互斥契约**永不**落 modelApiKeyEnv)于是静默拿网关 key 打自己的网关(错 key 打上游),毒丸更
37
+ * 被当成"没配 key"。链只有一条,重复实现就会长出这种半条链的偏差,故此处抽出同步内核共用。
38
+ *
39
+ * `undefined` = this model has no per-model key ⇒ the CALLER's gateway/default credential applies (the
40
+ * additive contract). An empty env value counts as unset (unchanged from the original inline chain).
41
+ */
42
+ export declare function resolveModelApiKey(modelName: string, modelApiKeyEnv: Record<string, string>, env?: NodeJS.ProcessEnv, modelApiKeys?: Record<string, string | SealedKeyPoison>): string | undefined;
29
43
  //# sourceMappingURL=key-resolver.d.ts.map
@@ -26,18 +26,34 @@ export function createKeyResolver(modelApiKeyEnv, env = process.env, modelApiKey
26
26
  if (Object.keys(modelApiKeyEnv).length === 0 && Object.keys(modelApiKeys).length === 0)
27
27
  return undefined;
28
28
  return async (model) => {
29
- const sealed = modelApiKeys[model.name];
30
- // Poison pill: the operator configured a sealed key for this model but it cannot be unsealed —
31
- // fail the brain call LOUD (never undefined = never the gateway fallback).
32
- if (isSealedKeyPoison(sealed))
33
- throw new SealedKeyPoisonedError(model.name, sealed);
34
- if (sealed)
35
- return { apiKey: sealed }; // sealed-box custody wins (registry-core: sealed 压过 apiKeyEnv)
36
- const envName = modelApiKeyEnv[model.name];
37
- if (!envName)
38
- return undefined; // model has no per-model key → core uses the gateway key
39
- const apiKey = env[envName];
29
+ const apiKey = resolveModelApiKey(model.name, modelApiKeyEnv, env, modelApiKeys);
40
30
  return apiKey ? { apiKey } : undefined;
41
31
  };
42
32
  }
33
+ /**
34
+ * The ONE per-model key chain, synchronous: **sealed custody → env-NAME reference → (caller's) gateway key**,
35
+ * with a poisoned sealed entry THROWING instead of returning undefined. `createKeyResolver` is the async
36
+ * `getApiKeyAndHeaders` face of exactly this function — every other consumer must call it rather than
37
+ * re-implement a subset of the chain.
38
+ *
39
+ * 复审 2026-07-29(同族缺口):`brain.ts` 的反应式降级 hop 曾只查 `modelApiKeyEnv` —— sealed-box 托管密钥的
40
+ * 降级目标(按互斥契约**永不**落 modelApiKeyEnv)于是静默拿网关 key 打自己的网关(错 key 打上游),毒丸更
41
+ * 被当成"没配 key"。链只有一条,重复实现就会长出这种半条链的偏差,故此处抽出同步内核共用。
42
+ *
43
+ * `undefined` = this model has no per-model key ⇒ the CALLER's gateway/default credential applies (the
44
+ * additive contract). An empty env value counts as unset (unchanged from the original inline chain).
45
+ */
46
+ export function resolveModelApiKey(modelName, modelApiKeyEnv, env = process.env, modelApiKeys = {}) {
47
+ const sealed = modelApiKeys[modelName];
48
+ // Poison pill: the operator configured a sealed key for this model but it cannot be unsealed —
49
+ // fail the brain call LOUD (never undefined = never the gateway fallback).
50
+ if (isSealedKeyPoison(sealed))
51
+ throw new SealedKeyPoisonedError(modelName, sealed);
52
+ if (sealed)
53
+ return sealed; // sealed-box custody wins (registry-core: sealed 压过 apiKeyEnv)
54
+ const envName = modelApiKeyEnv[modelName];
55
+ if (!envName)
56
+ return undefined; // model has no per-model key → core uses the gateway key
57
+ return env[envName] || undefined;
58
+ }
43
59
  //# sourceMappingURL=key-resolver.js.map
@@ -280,22 +280,40 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
280
280
  * `ctx.sessionId` into the workspace dir id so two concurrent tasks never share a working directory.
281
281
  */
282
282
  export declare function hostExecutionEnvFactory(config?: HostEnvConfig): ExecutionEnvFactory;
283
- export { localDockerExecutionEnvFactory, type LocalDockerEnvConfig, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js";
284
- /** Config for the (not-yet-implemented) `remote-docker` provider: run each task in a container on a REMOTE
285
- * docker daemon (e.g. over a TLS docker endpoint). Fast-follow per DUAL-MODE-DESIGN §5. */
286
- export interface RemoteDockerEnvConfig {
287
- /** Container image to run the task in. */
288
- image: string;
289
- /** Workspace mount path inside the container. */
290
- mountPath?: string;
291
- /** Remote docker daemon endpoint (e.g. tcp://host:2376 or ssh://user@host). Required for remote-docker. */
283
+ export { localDockerExecutionEnvFactory, type LocalDockerEnvConfig, type LocalDockerEnvDeps, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js";
284
+ import { type LocalDockerEnvConfig, type LocalDockerEnvDeps } from "./remote-env-local-docker.js";
285
+ /**
286
+ * Config for the `remote-docker` provider: run each task in a container on a REMOTE docker daemon
287
+ * (DUAL-MODE-DESIGN §5 "a user-specified REMOTE host's docker"; registry-core `RemoteExecSpec`).
288
+ *
289
+ * It is {@link LocalDockerEnvConfig} with the endpoint made REQUIRED — that is the whole difference, by design:
290
+ * the container adapter is already daemon-agnostic (workspace `mkdir`'d INSIDE the container, never a host
291
+ * bind-mount; bytes over `docker cp` / `docker exec cat`, which stream through the daemon API), and the endpoint
292
+ * rides as `-H <dockerHost>` on every invocation. So every local-docker knob (memory/cpus/pidsLimit/dropAllCaps/
293
+ * network/timeouts/env/dockerPath) applies unchanged to the remote lane.
294
+ */
295
+ export interface RemoteDockerEnvConfig extends Omit<LocalDockerEnvConfig, "dockerHost" | "providerLabel"> {
296
+ /** Remote docker daemon endpoint (e.g. `tcp://host:2376` or `ssh://user@host`). REQUIRED for remote-docker. */
292
297
  dockerHost: string;
293
- /** Out-of-band env injected into the container (secrets resolved by the control plane). */
294
- env?: Record<string, string>;
295
298
  }
296
299
  /**
297
- * `remote-docker` factory entry point wired but an explicit TODO (DUAL-MODE-DESIGN §5 fast-follow). Throws
298
- * at factory-BUILD time so selecting it before it exists fails loud rather than degrading isolation silently.
300
+ * `ExecutionEnvFactory` for the TOC `remote-docker` backend (DUAL-MODE-DESIGN §5): a per-task container on
301
+ * SOMEONE ELSE'S docker daemon (offload the operator's box stays free; isolation:true, suspendable:false).
302
+ * Same lifetime contract as local-docker: unconnected/lazy, one container per task, `destroy()` = `docker rm -f`.
303
+ *
304
+ * 🔴 **Endpoint credentials are the docker CLI's own** — this adapter deliberately carries none. The docker CLI
305
+ * reads `DOCKER_CERT_PATH` + `DOCKER_TLS_VERIFY` from the worker's environment (inherited by every spawn) for a
306
+ * `tcp://` + TLS daemon, and uses `ssh-agent` / `~/.ssh/config` for an `ssh://` one. That keeps key material out
307
+ * of this process's config objects entirely. Consequence to know: `tcp://…:2376` WITHOUT those env vars fails
308
+ * loud at connect (the daemon refuses plaintext) — it does not silently downgrade; a plaintext `tcp://…:2375`
309
+ * daemon is unauthenticated and remains the operator's decision, exactly as in the registry-core schema.
310
+ * (The registry-core `remote-docker` arm additionally models `tlsCertEnv`/`tlsKeyEnv`/`tlsCaEnv`/`sshKeyEnv` =
311
+ * env-NAMEs the control plane resolves to PEM BYTES. Materializing those bytes to short-lived 0600 files and
312
+ * passing `--tlsverify --tlscert/--tlskey/--tlscacert` is the remaining config-surface follow-up, together with
313
+ * the `provider:"remote-docker"` arm on `AppConfig.remoteExec` — see the note on that union in config-types.ts.)
314
+ *
315
+ * Throws at factory-BUILD time on a missing/blank endpoint: a "remote" lane with no endpoint would otherwise
316
+ * quietly become the LOCAL daemon — an isolation-topology surprise, so it fails loud instead.
299
317
  */
300
- export declare function remoteDockerExecutionEnvFactory(_config: RemoteDockerEnvConfig): ExecutionEnvFactory;
318
+ export declare function remoteDockerExecutionEnvFactory(config: RemoteDockerEnvConfig, deps?: LocalDockerEnvDeps): ExecutionEnvFactory;
301
319
  //# sourceMappingURL=remote-env-host.d.ts.map