@sema-agent/server 3.0.0 → 3.1.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.
package/USAGE.md CHANGED
@@ -239,6 +239,10 @@ SEMA_REGISTRY_WORKER=<worker名> # 可选:拉取 /effective?worke
239
239
  🪦 `LSP_ENABLED=false`(拆分前唯一的 host 腿逃生舱)自 3.0.0 起是墓碑:拒启并指路 `LSP_HOST_ENABLED`。
240
240
  `LSP_ENABLED=true` 不受影响——那是沙箱腿自己的 opt-in,语义没变。
241
241
 
242
+ **`CONFIG_REQUIRE_ROSTER=true`(3.1.0,可选,缺省关)**:远程 registry 非 dryRun 车道的 fleet worker
243
+ 可布防「roster 落地前拒接计费提交(503)」——3.0.0 起 MODEL_ID 必填,每个 worker 都带 boot 模型起服,
244
+ 旧的「无 env 模型即等 roster」推断失效;要那个保护语义现在需显式声明。
245
+
242
246
  ---
243
247
 
244
248
  ## 1. 请求与响应的通用规则
@@ -47,7 +47,8 @@ export interface ConfigCenterRuntime {
47
47
  } | undefined>) | undefined;
48
48
  /** 晚绑取值:center 提示词面(热采用,新任务边界生效)。 */
49
49
  getCenterPrompts(): PromptsDomainFaces | undefined;
50
- /** boot ready 门:roster 未落地 = 计费提交 503 + /health ready:false。 */
50
+ /** boot ready 门:布防(`CONFIG_REQUIRE_ROSTER=true`,远程 registry dryRun)且 roster 未落地 =
51
+ * 计费提交 503 + /health ready:false。缺省未布防 ⇒ 恒 true。 */
51
52
  modelReady(): boolean;
52
53
  /** /health.restart(orchestrator 自动重启信号)。 */
53
54
  restartState(): RestartSignal | undefined;
@@ -119,17 +119,24 @@ export async function createConfigCenterRuntime(ctx) {
119
119
  ...(config.configProvider === "local" ? { localDir: localRoot } : {}),
120
120
  })
121
121
  : undefined;
122
- // boot ready 门 latch:仅「远程 registry 部署(非 dryRun)+ 无显式 env MODEL_ID」时等 roster——
123
- // 首次 effective ≥1 enabled 模型即翻 true(one-way)。其余姿势(env 模型在/local provider/dryRun/纯 env)
124
- // 恒 ready=现有部署零影响。E3 废除 workers.model 后,fleet worker 的占位模型窗口由此门 fail-closed
122
+ // boot ready 门 latch:布防时等中心 roster 落地——首次 effective ≥1 enabled 模型即翻 true(one-way);
123
+ // 未布防则恒 ready。守的危险=**registry 部署的 fleet worker roster 落地前拿占位模型跑计费任务**
124
+ // (E3 废除 workers.model 之后,占位模型正是 fleet worker 的常态起服姿势)
125
125
  //
