@sema-agent/server 7.129.0 → 7.130.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 (44) hide show
  1. package/MIGRATION.md +37 -0
  2. package/USAGE.md +38 -2
  3. package/dist/boot/execution-env.js +1 -0
  4. package/dist/boot/resolve-spec.js +8 -4
  5. package/dist/boot/stage-10-http-server.d.ts +1 -1
  6. package/dist/boot/stage-10-http-server.js +2 -0
  7. package/dist/capabilities/mcp-probe.js +16 -3
  8. package/dist/capabilities/scenarios.d.ts +33 -0
  9. package/dist/capabilities/scenarios.js +15 -0
  10. package/dist/capabilities/skills.js +1 -1
  11. package/dist/config-catalog.js +5 -1
  12. package/dist/config-types.d.ts +3 -0
  13. package/dist/config.js +7 -1
  14. package/dist/exec-bounds.d.ts +69 -0
  15. package/dist/exec-bounds.js +37 -0
  16. package/dist/hooks/hook-llm.d.ts +11 -1
  17. package/dist/hooks/hook-llm.js +10 -13
  18. package/dist/hooks/hook-runner.d.ts +1 -1
  19. package/dist/http/routes/capabilities.js +20 -2
  20. package/dist/http/routes/runs.js +12 -4
  21. package/dist/http/server.d.ts +8 -0
  22. package/dist/observability/fail-open.d.ts +5 -1
  23. package/dist/observability/fail-open.js +5 -1
  24. package/dist/plugins/one-time-migrations.d.ts +12 -1
  25. package/dist/plugins/one-time-migrations.js +17 -0
  26. package/dist/plugins/remote-env-adb.js +5 -4
  27. package/dist/plugins/remote-env-e2b.d.ts +31 -9
  28. package/dist/plugins/remote-env-e2b.js +77 -26
  29. package/dist/plugins/remote-env-host.js +7 -5
  30. package/dist/plugins/remote-env-k8s.d.ts +12 -4
  31. package/dist/plugins/remote-env-k8s.js +22 -31
  32. package/dist/plugins/remote-env-local-docker.js +5 -4
  33. package/dist/plugins/remote-env-ssh.js +5 -4
  34. package/dist/runtime-caps-resolver.d.ts +7 -0
  35. package/dist/runtime-caps-resolver.js +1 -0
  36. package/dist/session-titler.d.ts +4 -1
  37. package/dist/session-titler.js +13 -3
  38. package/dist/stored-request-tool-axes-m19.d.ts +29 -0
  39. package/dist/stored-request-tool-axes-m19.js +27 -0
  40. package/dist/task-mcp.d.ts +32 -1
  41. package/dist/task-mcp.js +66 -33
  42. package/dist/tool-axis-words.d.ts +14 -17
  43. package/dist/web-admin-client/skills-mcp.d.ts +1 -1
  44. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { randomBytes } from "node:crypto";
6
6
  import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdirSync, rmdirSync, mkdtempSync, existsSync, realpathSync, accessSync, constants as FS_CONST } from "node:fs";
7
7
  import { StringDecoder } from "node:string_decoder";
8
8
  import { armPipeDestroyGrace, numEnvOr, SYMLINK_CHAIN_MAX_HOPS } from "./remote-shell.js";
9
+ import { commandBoundsOf, livenessCutMessage } from "../exec-bounds.js";
9
10
  import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
10
11
  import { IS_WIN32, resolveHostShell, hostShell, pinHostShell, spawnGroupOptions, killTreeHard, killTreeSoft, hardKillGroupNow, collapseWin32EnvKeys, adoptHostForegroundChild } from "./host-platform.js";
