@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.
- package/MIGRATION.md +37 -0
- package/USAGE.md +38 -2
- package/dist/boot/execution-env.js +1 -0
- package/dist/boot/resolve-spec.js +8 -4
- package/dist/boot/stage-10-http-server.d.ts +1 -1
- package/dist/boot/stage-10-http-server.js +2 -0
- package/dist/capabilities/mcp-probe.js +16 -3
- package/dist/capabilities/scenarios.d.ts +33 -0
- package/dist/capabilities/scenarios.js +15 -0
- package/dist/capabilities/skills.js +1 -1
- package/dist/config-catalog.js +5 -1
- package/dist/config-types.d.ts +3 -0
- package/dist/config.js +7 -1
- package/dist/exec-bounds.d.ts +69 -0
- package/dist/exec-bounds.js +37 -0
- package/dist/hooks/hook-llm.d.ts +11 -1
- package/dist/hooks/hook-llm.js +10 -13
- package/dist/hooks/hook-runner.d.ts +1 -1
- package/dist/http/routes/capabilities.js +20 -2
- package/dist/http/routes/runs.js +12 -4
- package/dist/http/server.d.ts +8 -0
- package/dist/observability/fail-open.d.ts +5 -1
- package/dist/observability/fail-open.js +5 -1
- package/dist/plugins/one-time-migrations.d.ts +12 -1
- package/dist/plugins/one-time-migrations.js +17 -0
- package/dist/plugins/remote-env-adb.js +5 -4
- package/dist/plugins/remote-env-e2b.d.ts +31 -9
- package/dist/plugins/remote-env-e2b.js +77 -26
- package/dist/plugins/remote-env-host.js +7 -5
- package/dist/plugins/remote-env-k8s.d.ts +12 -4
- package/dist/plugins/remote-env-k8s.js +22 -31
- package/dist/plugins/remote-env-local-docker.js +5 -4
- package/dist/plugins/remote-env-ssh.js +5 -4
- package/dist/runtime-caps-resolver.d.ts +7 -0
- package/dist/runtime-caps-resolver.js +1 -0
- package/dist/session-titler.d.ts +4 -1
- package/dist/session-titler.js +13 -3
- package/dist/stored-request-tool-axes-m19.d.ts +29 -0
- package/dist/stored-request-tool-axes-m19.js +27 -0
- package/dist/task-mcp.d.ts +32 -1
- package/dist/task-mcp.js +66 -33
- package/dist/tool-axis-words.d.ts +14 -17
- package/dist/web-admin-client/skills-mcp.d.ts +1 -1
- 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
|
|
814
|
-
const
|
|
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 =
|
|
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 (
|
|
919
|
+
if (liveness !== undefined && !finished && Date.now() - lastChunk >= liveness.ms) {
|
|
918
920
|
finished = true;
|
|
919
921
|
kill();
|
|
920
|
-
throw new RemoteExecutionError("timeout",
|
|
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
|
-
/**
|
|
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
|
|
555
|
-
*
|
|
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
|
|
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 ??
|
|
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
|
|
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
|
|
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
|
|
1131
|
-
const
|
|
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
|
|
1138
|
+
const liveness = bounds.liveness;
|
|
1134
1139
|
const armLiveness = () => {
|
|
1135
|
-
if (
|
|
1140
|
+
if (liveness === undefined)
|
|
1136
1141
|
return;
|
|
1137
1142
|
clearTimeout(livenessTimer);
|
|
1138
1143
|
const idle = Date.now() - lastProgress;
|
|
1139
|
-
if (idle >=
|
|
1140
|
-
finish({ ok: false, error: new ExecutionError("timeout",
|
|
1144
|
+
if (idle >= liveness.ms) {
|
|
1145
|
+
finish({ ok: false, error: new ExecutionError("timeout", livenessCutMessage(liveness)) });
|
|
1141
1146
|
return;
|
|
1142
1147
|
}
|
|
1143
|
-
livenessTimer = setTimeout(armLiveness,
|
|
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
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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 =
|
|
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 (
|
|
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",
|
|
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
|
|
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 =
|
|
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 (
|
|
438
|
-
throw new RemoteExecutionError("timeout",
|
|
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
|
}
|
package/dist/session-titler.d.ts
CHANGED
|
@@ -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).
|
|
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;
|
package/dist/session-titler.js
CHANGED
|
@@ -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(
|
|
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
|
package/dist/task-mcp.d.ts
CHANGED
|
@@ -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
|
|
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
|