126
- // ⚠️ 如实记账(复审 2026-07-29 C1):**server 3.0.0 起 `MODEL_ID` 是必填**(未设 = loadConfig throw,
127
- // config.ts parseModelDomain),所以走到这一行时 `process.env.MODEL_ID` 必非空 ⇒ 这个初值**当前恒为
128
- // true**,「等 roster」那条臂在今天的配置面上不可达。保留原式不改行为,原因有二:① latch
129
- // `markRosterLanded` 是同一套机制,删掉初值里的判据不会让代码变简单,只会让重新开门时无处可挂;
130
- // registry 轴一旦允许「无 env MODEL_ID、模型全由中心 roster 下发」(E3 之后这正是 fleet worker 的目标
131
- // 形态),这条臂就要重新活过来 —— 届时应连同 MODEL_ID 必填的口径一起重估,而不是各改各的。
132
- const modelReadyState = { ready: !(cc && !cc.dryRun) || Boolean(process.env.MODEL_ID) };
126
+ // 🔧 复审 2026-07-29 P1-6 —— 布防判据从**推断**改成**运营者声明**。原式是
127
+ // `!(cc && !cc.dryRun) || Boolean(process.env.MODEL_ID)`,其中 `Boolean(process.env.MODEL_ID)`
128
+ // 3.0.0 前「运营者手上除了中心 roster 什么都没有」的代理判据。**server 3.0.0 起 `MODEL_ID` 必填**
129
+ // (未设 = loadConfig throw,见 config.ts parseModelDomain)⇒ 走到这一行时它必非空 ⇒ 初值恒 true ⇒
130
+ // 「等 roster」那条臂不可达、markRosterLanded 的门体是死码、/health ready:false 与计费提交的 503
131
+ // 全不可能发生。注意这不是「危险消失了」:必填 MODEL_ID 恰恰让**每个** fleet worker 都带着占位模型
132
+ // 起服,危险比以前更常见——只是那个代理判据从此写不出来了。
133
+ //
134
+ // 于是改成显式声明:`CONFIG_REQUIRE_ROSTER=true`(与 CONFIG_LKG_* / CONFIG_BOOT_FETCH_BUDGET_MS 同族,
135
+ // 直读 env,不进 ServiceConfig——纯 boot 期姿势旋钮)。**缺省关 = 现有部署逐字节零变化**;开了才真的
136
+ // fail-closed 等 roster。仅远程 registry 且非 dryRun 车道可布防:dryRun 是 compare-only(中心 roster
137
+ // 从不被应用,拿它当就绪判据无意义),local provider / 纯 env 部署则没有「中心」可等。
138
+ const rosterGateArmed = cc !== undefined && !cc.dryRun && process.env.CONFIG_REQUIRE_ROSTER === "true";
139
+ const modelReadyState = { ready: !rosterGateArmed };
133
140
  const markRosterLanded = (eff) => {
134
141
  if (!modelReadyState.ready && (eff.models?.models ?? []).some((m) => m.enabled !== false)) {
135
142
  modelReadyState.ready = true;
@@ -463,12 +470,19 @@ export async function createConfigCenterRuntime(ctx) {
463
470
  }
464
471
  }
465
472
  if (!modelReadyState.ready)
466
- logger.warn("model_roster_pending", { note: "registry deployment without an env model and no roster yet — billable submissions 503 until the first effective pull lands models" });
473
+ logger.warn("model_roster_pending", {
474
+ note: "CONFIG_REQUIRE_ROSTER=true and no center roster has landed yet — the env MODEL_ID is treated as a PLACEHOLDER, so billable submissions 503 (and /health reports ready:false) until the first effective pull lands ≥1 enabled model. Unset the knob if this worker's env model is authoritative.",
475
+ });
467
476
  // Per-model API key (sema-registry `apiKeyEnv`): resolve each model's own upstream key per brain call /
468
477
  // cascade rung. undefined when no per-model keys are configured → spec field stays unset (core default).
469
478
  // `let` (not const): rebuilt on a sema-registry refresh so per-model key ADDITIONS hot-apply too (the
470
479
  // resolver is undefined when no per-model keys exist, so in-place mutation alone wouldn't cover empty→non-empty).
471
480
  let keyResolver;
481
+ // 复审 2026-07-29 P1-11:restart 片里唯一一块 EffectiveConfig **看不见**的输入 —— 反应式降级车道由 env
482
+ // (MODEL_DEGRADE_TO / MODEL_DEGRADE_REACTIVE)开关,而它焊死的是 CENTER 目录里那条模型的路由面。读成
483
+ // 函数而非常量:`config.degrade` 今天由 env 独占(applyEffective 不写它),但若哪天中心接管这面,这里
484
+ // 自动跟着走,不会退化成 boot 期快照。车道关(默认)⇒ ctx 空 ⇒ 该片恒 null ⇒ 零行为变化。
485
+ const restartCtx = () => (config.degrade?.reactive ? { reactiveDegradeTo: config.degrade.to } : {});
472
486
  return {
473
487
  providerKind: configProvider?.kind,
474
488
  promptSource,
@@ -794,7 +808,7 @@ export async function createConfigCenterRuntime(ctx) {
794
808
  // 顺序=先持久化(含 skill 正文预热,F1)再发信号;持久化失败=warn+照发(不发=配置永不生效;
795
809
  // 「盘坏+中心挂」双故障下环重现,接受并点名)。
796
810
  const lkgPersisted = await persistLkgDurable(r.effective, r.etag);
797
- const reasons = restartReasons(effective, r.effective);
811
+ const reasons = restartReasons(effective, r.effective, restartCtx());
798
812
  // codex R35: the sticky boot skill debt rides EVERY tick's reasons — change-detection vs boot is
799
813
  // blind to it (the stale table IS the boot baseline). Under a no-handoff deferral it folds into
800
814
  // blocked (promoted later); otherwise it keeps pendingRestart alive until the restart happens.
@@ -956,7 +970,7 @@ export async function createConfigCenterRuntime(ctx) {
956
970
  latestEffective = r.effective;
957
971
  ccEtag = r.etag;
958
972
  const lkgPersistedLate = await persistLkgDurable(r.effective, r.etag); // F2 parity:信号可见前落盘+skill 正文预热
959
- const reasons = restartReasons(undefined, r.effective); // (prompts 不在 restart slices——热采用已在上方 adopt)
973
+ const reasons = restartReasons(undefined, r.effective, restartCtx()); // (prompts 不在 restart slices——热采用已在上方 adopt)
960
974
  // codex R15 (late-boot twin): tiered env boot + tier-less late candidate ⇒ deferral fires but BOTH slice
961
975
  // fingerprints reduce to null (undefined baseline, tier-less candidate) — no models-tiers reason, plane
962
976
  // deferred forever. Deferral is the ground truth; force the reason so the orchestrator restarts and the
@@ -1,5 +1,13 @@
1
1
  import type { EffectiveConfig } from "./types.js";
2
- export type RestartSlice = "skills" | "mcp" | "scenarios" | "runtime-gates" | "models-tiers";
2
+ export type RestartSlice = "skills" | "mcp" | "scenarios" | "runtime-gates" | "models-tiers" | "degrade-route";
3
+ /** What the CALLER must contribute about boot-baked catalog consumers that live OUTSIDE the Runner — the
4
+ * slices cannot see them, because they are configured from env (`ServiceConfig`), not from the center's
5
+ * EffectiveConfig. Absent/empty = that lane is off ⇒ its slice is inert (null on both sides). */
6
+ export interface RestartSliceCtx {
7
+ /** `config.degrade.to` when REACTIVE degrade is on (env `MODEL_DEGRADE_TO` + `MODEL_DEGRADE_REACTIVE`);
8
+ * undefined = the lane is off ⇒ no degrading brain was composed ⇒ nothing is boot-frozen. */
9
+ reactiveDegradeTo?: string;
10
+ }
3
11
  /** Structured restart signal an orchestrator consumes (GET /health → `restart`). Change-detected against the
4
12
  * process's BOOT config, NOT presence — so it stays absent when nothing baked-at-boot changed (presence would
5
13
  * re-fire every refresh → a restart LOOP). `restartRequired` is always true when this object exists. */
@@ -16,7 +24,7 @@ export interface RestartSignal {
16
24
  * nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
17
25
  * can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
18
26
  * against boot (a refresh that re-fires the same diff is idempotent, not a fresh trigger). */
19
- export declare function restartReasons(boot: EffectiveConfig | undefined, current: EffectiveConfig): RestartSlice[];
27
+ export declare function restartReasons(boot: EffectiveConfig | undefined, current: EffectiveConfig, ctx?: RestartSliceCtx): RestartSlice[];
20
28
  /** codex R10 (models-tiers 窗收口): TRUE when the MODEL PLANE (enabled models + active tier table + resolved
21
29
  * default) of `next` differs from the last-APPLIED plane `prev`. Under a tier-frozen Runner (tiers non-empty at
22
30
  * construction → private expanded copy) main.ts DEFERS the whole plane mutation until restart when this is true —
@@ -7,7 +7,7 @@
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,22 @@ function enabledOnly(rows) {
31
31
  return null;
32
32
  return rows.filter((r) => r.enabled !== false);
33
33
  }
34
- function restartSliceValue(eff, slice) {
34
+ /** The degrade hop is built from the FULLY-DERIVED Model (`toModel`), so almost every catalog field on the
35
+ * target reaches it (id → body.model, baseUrl → the endpoint, provider → which brain, maxTokens/compat/
36
+ * extraBody/input → the request body, apiKeyEnv → the construction-time credential). We therefore fingerprint
37
+ * the WHOLE entry minus the two fields that are provably HOT — `cost` (refreshed via
38
+ * `mutateInPlace(pricing, buildPricing(config.models))`) and `quotaWeight` (via
39
+ * `mutateInPlace(config.modelQuotaWeights, …)`) — so a pure re-pricing never restarts a worker. A DENY-list
40
+ * (not an allow-list) on purpose: a catalog field added later rides the fingerprint automatically, so the
41
+ * failure mode of drift is a spurious restart, never a silently missed one. */
42
+ function degradeRouteFingerprint(entry) {
43
+ // `enabled` is dropped too, but for a different reason: `enabledOnly` already filtered on it, so among the
44
+ // entries that reach here it is `true`-or-absent — two encodings of ONE state. Keeping it would let a center
45
+ // serializer that starts/stops emitting the explicit `true` manufacture a restart out of nothing.
46
+ const { cost: _cost, quotaWeight: _quotaWeight, enabled: _enabled, ...route } = entry;
47
+ return route;
48
+ }
49
+ function restartSliceValue(eff, slice, ctx) {
35
50
  if (!eff)
36
51
  return null;
37
52
  switch (slice) {
@@ -69,14 +84,23 @@ function restartSliceValue(eff, slice) {
69
84
  const def = resolveDefaultModelName(eff, (n) => names.has(n), enabled?.[0]?.name ?? "");
70
85
  return { models: enabled, tiers: active, default: def.name };
71
86
  }
87
+ case "degrade-route": {
88
+ const to = ctx?.reactiveDegradeTo;
89
+ if (to === undefined)
90
+ return null; // lane off — no degrading brain exists to go stale
91
+ // Present↔absent is itself a change: with no enabled entry named `to`, createBrain composes NO
92
+ // degrading shell at all, so the flip only takes effect at the next boot.
93
+ const target = (enabledOnly(eff.models?.models) ?? []).find((m) => m.name === to);
94
+ return target ? degradeRouteFingerprint(target) : null;
95
+ }
72
96
  }
73
97
  }
74
98
  /** Restart-to-apply slices that DIFFER between this process's BOOT config and a freshly-pulled one. Empty =
75
99
  * nothing baked-at-boot changed (only hot-apply slices moved, or nothing) → no restart needed. An orchestrator
76
100
  * can auto rolling-restart on a non-empty result WITHOUT a restart loop, because the comparison is always
77
101
  * 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)));
102
+ export function restartReasons(boot, current, ctx) {
103
+ return RESTART_SLICES.filter((s) => stableStringify(restartSliceValue(boot, s, ctx)) !== stableStringify(restartSliceValue(current, s, ctx)));
80
104
  }
81
105
  /** codex R10 (models-tiers 窗收口): TRUE when the MODEL PLANE (enabled models + active tier table + resolved
82
106
  * default) of `next` differs from the last-APPLIED plane `prev`. Under a tier-frozen Runner (tiers non-empty at
@@ -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
@@ -28,8 +28,9 @@
28
28
  * (`{ ok:true, exitCode }`), never an error.
29
29
  *
30
30
  * Platforms: macOS + Linux (POSIX shell). Forward-compatible with the `remoteExec` superset: this file also
31
- * scaffolds the `local-docker` / `remote-docker` providers' factory entry points (typed stubs that throw a
32
- * clear "not yet implemented" explicit fast-follow per DUAL-MODE-DESIGN §5; NOT faked green).
31
+ * hosts the `local-docker` / `remote-docker` providers' factory entry points both now IMPLEMENTED by the
32
+ * container adapter in remote-env-local-docker.ts (the two providers differ only in whether the `-H` daemon
33
+ * endpoint is off-box and required), re-exported/wrapped here so the discriminator has one canonical site.
33
34
  */
34
35
  import { spawn } from "node:child_process";
35
36
  import os from "node:os";
@@ -1647,19 +1648,38 @@ export function hostExecutionEnvFactory(config = {}) {
1647
1648
  // ─────────────────────────────── remoteExec superset: docker providers ───────────────────────────────
1648
1649
  //
1649
1650
  // DUAL-MODE-DESIGN §5 defines a `remoteExec.provider` superset. `host` (above) is implemented; `local-docker`
1650
- // is NOW IMPLEMENTED (the isolation lane) it lives in its own module (remote-env-local-docker.ts) and is
1651
- // re-exported here so the discriminator entry point + the historical import site stay stable. `remote-docker`
1652
- // (a REMOTE docker daemon over TLS/SSH) is still an explicit fast-follow stub — it throws a clear "not yet
1653
- // implemented" so selecting it fails loud at wiring time instead of silently degrading isolation. NOT faked green.
1651
+ // (the isolation lane) and `remote-docker` (that same container lane aimed at a REMOTE daemon) are BOTH
1652
+ // implemented by remote-env-local-docker.ts and re-exported / wrapped here so the discriminator entry points +
1653
+ // the historical import sites stay stable.
1654
1654
  // `local-docker` (the isolation lane): real implementation moved to remote-env-local-docker.ts. Re-export the
1655
1655
  // factory + config from here so existing wiring (`import { localDockerExecutionEnvFactory } from "./remote-env-host"`)
1656
1656
  // keeps working and the provider discriminator has one canonical entry point.
1657
1657
  export { localDockerExecutionEnvFactory, RemoteLocalDockerExecutionEnv } from "./remote-env-local-docker.js";
1658
+ import { localDockerExecutionEnvFactory } from "./remote-env-local-docker.js";
1658
1659
  /**
1659
- * `remote-docker` factory entry point wired but an explicit TODO (DUAL-MODE-DESIGN §5 fast-follow). Throws
1660
- * at factory-BUILD time so selecting it before it exists fails loud rather than degrading isolation silently.
1660
+ * `ExecutionEnvFactory` for the TOC `remote-docker` backend (DUAL-MODE-DESIGN §5): a per-task container on
1661
+ * SOMEONE ELSE'S docker daemon (offload the operator's box stays free; isolation:true, suspendable:false).
1662
+ * Same lifetime contract as local-docker: unconnected/lazy, one container per task, `destroy()` = `docker rm -f`.
1663
+ *
1664
+ * 🔴 **Endpoint credentials are the docker CLI's own** — this adapter deliberately carries none. The docker CLI
1665
+ * reads `DOCKER_CERT_PATH` + `DOCKER_TLS_VERIFY` from the worker's environment (inherited by every spawn) for a
1666
+ * `tcp://` + TLS daemon, and uses `ssh-agent` / `~/.ssh/config` for an `ssh://` one. That keeps key material out
1667
+ * of this process's config objects entirely. Consequence to know: `tcp://…:2376` WITHOUT those env vars fails
1668
+ * loud at connect (the daemon refuses plaintext) — it does not silently downgrade; a plaintext `tcp://…:2375`
1669
+ * daemon is unauthenticated and remains the operator's decision, exactly as in the registry-core schema.
1670
+ * (The registry-core `remote-docker` arm additionally models `tlsCertEnv`/`tlsKeyEnv`/`tlsCaEnv`/`sshKeyEnv` =
1671
+ * env-NAMEs the control plane resolves to PEM BYTES. Materializing those bytes to short-lived 0600 files and
1672
+ * passing `--tlsverify --tlscert/--tlskey/--tlscacert` is the remaining config-surface follow-up, together with
1673
+ * the `provider:"remote-docker"` arm on `AppConfig.remoteExec` — see the note on that union in config-types.ts.)
1674
+ *
1675
+ * Throws at factory-BUILD time on a missing/blank endpoint: a "remote" lane with no endpoint would otherwise
1676
+ * quietly become the LOCAL daemon — an isolation-topology surprise, so it fails loud instead.
1661
1677
  */
1662
- export function remoteDockerExecutionEnvFactory(_config) {
1663
- throw new RemoteExecutionError("unsupported", "remoteExec provider 'remote-docker' is not yet implemented (DUAL-MODE-DESIGN §5 fast-follow) — use 'host', 'local-docker', or 'e2b' until it ships");
1678
+ export function remoteDockerExecutionEnvFactory(config, deps = {}) {
1679
+ const dockerHost = config.dockerHost?.trim();
1680
+ if (!dockerHost) {
1681
+ throw new RemoteExecutionError("connect_failed", "remoteExec provider 'remote-docker' requires a non-empty dockerHost (the remote daemon endpoint, e.g. tcp://host:2376 or ssh://user@host) — use provider 'local-docker' for THIS machine's daemon");
1682
+ }
1683
+ return localDockerExecutionEnvFactory({ ...config, dockerHost, providerLabel: "remote-docker" }, deps);
1664
1684
  }
1665
1685
  //# sourceMappingURL=remote-env-host.js.map
@@ -17,8 +17,20 @@ export interface LocalDockerEnvConfig {
17
17
  /**
18
18
  * DOCKER_HOST override for the docker CLI (e.g. a non-default socket). Passed as `-H <value>` on EVERY docker
19
19
  * invocation (not the process env) so it can't leak / drift. Absent = the daemon DOCKER_HOST points at.
20
+ *
21
+ * Pointing this OFF-BOX (`tcp://host:2376`, `ssh://user@host`) is exactly the DUAL-MODE-DESIGN §5
22
+ * **`remote-docker`** provider — see {@link remoteDockerExecutionEnvFactory} (remote-env-host.ts), which is
23
+ * this adapter with the endpoint required. Nothing else about the class is host-local: the workspace is
24
+ * `mkdir`'d INSIDE the container (never a bind-mount of a host path) and bytes move by `docker cp` /
25
+ * `docker exec cat`, which stream over the daemon API.
20
26
  */
21
27
  dockerHost?: string;
28
+ /**
29
+ * Provider NAME reported on the {@link WorkspaceHandle}. Default `"local-docker"`; the `remote-docker` arm
30
+ * passes `"remote-docker"` so a handle never mislabels which lane it is (the CLASS is shared — `remote-docker`
31
+ * IS this adapter aimed at a remote daemon). Cosmetic/observability only: it selects no behavior.
32
+ */
33
+ providerLabel?: string;
22
34
  /** Container memory limit (docker `--memory`, e.g. "2g"). Absent = no limit. */
23
35
  memory?: string;
24
36
  /** Container CPU limit (docker `--cpus`, e.g. 1.5). Absent = no limit. */
@@ -65,6 +77,8 @@ export declare class RemoteLocalDockerExecutionEnv implements RemoteExecutionEnv
65
77
  private readonly spawnFn;
66
78
  /** Stable container NAME (also our handle id). Created on connect; targeted by every docker exec/cp/rm. */
67
79
  private readonly containerName;
80
+ /** Provider name put on the WorkspaceHandle ("local-docker" | "remote-docker"). Observability only. */
81
+ private readonly providerName;
68
82
  /** Set once the container is up + the workspace dir created. */
69
83
  private containerId?;
70
84
  private handle?;
@@ -66,6 +66,8 @@ export class RemoteLocalDockerExecutionEnv {
66
66
  spawnFn;
67
67
  /** Stable container NAME (also our handle id). Created on connect; targeted by every docker exec/cp/rm. */
68
68
  containerName;
69
+ /** Provider name put on the WorkspaceHandle ("local-docker" | "remote-docker"). Observability only. */
70
+ providerName;
69
71
  /** Set once the container is up + the workspace dir created. */
70
72
  containerId;
71
73
  handle;
@@ -92,6 +94,7 @@ export class RemoteLocalDockerExecutionEnv {
92
94
  const id = config.id ?? randomBytes(6).toString("hex");
93
95
  const suffix = randomBytes(4).toString("hex"); // always-unique tail so a reused id never collides
94
96
  this.containerName = `sema-docker-${sanitizeId(id)}-${suffix}`;
97
+ this.providerName = config.providerLabel?.trim() || PROVIDER;
95
98
  }
96
99
  resolve(p) {
97
100
  return path.posix.isAbsolute(p) ? path.posix.normalize(p) : path.posix.normalize(path.posix.join(this.cwd, p));
@@ -165,7 +168,7 @@ export class RemoteLocalDockerExecutionEnv {
165
168
  return { ok: false, error: new RemoteExecutionError("connect_failed", "execution env destroyed during connect") };
166
169
  }
167
170
  this.containerId = run.value.stdout.trim() || this.containerName;
168
- this.handle = { sandboxId: this.containerName, provider: PROVIDER, mountPath: this.cfg.mountPath, sessionToken: this.containerName };
171
+ this.handle = { sandboxId: this.containerName, provider: this.providerName, mountPath: this.cfg.mountPath, sessionToken: this.containerName };
169
172
  if (config?.secrets?.length) {
170
173
  // Secrets are injected per-command via cfg.env (resolved by the control plane), not at connect — record only.
171
174
  }
@@ -197,7 +200,7 @@ export class RemoteLocalDockerExecutionEnv {
197
200
  // re-run `docker run --name <same>` → name conflict → the failure path rm -f's the very container we just
198
201
  // re-attached to (destroying the running workspace). The name IS our exec/handle target.
199
202
  this.containerId = this.containerName;
200
- this.handle ??= { sandboxId: this.containerName, provider: PROVIDER, mountPath: this.cfg.mountPath, sessionToken: this.containerName };
203
+ this.handle ??= { sandboxId: this.containerName, provider: this.providerName, mountPath: this.cfg.mountPath, sessionToken: this.containerName };
201
204
  return ok(this.handle);
202
205
  }
203
206
  return { ok: false, error: new RemoteExecutionError("connect_failed", `container '${this.containerName}' is not running (gone or stopped) — no managed snapshot to resume`) };
@@ -609,7 +612,11 @@ export class RemoteLocalDockerExecutionEnv {
609
612
  return;
610
613
  this.containerShellProbed = true;
611
614
  try {
612
- const r = await this.docker(this.withHostFlag(["exec", this.containerName, "/bin/sh", "-c", "command -v bash"]), { timeoutMs: this.cfg.controlTimeoutMs });
615
+ // 🔴 NO withHostFlag here — `docker()` already prepends it. A second `-H` is not a harmless repeat: the
616
+ // docker CLI rejects the whole command (`invalid argument … specify only one -H`), so on every
617
+ // REMOTE-daemon container (`dockerHost` set — always, on the `remote-docker` arm) the probe used to fail
618
+ // and silently degrade the user shell to /bin/sh for a reason unrelated to the image.
619
+ const r = await this.docker(["exec", this.containerName, "/bin/sh", "-c", "command -v bash"], { timeoutMs: this.cfg.controlTimeoutMs });
613
620
  if (r.ok && r.value.exitCode === 0 && r.value.stdout.trim().length > 0)
614
621
  this.containerShell = "bash";
615
622
  }
@@ -126,9 +126,14 @@ export declare class SqlRunStore {
126
126
  * suspend, an INTENTIONAL durable pause" (design/76 §2.5) — the service MUST park it the same way, else the run is
127
127
  * terminalized + its lock released and the plan_review/dry_run_review resume can never run (it gets
128
128
  * taskId=undefined and drives the model unprotected on an unlocked session). CAS on `running`/`needs_review`
129
- * (reaper-revert guard, mirrors setSuspended). 🔴 The store SQL here is exercised by mocked unit tests only — a
130
- * real-TiDB integration test of the park→resume→claim invariant is a required-before-GA TODO (the lesson from the
131
- * P2 lease-SQL + this needs_review-park bug: mocked tests hide store-SQL defects).
129
+ * (reaper-revert guard, mirrors setSuspended). The park→resume→claim invariant is pinned on BOTH REAL engines by
130
+ * `test/run-store-reaper-integration.test.ts` (`inv#1` row ownership across park→resume→handover, `inv#2` two
131
+ * replicas racing one session exactly one claim wins, `inv#3`/`inv#3'` stale-claim takeover vs fresh/parked
132
+ * claims), closing the former required-before-GA gap. Keep it that way: the P2 lease-SQL bug and this
133
+ * needs_review-park bug were both born from store SQL that mocked unit tests pass — measured, not asserted, when
134
+ * the invariant set was built: an upserting (non-exclusive) claim reddened all four real-DB cases while all 111
135
+ * mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser,
136
+ * or a multi-table reaper DELETE.
132
137
  */
133
138
  setNeedsReview(taskId: string): Promise<void>;
134
139
  /**
@@ -206,9 +206,14 @@ export class SqlRunStore {
206
206
  * suspend, an INTENTIONAL durable pause" (design/76 §2.5) — the service MUST park it the same way, else the run is
207
207
  * terminalized + its lock released and the plan_review/dry_run_review resume can never run (it gets
208
208
  * taskId=undefined and drives the model unprotected on an unlocked session). CAS on `running`/`needs_review`
209
- * (reaper-revert guard, mirrors setSuspended). 🔴 The store SQL here is exercised by mocked unit tests only — a
210
- * real-TiDB integration test of the park→resume→claim invariant is a required-before-GA TODO (the lesson from the
211
- * P2 lease-SQL + this needs_review-park bug: mocked tests hide store-SQL defects).
209
+ * (reaper-revert guard, mirrors setSuspended). The park→resume→claim invariant is pinned on BOTH REAL engines by
210
+ * `test/run-store-reaper-integration.test.ts` (`inv#1` row ownership across park→resume→handover, `inv#2` two
211
+ * replicas racing one session exactly one claim wins, `inv#3`/`inv#3'` stale-claim takeover vs fresh/parked
212
+ * claims), closing the former required-before-GA gap. Keep it that way: the P2 lease-SQL bug and this
213
+ * needs_review-park bug were both born from store SQL that mocked unit tests pass — measured, not asserted, when
214
+ * the invariant set was built: an upserting (non-exclusive) claim reddened all four real-DB cases while all 111
215
+ * mocked run-store unit tests stayed green. A mock cannot observe a unique-key claim race, a rolled-back loser,
216
+ * or a multi-table reaper DELETE.
212
217
  */
213
218
  async setNeedsReview(taskId) {
214
219
  await this.db.query(this.q("UPDATE task_run SET status = 'needs_review', updated_at = ? WHERE task_id = ? AND status IN ('running','needs_review')", "UPDATE task_run SET status = 'needs_review', updated_at = $1 WHERE task_id = $2 AND status IN ('running','needs_review')"), [new Date(), taskId]);
@@ -10,9 +10,23 @@
10
10
  * (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
11
11
  *
12
12
  * Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
13
- * core brain honors model.baseUrl, absent = "" = boot-env endpoint). TODO remaining: hot-reload
14
- * of models/roles needs Runner support — today teams hot-reload via the registry, while a models/roles
15
- * change is logged as "restart to apply".
13
+ * core brain honors model.baseUrl, absent = "" = boot-env endpoint).
14
+ *
15
+ * Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
16
+ * TODO, which went stale when `mutateInPlace` landed):
17
+ * - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
18
+ * `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
19
+ * reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
20
+ * call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
21
+ * next task with no restart.
22
+ * - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
23
+ * at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
24
+ * - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
25
+ * (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
26
+ * at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
27
+ * restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
28
+ * gateway silently on the rate-limit path.
29
+ * - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
16
30
  *
17
31
  * (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives in
18
32
  * `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig application /
@@ -21,7 +35,7 @@
21
35
  */
22
36
  export { fetchEffective, fetchPrincipalCaps, ConfigCenterHttpError, fetchSkillContent, fetchPromptArtifact, fetchPromptBlob, } from "./config-center/http-client.js";
23
37
  export { mutateInPlace, applyEffective, applyRuntimeGates, applyRuntimeHot, resolveDefaultModelName, logEffectiveDiff, runtimeHasActiveGate, } from "./config-center/apply-effective.js";
24
- export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSignal, } from "./config-center/restart-signal.js";
38
+ export { restartReasons, planeHasActiveTiers, modelPlaneChanged, type RestartSlice, type RestartSliceCtx, type RestartSignal, } from "./config-center/restart-signal.js";
25
39
  export { applyCenterSkills, resolveMcpServers, mcpForScenario } from "./config-center/skills-mcp.js";
26
40
  export type { CenterSkillManifest, CenterMcpServer, EffectiveConfig, ExecutionRuling, SessionMirrorRuling, } from "./config-center/types.js";
27
41
  //# sourceMappingURL=sema-registry.d.ts.map
@@ -10,9 +10,23 @@
10
10
  * (core 1.45), so each model/cascade-rung authenticates with its own upstream key.
11
11
  *
12
12
  * Per-model `baseUrl` IS transported (a catalog model may live on a different gateway;
13
- * core brain honors model.baseUrl, absent = "" = boot-env endpoint). TODO remaining: hot-reload
14
- * of models/roles needs Runner support — today teams hot-reload via the registry, while a models/roles
15
- * change is logged as "restart to apply".
13
+ * core brain honors model.baseUrl, absent = "" = boot-env endpoint).
14
+ *
15
+ * Hot-reload status (复审 2026-07-29 P1-11 — 亲读判定,取代此处旧的 "models/roles are restart-to-apply"
16
+ * TODO, which went stale when `mutateInPlace` landed):
17
+ * - models/roles/roster/teams/projects/autonomy — **HOT**. `applyEffective` mutates `config.models`/
18
+ * `config.roles` IN PLACE, and core's Runner reads `this.deps.models/roles` per task off that very
19
+ * reference. Both brains re-resolve `model.baseUrl || config.baseUrl` inside `buildRequest()` on every
20
+ * call (core 2.1.0 `brain/openai.js` + `brain/anthropic.js`), so a moved gateway takes effect on the
21
+ * next task with no restart.
22
+ * - the same plane under a **tier-frozen** Runner — deferred, not hot: core expands a PRIVATE catalog copy
23
+ * at construction, so `main` defers the whole plane and the `models-tiers` restart slice signals.
24
+ * - **`degrade-route`** — the one genuinely boot-frozen catalog consumer left: reactive degrade
25
+ * (`MODEL_DEGRADE_REACTIVE`) bakes the target Model + its gateway + its key into the brain composition
26
+ * at boot. It cannot be hot-applied without rebuilding the brain, so it is registered on the existing
27
+ * restartRequired /health contract instead (see `restart-signal.ts`). Fail-loud beats serving a stale
28
+ * gateway silently on the rate-limit path.
29
+ * - skills/mcp/scenarios/runtime-gates — restart-to-apply by construction (baked into the boot wiring).
16
30
  *
17
31
  * (design/158 A13, internal-lossless) This module is now a FACADE: the implementation lives in
18
32
  * `src/config-center/` split by responsibility group (HTTP client / EffectiveConfig application /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -67,7 +67,7 @@
67
67
  "sharp": "^0.35.3"
68
68
  },
69
69
  "devDependencies": {
70
- "@sema-agent/sdk": "^0.0.46",
70
+ "@sema-agent/sdk": "^1.0.0",
71
71
  "@types/libsodium-wrappers": "^0.7.14",
72
72
  "@types/node": "22.10.2",
73
73
  "@types/pg": "^8.20.0",