11
12
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
@@ -810,8 +811,9 @@ export class RemoteHostExecutionEnv {
810
811
  if (!ready.ok)
811
812
  throw new RemoteExecutionError("connect_failed", ready.error.message, ready.error);
812
813
  const cwd = this.resolve(options?.cwd ?? this.cwd);
813
- const timeoutMs = options?.timeout != null ? options.timeout * 1000 : undefined;
814
- const idleMs = options?.readTimeoutMs;
814
+ const bounds = commandBoundsOf({ timeoutMs: options?.timeout != null ? options.timeout * 1000 : undefined, readTimeoutMs: options?.readTimeoutMs }, undefined);
815
+ const timeoutMs = bounds.wallMs;
816
+ const liveness = bounds.liveness;
815
817
  const cap = options?.maxOutputBytes;
816
818
  const queue = [];
817
819
  let finished = false;
@@ -905,7 +907,7 @@ export class RemoteHostExecutionEnv {
905
907
  throw failure;
906
908
  if (finished)
907
909
  break;
908
- const waitMs = idleMs != null ? Math.max(1, idleMs - (Date.now() - lastChunk)) : 30_000;
910
+ const waitMs = liveness !== undefined ? Math.max(1, liveness.ms - (Date.now() - lastChunk)) : 30_000;
909
911
  let waitTimer;
910
912
  try {
911
913
  await new Promise((r) => { wake = r; waitTimer = setTimeout(r, waitMs); });
@@ -914,10 +916,10 @@ export class RemoteHostExecutionEnv {
914
916
  if (waitTimer !== undefined)
915
917
  clearTimeout(waitTimer);
916
918
  }
917
- if (idleMs != null && !finished && Date.now() - lastChunk >= idleMs) {
919
+ if (liveness !== undefined && !finished && Date.now() - lastChunk >= liveness.ms) {
918
920
  finished = true;
919
921
  kill();
920
- throw new RemoteExecutionError("timeout", `execStream idle > ${idleMs}ms (command hang)`);
922
+ throw new RemoteExecutionError("timeout", livenessCutMessage(liveness));
921
923
  }
922
924
  }
923
925
  while (queue.length)
@@ -38,7 +38,9 @@ export interface K8sEnvConfig {
38
38
  readyTimeoutMs?: number;
39
39
  /** Control-plane RPC wall-clock (pod create/get/delete). */
40
40
  rpcTimeoutMs?: number;
41
- /** Zero-progress liveness bound for commands (no output AND not finished ⇒ hang). */
41
+ /** The lane's zero-progress liveness bound (env `K8S_LIVENESS_MS`; default {@link LIVENESS_MS_DEFAULT}): a command that
42
+ * produces no output for this long and has not finished is cut as a hang — ONLY when its caller stated neither a wall-clock
43
+ * (`timeout`) nor its own idle bound (`readTimeoutMs`); a stated bound replaces it (the rule: `exec-bounds.ts`). */
42
44
  livenessMs?: number;
43
45
  /** The ONE data bound (`WORKSPACE_SNAPSHOT_TIMEOUT_MS`; default = `WORKSPACE_SNAPSHOT_TIMEOUT_MS_DEFAULT`, owned by
44
46
  * `workspace-space.ts`): the snapshot upload's and restore's wall-clock, the deadline this replica's commits write into
@@ -257,6 +259,8 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
257
259
  /** The known occupant's incarnation (`undefined` = this run has no known occupant: a delegated child, an unread session, a direct construction) — the
258
260
  * ownership mark of every snapshot id this env mints, both lanes ({@link mintSnapshotId}); the space lane's partition is the same reading. */
259
261
  private readonly occupantIncarnation;
262
+ /** This lane's own liveness bound — what {@link commandBoundsOf} falls back to when a command's caller stated no bound. */
263
+ private readonly laneLiveness;
260
264
  /** This env's space lane (resolved once from the policy + the occupant's incarnation; see {@link SpaceLane}). */
261
265
  private readonly spaceLane;
262
266
  /** `workspace_space_unavailable` is said once per env. */
@@ -551,8 +555,10 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
551
555
  private execUrl;
552
556
  /**
553
557
  * Run a command over the exec WebSocket. Frames: byte0=channel (1 stdout / 2 stderr / 3 status JSON with the
554
- * REAL exit code). Bounds: caller wall-clock (explicit `timeout`) + zero-progress liveness (`livenessMs`) —
555
- * a socket killed by either does NOT kill the remote process (disposable-pod posture, see header).
558
+ * REAL exit code). Bounds = {@link commandBoundsOf} over what the caller stated (`timeoutMs` wall / `readTimeoutMs` idle)
559
+ * and this lane's liveness — the one rule, decided here and nowhere else on this lane — plus the adapter's own ceiling
560
+ * `capMs` (a data bound, not a statement: it never turns the liveness bound off). A socket killed by any bound does NOT
561
+ * kill the remote process (disposable-pod posture, see header).
556
562
  */
557
563
  private runExec;
558
564
  exec(command: string, options?: ExecOpts): Promise<Result<{
@@ -654,7 +660,9 @@ export declare class RemoteK8sExecutionEnv implements RemoteExecutionEnv, Backgr
654
660
  suffix?: string;
655
661
  abortSignal?: AbortSignal;
656
662
  }): Promise<Result<string, FileError>>;
657
- /** exec with the data-transfer bound (large reads/writes legitimately run longer than a control op). */
663
+ /** exec with the data-transfer ceiling (large reads/writes legitimately run longer than a control op). The engine states no
664
+ * bound for a file op, so the lane liveness bound stays on (a stalled transfer is cut by it); the data bound is this
665
+ * adapter's own ceiling (`capMs`), not a statement. */
658
666
  private execData;
659
667
  /** 本腿只定工作目录(缺省 cwd 规则 + 绝对化);串的形状归 {@link buildCwdEnvCommand} / {@link createCwdGuardedCall}(S-410:cwd 不可用 ⇒ 命令不跑;
660
668
  * 车PL:buffered `exec` 上守卫触发 ⇒ `spawn_error`)。 */
@@ -11,6 +11,7 @@ import { spaceCommitAlive } from "../workspace-space-commit.js";
11
11
  import { incarnationLogPrefix, redactIncarnations } from "../session-incarnation.js";
12
12
  import { WORKSPACE_CACHE_SPACE_LABEL, workspaceCacheClaimOf, workspaceCacheEnv } from "../workspace-cache.js";
13
13
  import { recordFailOpen } from "../observability/fail-open.js";
14
+ import { commandBoundsOf, laneLivenessOf, livenessCutMessage, LIVENESS_MS_DEFAULT } from "../exec-bounds.js";
14
15
  import { SANDBOX_IPV6_DEFAULT, sandboxEgressGaps, sandboxPodSysctls } from "../sandbox-egress-policy.js";
15
16
  import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
16
17
  import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
@@ -141,6 +142,7 @@ export class RemoteK8sExecutionEnv {
141
142
  cfg;
142
143
  deps;
143
144
  occupantIncarnation;
145
+ laneLiveness;
144
146
  spaceLane;
145
147
  spaceUnavailableSaid = false;
146
148
  unreadableOccupantSaid = false;
@@ -161,7 +163,7 @@ export class RemoteK8sExecutionEnv {
161
163
  timeoutMs: config.timeoutMs ?? 30 * 60_000,
162
164
  readyTimeoutMs: config.readyTimeoutMs ?? 120_000,
163
165
  rpcTimeoutMs: config.rpcTimeoutMs ?? 30_000,
164
- livenessMs: config.livenessMs ?? 120_000,
166
+ livenessMs: config.livenessMs ?? LIVENESS_MS_DEFAULT,
165
167
  dataTimeoutMs: dataTimeoutMsOf(config),
166
168
  cpu: config.cpu ?? "1",
167
169
  memory: config.memory ?? "2Gi",
@@ -176,6 +178,7 @@ export class RemoteK8sExecutionEnv {
176
178
  this.cwd = this.cfg.mountPath;
177
179
  this.homeDir = config.homeDir;
178
180
  this.deps = deps ?? {};
181
+ this.laneLiveness = laneLivenessOf(this.cfg.livenessMs, "K8S_LIVENESS_MS");
179
182
  const occupant = occupantKeyOf(config.spaceOccupant);
180
183
  this.occupantIncarnation = "incarnation" in occupant ? occupant.incarnation : undefined;
181
184
  this.spaceLane = spaceLaneOf(config.s3Snapshot?.space, config.spaceOccupant);
@@ -875,7 +878,7 @@ export class RemoteK8sExecutionEnv {
875
878
  const excludes = (this.cfg.snapshotExclude ?? []).map((item) => ` --exclude=${shellQuote(`./${item}`)}`).join("");
876
879
  const cmd = `tar czf ${tmp}${excludes} -C ${shellQuote(this.cfg.mountPath)} . && curl -fsS -X PUT -T ${tmp} ${shellQuote(url)}; ` +
877
880
  `rc=$?; rm -f ${tmp}; exit $rc`;
878
- const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
881
+ const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs });
879
882
  if (!r.ok)
880
883
  return { ok: false, error: new RemoteExecutionError("connect_failed", `workspace snapshot upload failed: ${this.redactUrl(r.error.message, url)}`, r.error) };
881
884
  if (r.value.exitCode !== 0)
@@ -911,7 +914,7 @@ export class RemoteK8sExecutionEnv {
911
914
  const c = await this.provision(abortSignal);
912
915
  if (!c.ok)
913
916
  return c;
914
- const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs, livenessMs: 0 });
917
+ const r = await this.runExec(cmd, { timeoutMs: this.cfg.dataTimeoutMs });
915
918
  const failure = !r.ok
916
919
  ? { cls: r.error.code === "timeout" ? "timeout" : "transport", detail: `workspace snapshot restore failed: ${this.redactUrl(r.error.message, url)}`, cause: r.error }
917
920
  : r.value.exitCode !== 0
@@ -1127,20 +1130,22 @@ export class RemoteK8sExecutionEnv {
1127
1130
  }
1128
1131
  resolvePromise(r);
1129
1132
  };
1130
- const wallMs = opts?.timeoutMs;
1131
- const wallTimer = wallMs ? setTimeout(() => finish({ ok: false, error: new ExecutionError("timeout", `command exceeded ${wallMs}ms`) }), wallMs) : undefined;
1133
+ const bounds = commandBoundsOf({ timeoutMs: opts?.timeoutMs, readTimeoutMs: opts?.readTimeoutMs }, this.laneLiveness);
1134
+ const capMs = opts?.capMs;
1135
+ const wallMs = bounds.wallMs === undefined ? capMs : capMs === undefined ? bounds.wallMs : Math.min(bounds.wallMs, capMs);
1136
+ const wallTimer = wallMs !== undefined ? setTimeout(() => finish({ ok: false, error: new ExecutionError("timeout", `command exceeded ${wallMs}ms`) }), wallMs) : undefined;
1132
1137
  let livenessTimer;
1133
- const livenessMs = opts?.livenessMs ?? this.cfg.livenessMs;
1138
+ const liveness = bounds.liveness;
1134
1139
  const armLiveness = () => {
1135
- if (livenessMs <= 0)
1140
+ if (liveness === undefined)
1136
1141
  return;
1137
1142
  clearTimeout(livenessTimer);
1138
1143
  const idle = Date.now() - lastProgress;
1139
- if (idle >= livenessMs) {
1140
- finish({ ok: false, error: new ExecutionError("timeout", `no output for ${idle}ms (exec liveness) — suspected hang`) });
1144
+ if (idle >= liveness.ms) {
1145
+ finish({ ok: false, error: new ExecutionError("timeout", livenessCutMessage(liveness)) });
1141
1146
  return;
1142
1147
  }
1143
- livenessTimer = setTimeout(armLiveness, livenessMs - idle);
1148
+ livenessTimer = setTimeout(armLiveness, liveness.ms - idle);
1144
1149
  };
1145
1150
  armLiveness();
1146
1151
  const onAbort = () => finish({ ok: false, error: new ExecutionError("aborted", "command aborted") });
@@ -1343,22 +1348,20 @@ export class RemoteK8sExecutionEnv {
1343
1348
  wake = undefined;
1344
1349
  w?.();
1345
1350
  };
1346
- let lastChunk = Date.now();
1347
- const idleMs = options?.readTimeoutMs ?? this.cfg.livenessMs;
1348
1351
  const cap = options?.maxOutputBytes;
1349
1352
  let bytes = 0;
1350
1353
  const ac = new AbortController();
1351
1354
  const signal = options?.signal ? AbortSignal.any([options.signal, ac.signal]) : ac.signal;
1352
1355
  const done = this.runExec(buildCwdEnvCommand(command, this.workingDirOf(options?.cwd), options?.env, "raw").command, {
1353
1356
  signal,
1357
+ timeoutMs: options?.timeout != null ? options.timeout * 1000 : undefined,
1358
+ readTimeoutMs: options?.readTimeoutMs,
1354
1359
  boundOutput: true,
1355
1360
  onStdout: (d) => {
1356
- lastChunk = Date.now();
1357
1361
  queue.push({ type: "stdout", data: d });
1358
1362
  signalReady();
1359
1363
  },
1360
1364
  onStderr: (d) => {
1361
- lastChunk = Date.now();
1362
1365
  queue.push({ type: "stderr", data: d });
1363
1366
  signalReady();
1364
1367
  },
@@ -1395,21 +1398,9 @@ export class RemoteK8sExecutionEnv {
1395
1398
  throw failure;
1396
1399
  if (finished)
1397
1400
  break;
1398
- const waitMs = Math.max(1, idleMs - (Date.now() - lastChunk));
1399
- let waitTimer;
1400
- try {
1401
- await new Promise((r) => {
1402
- wake = r;
1403
- waitTimer = setTimeout(r, Math.min(waitMs, 30_000));
1404
- });
1405
- }
1406
- finally {
1407
- if (waitTimer !== undefined)
1408
- clearTimeout(waitTimer);
1409
- }
1410
- if (!finished && Date.now() - lastChunk >= idleMs) {
1411
- throw new RemoteExecutionError("timeout", `execStream idle > ${idleMs}ms (suspected hang)`);
1412
- }
1401
+ await new Promise((r) => {
1402
+ wake = r;
1403
+ });
1413
1404
  }
1414
1405
  while (queue.length)
1415
1406
  yield queue.shift();
@@ -1428,7 +1419,7 @@ export class RemoteK8sExecutionEnv {
1428
1419
  }
1429
1420
  makeBgDriver() {
1430
1421
  const self = this;
1431
- const bgExec = (script, boundOutput) => self.runExec(script, { timeoutMs: self.cfg.rpcTimeoutMs, livenessMs: self.cfg.livenessMs, boundOutput });
1422
+ const bgExec = (script, boundOutput) => self.runExec(script, { timeoutMs: self.cfg.rpcTimeoutMs, boundOutput });
1432
1423
  return {
1433
1424
  async launch(command, options, ctx) {
1434
1425
  const conn = await self.ensureConnected();
@@ -1628,7 +1619,7 @@ export class RemoteK8sExecutionEnv {
1628
1619
  const c = await this.ensureConnected();
1629
1620
  if (!c.ok)
1630
1621
  return c;
1631
- return this.runExec(command, { signal: abortSignal, timeoutMs: this.cfg.dataTimeoutMs });
1622
+ return this.runExec(command, { signal: abortSignal, capMs: this.cfg.dataTimeoutMs });
1632
1623
  }
1633
1624
  workingDirOf(cwd) {
1634
1625
  const dir = cwd ?? this.cwd;
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process";
5
5
  import { randomBytes } from "node:crypto";
6
6
  import { shellQuote, armPipeDestroyGrace } from "./remote-shell.js";
7
7
  import { signalNumber } from "./host-platform.js";
8
+ import { commandBoundsOf, livenessCutMessage } from "../exec-bounds.js";
8
9
  import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
9
10
  import { fileErrorFromExec, classifyFsStderr } from "./remote-env-file-error.js";
10
11
  import { createPosixShellFs } from "./posix-shell-fs.js";
@@ -188,7 +189,7 @@ export class RemoteLocalDockerExecutionEnv {
188
189
  let paused = false;
189
190
  let wake;
190
191
  const signalReady = () => { const w = wake; wake = undefined; w?.(); };
191
- const idleMs = options?.readTimeoutMs;
192
+ const liveness = commandBoundsOf({ readTimeoutMs: options?.readTimeoutMs }, undefined).liveness;
192
193
  const cap = options?.maxOutputBytes;
193
194
  let onAbort;
194
195
  const enqueue = (type, data) => {
@@ -270,7 +271,7 @@ export class RemoteLocalDockerExecutionEnv {
270
271
  throw failure;
271
272
  if (finished)
272
273
  break;
273
- const waitMs = idleMs != null ? Math.max(1, idleMs - (Date.now() - lastChunk)) : 30_000;
274
+ const waitMs = liveness !== undefined ? Math.max(1, liveness.ms - (Date.now() - lastChunk)) : 30_000;
274
275
  let waitTimer;
275
276
  try {
276
277
  await new Promise((r) => { wake = r; waitTimer = setTimeout(r, waitMs); });
@@ -279,13 +280,13 @@ export class RemoteLocalDockerExecutionEnv {
279
280
  if (waitTimer !== undefined)
280
281
  clearTimeout(waitTimer);
281
282
  }
282
- if (idleMs != null && !finished && Date.now() - lastChunk >= idleMs) {
283
+ if (liveness !== undefined && !finished && Date.now() - lastChunk >= liveness.ms) {
283
284
  finished = true;
284
285
  try {
285
286
  child.kill("SIGKILL");
286
287
  }
287
288
  catch { }
288
- throw new RemoteExecutionError("timeout", `execStream idle > ${idleMs}ms (command hang)`);
289
+ throw new RemoteExecutionError("timeout", livenessCutMessage(liveness));
289
290
  }
290
291
  }
291
292
  while (queue.length)
@@ -3,6 +3,7 @@ import { Client } from "ssh2";
3
3
  import { createSshHostKeyVerifier } from "../ssh-host-key.js";
4
4
  import { buildCwdEnvCommand, createCwdGuardedCall, shellQuote, kindFromMode } from "./remote-shell.js";
5
5
  import { signalNumber } from "./host-platform.js";
6
+ import { commandBoundsOf, livenessCutMessage } from "../exec-bounds.js";
6
7
  import { createPosixShellFs } from "./posix-shell-fs.js";
7
8
  import { FileError, ExecutionError, RemoteExecutionError, withRetry, RollingTailBuffer, markTruncated, } from "@sema-agent/core";
8
9
  import { fileErrorFromExec } from "./remote-env-file-error.js";
@@ -335,7 +336,7 @@ export class RemoteSshExecutionEnv {
335
336
  w?.();
336
337
  };
337
338
  let lastChunk = Date.now();
338
- const idleMs = options?.readTimeoutMs;
339
+ const liveness = commandBoundsOf({ readTimeoutMs: options?.readTimeoutMs }, undefined).liveness;
339
340
  let streamRef;
340
341
  let onAbort;
341
342
  let paused = false;
@@ -422,7 +423,7 @@ export class RemoteSshExecutionEnv {
422
423
  throw failure;
423
424
  if (finished)
424
425
  break;
425
- const waitMs = idleMs ? Math.max(1, idleMs - (Date.now() - lastChunk)) : 30_000;
426
+ const waitMs = liveness !== undefined ? Math.max(1, liveness.ms - (Date.now() - lastChunk)) : 30_000;
426
427
  let waitTimer;
427
428
  try {
428
429
  await new Promise((r) => {
@@ -434,8 +435,8 @@ export class RemoteSshExecutionEnv {
434
435
  if (waitTimer !== undefined)
435
436
  clearTimeout(waitTimer);
436
437
  }
437
- if (idleMs && !finished && Date.now() - lastChunk >= idleMs)
438
- throw new RemoteExecutionError("timeout", `execStream idle > ${idleMs}ms (provider hang / unreachable host)`);
438
+ if (liveness !== undefined && !finished && Date.now() - lastChunk >= liveness.ms)
439
+ throw new RemoteExecutionError("timeout", livenessCutMessage(liveness));
439
440
  }
440
441
  while (queue.length)
441
442
  yield queue.shift();
@@ -175,6 +175,13 @@ export interface PrincipalEntitlementsClient {
175
175
  * FAIL-OPEN on error / older center without scenario keys / no principal (undefined = no governance, legacy passthrough).
176
176
  * Scenario switching is not a privilege-amplifying face; a center blip must not pin users to `default`. */
177
177
  scenarioRuling: (principal: string | undefined) => Promise<ScenarioRuling | undefined>;
178
+ /**
179
+ * {@link PrincipalEntitlementsClient.scenarioRuling} 的**只读**孪生(同 {@link PrincipalEntitlementsClient.peekRuntimeCaps} 的界):同一只缓存 / 同一条抓取 /
180
+ * 同一份 fail-open 判决(`undefined` = 无治理),唯一的差别是 center 硬失败时本腿铸的 fail-closed 降级项**只对读面自己算数**,提交腿不认、照常重抓。
181
+ * 读面(`GET /v1/capabilities/mcp?scenario=` 的场景门)问中心裁定时必须走这一只 —— 经 `scenarioRuling` 问,center 抖动的那一拍会替这个主体的**真提交**
182
+ * 留下 `{allowWorkflows:false, allowFork:false, autoMode:false}` 的短窗 deny(读面成了别人被拒的原因,与 `peekRuntimeCaps` 的病灶同一类)。
183
+ */
184
+ peekScenarioRuling: (principal: string | undefined) => Promise<ScenarioRuling | undefined>;
178
185
  /** (stage7 P5) The resolved per-principal execution-lane ruling for resolveSpec's
179
186
  * gateExecutionLane. FAIL-OPEN + audit on error (this phase is "honest-but-bypassable";
180
187
  * tightening to fail-closed is a future, separate decision) / older center without the execution key / no principal. */
@@ -167,6 +167,7 @@ export function createPrincipalEntitlementsClient(opts) {
167
167
  resolveRuntimeCaps: async (principal) => (principal ? (await load(principal)).caps : undefined),
168
168
  peekRuntimeCaps: async (principal) => (principal ? (await load(principal, false)).caps : undefined),
169
169
  scenarioRuling: async (principal) => (principal ? (await load(principal)).scenario : undefined),
170
+ peekScenarioRuling: async (principal) => (principal ? (await load(principal, false)).scenario : undefined),
170
171
  executionRuling: async (principal) => (principal ? (await load(principal)).execution : undefined),
171
172
  };
172
173
  }
@@ -14,10 +14,12 @@
14
14
  * main model; bounded + timed out).
15
15
  *
16
16
  * FAILURE POSTURE: everything is best-effort — a generation/store failure logs and leaves title null (the
17
- * list falls back to objectivePreview). An in-process pending set dedupes concurrent submits on the same
17
+ * list falls back to objectivePreview). The fail-open tag it leaves names the CAUSE FAMILY (one tag, one family):
18
+ * the llm face's failure CODE picks it (`failOpenTagFor`), never the error's wording. An in-process pending set dedupes concurrent submits on the same
18
19
  * session; the store's IS NULL guard closes the cross-replica race (double generation costs one extra cheap
19
20
  * call at worst, and exactly one title lands).
20
21
  */
22
+ import type { HookLlmFailureCode } from "./hooks/hook-runner.js";
21
23
  export interface SessionTitlerDeps {
22
24
  /** Secret scrub applied to the RAW llm text before sanitize/persist (workflow audit 2026-07-13: the
23
25
  * objective may carry a pasted secret and the model can echo it into the title — titles are persisted,
@@ -42,6 +44,7 @@ export interface SessionTitlerDeps {
42
44
  } | {
43
45
  ok: false;
44
46
  error: string;
47
+ code?: HookLlmFailureCode;
45
48
  }>;
46
49
  logger?: {
47
50
  debug?(msg: string, meta?: unknown): void;
@@ -8,6 +8,16 @@ const LLM_TIMEOUT_MS = 15_000;
8
8
  const NO_ROW_RETRY_MS = 20_000;
9
9
  const FAIL_CAUSE_KEY_CHARS = 80;
10
10
  const SEEN_CAP = 10_000;
11
+ function failOpenTagFor(code) {
12
+ switch (code) {
13
+ case "redirect_refused":
14
+ return "server.session-titler.redirect-refused";
15
+ case "no_content":
16
+ case "hard_timeout":
17
+ case undefined:
18
+ return "server.session-titler.gateway-unreachable";
19
+ }
20
+ }
11
21
  export function sanitizeTitle(raw) {
12
22
  const oneLine = raw.replace(/[\r\n]+/g, " ").replace(CONTROL_AND_BIDI_STRIP_RE, "").trim();
13
23
  const unquoted = oneLine.replace(/^["'「『]+/, "").replace(/["'」』]+$/, "").trim();
@@ -25,10 +35,10 @@ export function buildTitlePrompt(objective) {
25
35
  }
26
36
  export function createSessionTitler(deps) {
27
37
  const seen = new Set();
28
- const logFailure = (sessionId, error) => {
38
+ const logFailure = (sessionId, error, code) => {
29
39
  const safe = redactSecrets(error);
30
40
  deps.logger?.debug?.("session_title_failed", { sessionId, error: safe.slice(0, 200) });
31
- recordFailOpen("server.session-titler.gateway-unreachable", safe.slice(0, FAIL_CAUSE_KEY_CHARS));
41
+ recordFailOpen(failOpenTagFor(code), safe.slice(0, FAIL_CAUSE_KEY_CHARS));
32
42
  };
33
43
  const probeOrContinue = async (sessionId, continueWord) => deps
34
44
  .probeTitle(sessionId)
@@ -65,7 +75,7 @@ export function createSessionTitler(deps) {
65
75
  const r = await deps.llm({ prompt: buildTitlePrompt(objective), timeoutMs: LLM_TIMEOUT_MS, model });
66
76
  if (!r.ok) {
67
77
  deps.metrics?.inc("session_title_total", { outcome: "llm_failed" });
68
- logFailure(sessionId, r.error);
78
+ logFailure(sessionId, r.error, r.code);
69
79
  return;
70
80
  }
71
81
  const title = sanitizeTitle(deps.redact ? deps.redact(r.text) : r.text);
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 一次性迁移 M19 —— 续跑重放腿上,升级前存下的请求体里引擎判官判不过的 `mcpServers[].toolAxes`,窗内按升级前的读法重读一次。
3
+ * 🔴 M19 · 窗口见 `plugins/one-time-migrations.ts` 的 ONE_TIME_MIGRATIONS 登记表(窗、删法、删后由谁接住只写在那一行)。
4
+ *
5
+ * ## 为什么要它(持久数据面)
6
+ * 请求腿读 `toolAxes` 的判官是引擎根导出的 `readMcpToolAxes`(整份一坏全拒、不默认)。升级前的请求腿读者是另一种读法:不是对象(含 `null`)⇒ 整键忽略、
7
+ * server 照挂;是对象 ⇒ 逐成员判形,过不了形的成员剔掉、好的留下。续跑重放腿重放的是**升级前存下的**请求体(`checkpoint_ctx` 里那一份),那时这台 server
8
+ * 是挂着的 —— 一条停驻在它某只工具审批上的任务,升级后若这台 server 整条不进,批准即撞引擎的 `resume.tool_unavailable`、检查点重开、每次重试重放同一份体
9
+ * ⇒ 永远批不过去。持久数据面不能「翻红」,所以给它一次性迁移:窗开着时,续跑重放腿把判官判不过的那一格按升级前的读法重读一次,产物**再交同一只判官**
10
+ * (不过 ⇒ 原臂:整条不进);真挂上的这类 server 由请求腿说一行运维 warn `mcp_tool_axes_read_as_before_upgrade`。窗关之后回到今天的读法,删码零行为变化。
11
+ *
12
+ * ## 可达面(结构上只在续跑重放腿)
13
+ * · 本模块的值只被 `boot/resolve-spec.ts` 引用一处,挂在 `opts.leg` 不是 `"fresh"` 的那一臂(续跑家族经 `http/resume-legs.ts` 的 `resolveResumeSpec`
14
+ * 递 `leg: "resume"`);新提交腿递 `"fresh"` 拿不到重读座,且在腿首已经被同一只判官 400 掉(`assertRequestMcpToolAxes`),读器根本见不到坏形;
15
+ * · 请求腿(`task-mcp.ts`)只 import 本模块的**型**:重读座是 `resolveRequestMcp` 的一个形参,没人递就没有;探测口(`validateRequestMcp`)没有这个形参;
16
+ * · 部署腿(`web-admin-client/skills-mcp.ts` 的 `resolveMcpServers`)是另一只铸 spec 处,不读本模块。
17
+ * 机器锚:`test/mcp-tool-axes-m19.test.ts` 的结构格。
18
+ */
19
+ import type { McpServerSpec } from "@sema-agent/core";
20
+ type ToolAxes = NonNullable<McpServerSpec["toolAxes"]>;
21
+ /** 重读座:一份原始 `toolAxes` 声明 → 升级前的读法读出的那一份(`undefined` = 升级前读成「没声明」)。产物由调用方再交引擎判官。 */
22
+ export type StoredToolAxesRereadM19 = (declared: unknown) => ToolAxes | undefined;
23
+ /**
24
+ * 此刻(`today` = UTC 日历日 YYYY-MM-DD)M19 的窗开着 ⇒ 交回重读座;关了 ⇒ `undefined`(今天的读法)。窗只读登记表 M19 那一行
25
+ * (`migrationWindowOpen`,与 M10 的认领窗同一只判据)。登记表里没有 M19 ⇒ 响亮抛:重读臂活过了它的登记行,该随那一行一起删。
26
+ */
27
+ export declare function storedToolAxesRereadM19(today: string): StoredToolAxesRereadM19 | undefined;
28
+ export {};
29
+ //# sourceMappingURL=stored-request-tool-axes-m19.d.ts.map
@@ -0,0 +1,27 @@
1
+ import { ONE_TIME_MIGRATIONS, migrationWindowOpen } from "./plugins/one-time-migrations.js";
2
+ import { isToolAxisIrreversibility, isToolEffect } from "./tool-axis-words.js";
3
+ function readToolAxesAsServer7129M19(declared) {
4
+ if (!declared || typeof declared !== "object" || Array.isArray(declared))
5
+ return undefined;
6
+ const axes = {};
7
+ for (const [tool, ax] of Object.entries(declared)) {
8
+ if (!ax || typeof ax !== "object" || Array.isArray(ax))
9
+ continue;
10
+ const a = ax;
11
+ const entry = {
12
+ ...(isToolEffect(a.effect) ? { effect: a.effect } : {}),
13
+ ...(typeof a.egress === "boolean" ? { egress: a.egress } : {}),
14
+ ...(isToolAxisIrreversibility(a.irreversibility) ? { irreversibility: a.irreversibility } : {}),
15
+ };
16
+ if (Object.keys(entry).length > 0)
17
+ axes[tool] = entry;
18
+ }
19
+ return Object.keys(axes).length > 0 ? axes : undefined;
20
+ }
21
+ export function storedToolAxesRereadM19(today) {
22
+ const window = ONE_TIME_MIGRATIONS.find((w) => w.id === "M19");
23
+ if (window === undefined)
24
+ throw new Error("stored-request-tool-axes-m19: the one-time migration registry has no M19 row (the replay-leg toolAxes re-read window) — the re-read arm outlived its registry row; delete it with that row");
25
+ return migrationWindowOpen(window, today) ? readToolAxesAsServer7129M19 : undefined;
26
+ }
27
+ //# sourceMappingURL=stored-request-tool-axes-m19.js.map
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { type CompliancePosture, type EngineNotice, type LockedKey, type McpInjectionDropReason, type McpServerSpec } from "@sema-agent/core";
26
26
  import { type McpIdentityHeadersIssue, type RequestMcpServerSpec } from "./mcp-assertion-header.js";
27
+ import type { StoredToolAxesRereadM19 } from "./stored-request-tool-axes-m19.js";
27
28
  /** Bound on caller-supplied servers (anti-DoS; a real local `.mcp.json` has a handful). */
28
29
  export declare const MAX_REQUEST_MCP_SERVERS = 32;
29
30
  /**
@@ -114,6 +115,7 @@ export interface McpInjectionDropSeat {
114
115
  * Shape-check the raw `body.mcpServers`. `null` → a non-array (caller error; the route may 400). Otherwise the
115
116
  * valid subset + the names of dropped (malformed/over-cap) entries + [ref]'s `badContentOrigin`
116
117
  * (**降级**掉的内容出处声明,与 `dropped` 是两回事:那条 server **留着**,丢的只是它的声明)。
118
+ * 这一口没有 M19 的重读座(探测口与单测读到的 `toolAxes` 恒是判官的读数);重读只在续跑重放腿经 {@link resolveRequestMcp} 发生。
117
119
  */
118
120
  export declare function validateRequestMcp(raw: unknown): ValidatedRequestMcp | null;
119
121
  type ValidatedRequestMcp = {
@@ -208,6 +210,32 @@ export declare function requestEntryIdentityRefusal(entry: unknown): {
208
210
  errorCode: "request.body_shape" | "request.field_invalid";
209
211
  message: string;
210
212
  } | undefined;
213
+ /**
214
+ * S-1604 —— `mcpServers[].toolAxes` 的**值域门**(fresh 腿限定,同步 400):判官是引擎根导出的 `readMcpToolAxes`(与部署腿铸 spec 处同一只),
215
+ * 判出 `ok:false` ⇒ `400 request.field_invalid`,点名 `mcpServers[i].toolAxes`、server 名与引擎的原因句(点名工具与成员,不带值)。
216
+ *
217
+ * ## 为什么拒而不是剔
218
+ * `toolAxes` 是调用方对这台 server 每只工具的**双向**风险轴声明(能压低一只工具的效应让审批门放行,也能抬高)。静默剔掉坏成员 = 调用方拿到 2xx、
219
+ * 以为整份声明生效,实际只有另一半在力 —— 一份没有生效的信任根声明。引擎的读法本就是「整份一坏全拒、好的那几只也不留、什么都不默认」,
220
+ * 请求腿修前自铸的读者与它相反;从此只有一只判官,两条腿同读。
221
+ *
222
+ * ## 位置与 leg 分流(与 {@link assertRequestMcpContentOrigin} / {@link assertRequestMcpIdentityHeaders} 同款理由,不发明第二套)
223
+ * 排在腿首、任何副作用之前;**只在 fresh 腿**:`checkpoint_ctx` 存的是原始 `TaskRequestBody`,耐久 resume 逐字重放它,从这里逃出去的 `HttpError`
224
+ * 会被折成 `409 resume_blocked_by_policy`,把一条升级前 park 的任务永久砖死。resume 腿走读器({@link normalizeServer}):同一只判官判出坏形 ⇒ 这一条 server
225
+ * 不进 + `malformed_entry` 点名(运维 warn + 用户面通告,不抛);一次性迁移 M19 的窗开着时先按升级前读法重读一次(见那只读器的注)。
226
+ * 判据是纯值域、与租户车道无关(多租户腿本来就忽略整只数组,告诉调用方「你这条写错了」只多不少)。
227
+ * 探测口读同一只 {@link requestEntryToolAxesRefusal},同一句拒。
228
+ */
229
+ export declare function assertRequestMcpToolAxes(bodyMcp: unknown): void;
230
+ /**
231
+ * 一条**原始**请求条目上 `toolAxes` 的拒面(码 + 句子);没有问题 / 条目不是对象 / 没写 `toolAxes` ⇒ `undefined`。提交腿的门({@link assertRequestMcpToolAxes})与探测口
232
+ * (`mcp-probe.ts` 的 `entryValueIssue`)两扇拒绝门同读这一只,所以两处是**同一句**。判官是引擎的 `readMcpToolAxes`,句子里的原因句逐字取它的 `problem`
233
+ * (只剥控制与双向字符:工具名是调用方写的);不回显值 —— 引擎的句子点名工具与成员、不带值。`at` = 条目的键路径(`mcpServers[3]`)。
234
+ */
235
+ export declare function requestEntryToolAxesRefusal(entry: unknown, at: string): {
236
+ errorCode: "request.field_invalid";
237
+ message: string;
238
+ } | undefined;
211
239
  /**
212
240
  * The ONE predicate for "this MCP server name belongs to the deployment": the scenario baseline (what mounted for
213
241
  * this scenario) ∪ every name the deployment DECLARED (`CenterMcp.reservedNames` — mounted or not, any scenario).
@@ -247,6 +275,9 @@ export declare function resolveRequestMcp(baseline: McpServerSpec[] | undefined,
247
275
  /** L-167:本条腿的**用户面**投递口。`sessionId` = 通告的路由半场(缺席 ⇒ 一条都不铸,见
248
276
  * {@link mintInjectionDropNotices});`emit` 收下 core 铸好的通告,由调用腿决定投到哪(见 `boot/resolve-spec.ts`)。
249
277
  * 整段缺席(桩、单测、非 HTTP 腿)⇒ 只走日志终点,行为逐字与本件之前相同。 */
250
- notice?: McpInjectionDropSeat): McpServerSpec[] | undefined;
278
+ notice?: McpInjectionDropSeat,
279
+ /** 一次性迁移 M19 的重读座(窗口见 `plugins/one-time-migrations.ts` 登记表):只在续跑重放腿、只在窗开着时由 `boot/resolve-spec.ts` 递进来;
280
+ * 缺席(新提交腿 / 窗关 / 桩)⇒ 判官判不过的 `toolAxes` 照原臂整条不进。 */
281
+ storedToolAxes?: StoredToolAxesRereadM19): McpServerSpec[] | undefined;
251
282
  export {};
252
283
  //# sourceMappingURL=task-mcp.d.ts.map