machine-bridge-mcp 1.2.5 → 1.2.7
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/CHANGELOG.md +18 -0
- package/README.md +2 -2
- package/SECURITY.md +3 -3
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +6 -6
- package/docs/AUDIT.md +24 -0
- package/docs/OPERATIONS.md +5 -3
- package/docs/TESTING.md +3 -3
- package/docs/UPGRADING.md +3 -3
- package/package.json +1 -1
- package/src/local/browser-bridge.mjs +67 -22
- package/src/local/cli-local-admin.mjs +2 -3
- package/src/local/daemon-process.mjs +50 -8
- package/src/local/execution-limits.mjs +36 -0
- package/src/local/job-runner.mjs +5 -8
- package/src/local/loopback-health.mjs +67 -0
- package/src/local/managed-jobs.mjs +22 -9
- package/src/local/process-contract.mjs +19 -0
- package/src/local/process-execution.mjs +11 -10
- package/src/local/process-sessions.mjs +25 -50
- package/src/local/process-tracker.mjs +1 -1
- package/src/local/process-tree.mjs +53 -0
- package/src/local/relay-connection.mjs +11 -1
- package/src/local/runtime-reporting.mjs +3 -1
- package/src/local/runtime.mjs +8 -8
- package/src/local/shell.mjs +2 -19
- package/src/worker/index.ts +1 -1
|
@@ -11,6 +11,7 @@ import { inspectProcessInstance, isPidAlive, processCommandLine, splitProcessCom
|
|
|
11
11
|
|
|
12
12
|
const DEFAULT_TAKEOVER_TIMEOUT_MS = 15_000;
|
|
13
13
|
const DEFAULT_TAKEOVER_POLL_MS = 100;
|
|
14
|
+
const DEFAULT_FORCE_AFTER_MS = 2_000;
|
|
14
15
|
|
|
15
16
|
export async function acquireDaemonLockWithTakeover(state, options = {}) {
|
|
16
17
|
const ownerMetadata = options.ownerMetadata || {};
|
|
@@ -50,9 +51,10 @@ export async function acquireDaemonLockWithTakeover(state, options = {}) {
|
|
|
50
51
|
export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
51
52
|
const timeoutMs = boundedPositiveInt(options.timeoutMs, DEFAULT_TAKEOVER_TIMEOUT_MS);
|
|
52
53
|
const pollMs = boundedPositiveInt(options.pollMs, DEFAULT_TAKEOVER_POLL_MS);
|
|
54
|
+
const forceAfterMs = Math.min(timeoutMs, boundedPositiveInt(options.forceAfterMs, DEFAULT_FORCE_AFTER_MS));
|
|
53
55
|
const logger = options.logger || { info() {}, warn() {} };
|
|
54
56
|
const deadline = createMonotonicDeadline(timeoutMs);
|
|
55
|
-
const signalled = new
|
|
57
|
+
const signalled = new Map();
|
|
56
58
|
let owner = options.owner || readDaemonLockOwner(daemonLockPathForState(state));
|
|
57
59
|
let verified = false;
|
|
58
60
|
let lastOwner = owner;
|
|
@@ -89,12 +91,42 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
|
89
91
|
};
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
|
-
signalled.
|
|
94
|
+
signalled.set(Number(owner.pid), {
|
|
95
|
+
owner: { ...owner },
|
|
96
|
+
forceDeadline: createMonotonicDeadline(forceAfterMs),
|
|
97
|
+
forced: false,
|
|
98
|
+
});
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
101
|
|
|
96
|
-
const
|
|
97
|
-
|
|
102
|
+
for (const [pid, signalState] of signalled) {
|
|
103
|
+
if (signalState.forced || !signalState.forceDeadline.expired()) continue;
|
|
104
|
+
const current = inspectProcessInstance(signalState.owner);
|
|
105
|
+
if (!current.current) continue;
|
|
106
|
+
const identity = inspectWorkspaceDaemonOwner(state, signalState.owner);
|
|
107
|
+
if (!identity.verified_service_daemon) continue;
|
|
108
|
+
logger.warn?.(`detached background daemon ignored graceful termination; forcing process ${pid} to stop`);
|
|
109
|
+
try {
|
|
110
|
+
process.kill(pid, "SIGKILL");
|
|
111
|
+
signalState.forced = true;
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (error?.code !== "ESRCH") {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
found: true,
|
|
117
|
+
verified_service_daemon: true,
|
|
118
|
+
reason: "force_signal_failed",
|
|
119
|
+
timeout_ms: timeoutMs,
|
|
120
|
+
...publicDaemonOwner(signalState.owner),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const liveSignalled = [...signalled.entries()]
|
|
127
|
+
.filter(([, signalState]) => inspectProcessInstance(signalState.owner).current)
|
|
128
|
+
.map(([pid]) => pid);
|
|
129
|
+
const currentOwnerAlive = Boolean(owner?.pid && inspectProcessInstance(owner).current);
|
|
98
130
|
if (!currentOwnerAlive && liveSignalled.length === 0) break;
|
|
99
131
|
|
|
100
132
|
if (deadline.expired()) {
|
|
@@ -107,6 +139,7 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
|
107
139
|
verified_service_daemon: verified,
|
|
108
140
|
reason: "timeout",
|
|
109
141
|
timeout_ms: timeoutMs,
|
|
142
|
+
force_after_ms: forceAfterMs,
|
|
110
143
|
pid: remainingPid,
|
|
111
144
|
mode: publicDaemonMode(lastOwner),
|
|
112
145
|
version: typeof lastOwner?.version === "string" ? lastOwner.version : "unknown",
|
|
@@ -126,6 +159,8 @@ export async function stopWorkspaceServiceDaemon(state, options = {}) {
|
|
|
126
159
|
verified_service_daemon: verified,
|
|
127
160
|
reason: verified ? "stopped" : "not_running",
|
|
128
161
|
timeout_ms: timeoutMs,
|
|
162
|
+
force_after_ms: forceAfterMs,
|
|
163
|
+
forced: [...signalled.values()].some((item) => item.forced),
|
|
129
164
|
...(lastOwner ? publicDaemonOwner(lastOwner) : {}),
|
|
130
165
|
};
|
|
131
166
|
}
|
|
@@ -168,11 +203,18 @@ function inspectWorkspaceDaemonOwner(state, owner) {
|
|
|
168
203
|
const name = path.basename(value);
|
|
169
204
|
return name === entryName || name === "machine-mcp" || name === "machine-mcp.mjs";
|
|
170
205
|
});
|
|
171
|
-
const
|
|
206
|
+
const explicitIdentity = Boolean(workspaceArg && stateRootArg)
|
|
172
207
|
&& sameCanonicalPath(workspaceArg, state.workspace.path)
|
|
173
|
-
&& sameCanonicalPath(stateRootArg, state.paths.stateRoot)
|
|
174
|
-
|
|
175
|
-
|
|
208
|
+
&& sameCanonicalPath(stateRootArg, state.paths.stateRoot);
|
|
209
|
+
const implicitIdentity = !workspaceArg && !stateRootArg;
|
|
210
|
+
const commonIdentity = argv.includes("--daemon-only") && entryMatches;
|
|
211
|
+
if (commonIdentity && explicitIdentity) {
|
|
212
|
+
return { verified_service_daemon: true, reason: "service_command" };
|
|
213
|
+
}
|
|
214
|
+
if (commonIdentity && implicitIdentity) {
|
|
215
|
+
return { verified_service_daemon: true, reason: "implicit_service_command" };
|
|
216
|
+
}
|
|
217
|
+
return { verified_service_daemon: false, reason: "command_mismatch" };
|
|
176
218
|
}
|
|
177
219
|
|
|
178
220
|
function commandFlagValue(argv, name) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const MAX_CONCURRENT_TOOL_CALLS = 16;
|
|
2
|
+
export const MAX_PROCESS_SESSIONS = 8;
|
|
3
|
+
export const MIN_PROCESS_TIMEOUT_SECONDS = 1;
|
|
4
|
+
export const MAX_PROCESS_TIMEOUT_SECONDS = 600;
|
|
5
|
+
export const DEFAULT_PROCESS_OUTPUT_BYTES = 512 * 1024;
|
|
6
|
+
export const MAX_PROCESS_STDIN_BYTES = 1024 * 1024;
|
|
7
|
+
export const MAX_PROCESS_SESSION_OUTPUT_BYTES = 1024 * 1024;
|
|
8
|
+
export const MAX_PROCESS_SESSION_STDIN_BYTES = 64 * 1024;
|
|
9
|
+
export const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export function executionGuardrailsSnapshot() {
|
|
12
|
+
return {
|
|
13
|
+
tool_calls: {
|
|
14
|
+
maximum_concurrent: MAX_CONCURRENT_TOOL_CALLS,
|
|
15
|
+
},
|
|
16
|
+
one_shot_processes: {
|
|
17
|
+
timeout_seconds: { minimum: MIN_PROCESS_TIMEOUT_SECONDS, maximum: MAX_PROCESS_TIMEOUT_SECONDS },
|
|
18
|
+
stdin_max_bytes: MAX_PROCESS_STDIN_BYTES,
|
|
19
|
+
output_max_bytes_per_stream: DEFAULT_PROCESS_OUTPUT_BYTES,
|
|
20
|
+
process_tree_termination: "sigterm-then-sigkill",
|
|
21
|
+
},
|
|
22
|
+
process_sessions: {
|
|
23
|
+
maximum_concurrent: MAX_PROCESS_SESSIONS,
|
|
24
|
+
retained_output_max_bytes_per_stream: MAX_PROCESS_SESSION_OUTPUT_BYTES,
|
|
25
|
+
write_stdin_max_bytes: MAX_PROCESS_SESSION_STDIN_BYTES,
|
|
26
|
+
exited_retention_ms: PROCESS_SESSION_RETENTION_MS,
|
|
27
|
+
process_tree_termination: "sigterm-then-sigkill",
|
|
28
|
+
},
|
|
29
|
+
operating_system_enforcement: {
|
|
30
|
+
cpu_quota: "not-enforced",
|
|
31
|
+
memory_quota: "not-enforced",
|
|
32
|
+
network_isolation: "not-enforced",
|
|
33
|
+
required_boundary: "dedicated-low-privilege-account-or-vm-container",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/local/job-runner.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs
|
|
|
4
4
|
import { basename, join, resolve } from "node:path";
|
|
5
5
|
import { performance } from "node:perf_hooks";
|
|
6
6
|
import { executionEnv } from "./shell.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { terminateProcessTreeWithEscalation } from "./process-tree.mjs";
|
|
8
8
|
import { createExclusiveFileSync, removeOwnedJsonFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
9
9
|
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
10
10
|
import { currentProcessStartTimeMs } from "./process-identity.mjs";
|
|
@@ -279,8 +279,7 @@ function spawnStep(argv, { cwd, env, input, timeoutMs, cancellationAware, captur
|
|
|
279
279
|
let killTimer = null;
|
|
280
280
|
const timer = setTimeout(() => {
|
|
281
281
|
timedOut = true;
|
|
282
|
-
|
|
283
|
-
killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
|
|
282
|
+
killTimer = terminateProcessTreeWithEscalation(child);
|
|
284
283
|
}, timeoutMs);
|
|
285
284
|
timer.unref?.();
|
|
286
285
|
const cancellationPoll = setInterval(() => {
|
|
@@ -474,12 +473,10 @@ function requestCancellation() {
|
|
|
474
473
|
cancelRequested = true;
|
|
475
474
|
const child = activeChild;
|
|
476
475
|
if (!child || !activeChildCancellationAware) return;
|
|
477
|
-
terminateProcessTree(child, "SIGTERM");
|
|
478
476
|
if (cancellationEscalation) return;
|
|
479
|
-
cancellationEscalation =
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
}, 2000);
|
|
477
|
+
cancellationEscalation = terminateProcessTreeWithEscalation(child, {
|
|
478
|
+
onEscalated: () => { cancellationEscalation = null; },
|
|
479
|
+
});
|
|
483
480
|
}
|
|
484
481
|
|
|
485
482
|
function isCancellationRequested() {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { request as requestHttp } from "node:http";
|
|
2
|
+
|
|
3
|
+
export function readLoopbackJson(input, options = {}) {
|
|
4
|
+
let target;
|
|
5
|
+
try { target = new URL(String(input)); } catch { return Promise.resolve(null); }
|
|
6
|
+
const pathname = typeof options.pathname === "string" && options.pathname.startsWith("/") ? options.pathname : "/healthz";
|
|
7
|
+
const port = Number(target.port);
|
|
8
|
+
if (target.protocol !== "http:"
|
|
9
|
+
|| target.hostname !== "127.0.0.1"
|
|
10
|
+
|| target.pathname !== pathname
|
|
11
|
+
|| target.username || target.password || target.search || target.hash
|
|
12
|
+
|| !Number.isInteger(port) || port < 1024 || port > 65535) {
|
|
13
|
+
return Promise.resolve(null);
|
|
14
|
+
}
|
|
15
|
+
const request = typeof options.request === "function" ? options.request : requestHttp;
|
|
16
|
+
const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Math.max(1, Number(options.timeoutMs)) : 2000;
|
|
17
|
+
const maximumBytes = Number.isFinite(Number(options.maximumBytes)) ? Math.max(1, Number(options.maximumBytes)) : 64 * 1024;
|
|
18
|
+
return new Promise((resolvePromise) => {
|
|
19
|
+
let settled = false;
|
|
20
|
+
let client;
|
|
21
|
+
const finish = (value) => {
|
|
22
|
+
if (settled) return;
|
|
23
|
+
settled = true;
|
|
24
|
+
resolvePromise(value);
|
|
25
|
+
};
|
|
26
|
+
try {
|
|
27
|
+
client = request({
|
|
28
|
+
protocol: "http:", hostname: "127.0.0.1", port, path: pathname, method: "GET", agent: false,
|
|
29
|
+
headers: { accept: "application/json", connection: "close" },
|
|
30
|
+
}, (response) => {
|
|
31
|
+
if (response.statusCode !== 200) {
|
|
32
|
+
response.resume();
|
|
33
|
+
finish(null);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let total = 0;
|
|
38
|
+
response.on("data", (chunk) => {
|
|
39
|
+
if (settled) return;
|
|
40
|
+
total += chunk.length;
|
|
41
|
+
if (total > maximumBytes) {
|
|
42
|
+
client.destroy();
|
|
43
|
+
finish(null);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
});
|
|
48
|
+
response.on("end", () => {
|
|
49
|
+
if (settled) return;
|
|
50
|
+
try {
|
|
51
|
+
const value = JSON.parse(Buffer.concat(chunks, total).toString("utf8"));
|
|
52
|
+
finish(value && typeof value === "object" && !Array.isArray(value) ? value : null);
|
|
53
|
+
} catch {
|
|
54
|
+
finish(null);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
response.on("error", () => finish(null));
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
finish(null);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
client.setTimeout(timeoutMs, () => client.destroy(new Error("loopback health request timed out")));
|
|
64
|
+
client.on("error", () => finish(null));
|
|
65
|
+
client.end();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { closeSync, constants as fsConstants, existsSync, ftruncateSync, lstatSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { assertStateMaintenanceAvailable, ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
7
7
|
import { createExclusiveFileSync, replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
@@ -12,6 +12,7 @@ import { BridgeError } from "./errors.mjs";
|
|
|
12
12
|
import { inspectResourceFile, normalizeResourceRegistry, validatePlan } from "./managed-job-plan.mjs";
|
|
13
13
|
export { inspectResourceFile, publicResourceRegistry, validateResourceName } from "./managed-job-plan.mjs";
|
|
14
14
|
import { clampInteger } from "./numbers.mjs";
|
|
15
|
+
import { classifyOperationalError } from "./log.mjs";
|
|
15
16
|
|
|
16
17
|
const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
|
|
17
18
|
const MAX_JOBS = 50;
|
|
@@ -124,7 +125,7 @@ export class ManagedJobManager {
|
|
|
124
125
|
status.cleanup_guarantee = "best-effort-finally-and-recovery";
|
|
125
126
|
atomicWriteJson(statusFile, status, 256 * 1024);
|
|
126
127
|
try {
|
|
127
|
-
launchRunner(dir);
|
|
128
|
+
launchRunner(dir, false, "", { logger: this.logger });
|
|
128
129
|
} catch (error) {
|
|
129
130
|
failRunnerLaunch(dir, status, error);
|
|
130
131
|
throw error;
|
|
@@ -181,7 +182,7 @@ export class ManagedJobManager {
|
|
|
181
182
|
atomicWriteJson(join(dir, "status.json"), status, 256 * 1024);
|
|
182
183
|
if (launch) {
|
|
183
184
|
try {
|
|
184
|
-
launchRunner(dir);
|
|
185
|
+
launchRunner(dir, false, "", { logger: this.logger });
|
|
185
186
|
} catch (error) {
|
|
186
187
|
failRunnerLaunch(dir, status, error);
|
|
187
188
|
throw error;
|
|
@@ -356,7 +357,7 @@ export class ManagedJobManager {
|
|
|
356
357
|
markRecoveryExhausted(dir, file, status, recoveryAttempts);
|
|
357
358
|
return;
|
|
358
359
|
}
|
|
359
|
-
const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts, recoveryLock.token);
|
|
360
|
+
const runnerPid = relaunchInterruptedJob(dir, file, status, recoveryAttempts, recoveryLock.token, this.logger);
|
|
360
361
|
recoveryLock.handoff(runnerPid);
|
|
361
362
|
handedOff = true;
|
|
362
363
|
} finally {
|
|
@@ -513,7 +514,7 @@ function markRecoveryExhausted(dir, statusFile, status, recoveryAttempts) {
|
|
|
513
514
|
scrubFinishedPlan(dir, status);
|
|
514
515
|
}
|
|
515
516
|
|
|
516
|
-
function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recoveryToken) {
|
|
517
|
+
function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recoveryToken, logger) {
|
|
517
518
|
status.status = "interrupted";
|
|
518
519
|
status.updated_at = new Date().toISOString();
|
|
519
520
|
status.finished_at = status.updated_at;
|
|
@@ -522,7 +523,7 @@ function relaunchInterruptedJob(dir, statusFile, status, recoveryAttempts, recov
|
|
|
522
523
|
atomicWriteJson(statusFile, status, 256 * 1024);
|
|
523
524
|
rmSync(join(dir, "runtime"), { recursive: true, force: true });
|
|
524
525
|
rmSync(join(dir, "runner.pid"), { force: true });
|
|
525
|
-
return launchRunner(dir, true, recoveryToken);
|
|
526
|
+
return launchRunner(dir, true, recoveryToken, { logger });
|
|
526
527
|
}
|
|
527
528
|
|
|
528
529
|
function acquireRecoveryLock(dir) {
|
|
@@ -634,7 +635,7 @@ function replacePrivateTextFile(file, content) {
|
|
|
634
635
|
ownerOnlyFile(file);
|
|
635
636
|
}
|
|
636
637
|
|
|
637
|
-
function launchRunner(dir, recover = false, recoveryToken = "") {
|
|
638
|
+
export function launchRunner(dir, recover = false, recoveryToken = "", options = {}) {
|
|
638
639
|
const args = [RUNNER_PATH, "--job-dir", dir];
|
|
639
640
|
if (recover) args.push("--recover");
|
|
640
641
|
const stdoutFile = join(dir, "runner.out.log");
|
|
@@ -647,10 +648,12 @@ function launchRunner(dir, recover = false, recoveryToken = "") {
|
|
|
647
648
|
try {
|
|
648
649
|
stdoutFd = openPrivateAppendFile(stdoutFile);
|
|
649
650
|
stderrFd = openPrivateAppendFile(stderrFile);
|
|
650
|
-
|
|
651
|
+
const spawnProcess = typeof options.spawnProcess === "function" ? options.spawnProcess : spawn;
|
|
652
|
+
child = spawnProcess(process.execPath, args, {
|
|
651
653
|
detached: true,
|
|
652
654
|
stdio: ["ignore", stdoutFd, stderrFd],
|
|
653
655
|
windowsHide: true,
|
|
656
|
+
shell: false,
|
|
654
657
|
env: recoveryToken ? { ...process.env, MBM_RECOVERY_LOCK_TOKEN: recoveryToken } : process.env,
|
|
655
658
|
});
|
|
656
659
|
} finally {
|
|
@@ -659,8 +662,18 @@ function launchRunner(dir, recover = false, recoveryToken = "") {
|
|
|
659
662
|
}
|
|
660
663
|
ownerOnlyFile(stdoutFile);
|
|
661
664
|
ownerOnlyFile(stderrFile);
|
|
665
|
+
const logger = options.logger || console;
|
|
666
|
+
child.once?.("error", (error) => {
|
|
667
|
+
logger.error?.("managed job runner process reported an asynchronous failure", {
|
|
668
|
+
job_id: basename(dir),
|
|
669
|
+
recovery: recover,
|
|
670
|
+
error_class: classifyOperationalError(error),
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
const pid = Number(child.pid);
|
|
674
|
+
if (!Number.isInteger(pid) || pid <= 0) throw new Error("managed job runner did not receive a process id");
|
|
662
675
|
child.unref();
|
|
663
|
-
return
|
|
676
|
+
return pid;
|
|
664
677
|
}
|
|
665
678
|
|
|
666
679
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const MAX_COMMAND_BYTES = 64 * 1024;
|
|
2
|
+
export const MAX_ARGV_ITEMS = 256;
|
|
3
|
+
|
|
4
|
+
export function validateArgv(value) {
|
|
5
|
+
if (!Array.isArray(value) || !value.length || value.length > MAX_ARGV_ITEMS) {
|
|
6
|
+
throw new Error(`argv must contain 1-${MAX_ARGV_ITEMS} strings`);
|
|
7
|
+
}
|
|
8
|
+
const argv = value.map((item) => {
|
|
9
|
+
if (typeof item !== "string" || item.includes("\0")) {
|
|
10
|
+
throw new Error("argv entries must be strings without NUL bytes");
|
|
11
|
+
}
|
|
12
|
+
return item;
|
|
13
|
+
});
|
|
14
|
+
if (!argv[0]) throw new Error("argv[0] must not be empty");
|
|
15
|
+
if (Buffer.byteLength(JSON.stringify(argv)) > MAX_COMMAND_BYTES) {
|
|
16
|
+
throw new Error(`argv exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
|
|
17
|
+
}
|
|
18
|
+
return argv;
|
|
19
|
+
}
|
|
@@ -2,12 +2,13 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { stat } from "node:fs/promises";
|
|
3
3
|
import { BoundedOutput } from "./bounded-output.mjs";
|
|
4
4
|
import { executionEnv, workspaceShellCommand } from "./shell.mjs";
|
|
5
|
-
import { MAX_COMMAND_BYTES,
|
|
5
|
+
import { MAX_COMMAND_BYTES, validateArgv } from "./process-contract.mjs";
|
|
6
|
+
import { terminateProcessTreeWithEscalation } from "./process-tree.mjs";
|
|
6
7
|
import { BridgeError } from "./errors.mjs";
|
|
7
8
|
import { clampInteger } from "./numbers.mjs";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_PROCESS_OUTPUT_BYTES, MAX_PROCESS_STDIN_BYTES, MAX_PROCESS_TIMEOUT_SECONDS, MIN_PROCESS_TIMEOUT_SECONDS,
|
|
11
|
+
} from "./execution-limits.mjs";
|
|
11
12
|
|
|
12
13
|
function spawnDirectProcess(command, args, options) {
|
|
13
14
|
// Keep the production child_process API call structurally separate from the
|
|
@@ -41,7 +42,7 @@ export class ProcessExecutionService {
|
|
|
41
42
|
const argv = validateArgv(args.argv);
|
|
42
43
|
const cwd = await this.resolveExistingPath(args.cwd || ".");
|
|
43
44
|
if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "cwd is not a directory");
|
|
44
|
-
return this.run(argv[0], argv.slice(1), clampInteger(args.timeout_seconds, 120,
|
|
45
|
+
return this.run(argv[0], argv.slice(1), clampInteger(args.timeout_seconds, 120, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS) * 1000, false, DEFAULT_PROCESS_OUTPUT_BYTES, context, cwd);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
async runRegistered(args, context = {}) {
|
|
@@ -52,9 +53,9 @@ export class ProcessExecutionService {
|
|
|
52
53
|
if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "registered command cwd is not a directory");
|
|
53
54
|
const requested = args.timeout_seconds === undefined
|
|
54
55
|
? command.timeoutSeconds
|
|
55
|
-
: clampInteger(args.timeout_seconds, command.timeoutSeconds,
|
|
56
|
+
: clampInteger(args.timeout_seconds, command.timeoutSeconds, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS);
|
|
56
57
|
const timeoutSeconds = Math.min(requested, command.timeoutSeconds);
|
|
57
|
-
const result = await this.run(argv[0], argv.slice(1), timeoutSeconds * 1000, false,
|
|
58
|
+
const result = await this.run(argv[0], argv.slice(1), timeoutSeconds * 1000, false, DEFAULT_PROCESS_OUTPUT_BYTES, context, cwd);
|
|
58
59
|
return { name: command.name, cwd: this.displayPath(cwd), timeout_seconds: timeoutSeconds, ...result };
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -69,16 +70,16 @@ export class ProcessExecutionService {
|
|
|
69
70
|
if (command.includes("\0")) throw new BridgeError("invalid_request", "command contains a NUL byte");
|
|
70
71
|
if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new BridgeError("limit_exceeded", `command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
|
|
71
72
|
const shell = workspaceShellCommand(command);
|
|
72
|
-
return this.run(shell.cmd, shell.args, clampInteger(timeoutSeconds, 120,
|
|
73
|
+
return this.run(shell.cmd, shell.args, clampInteger(timeoutSeconds, 120, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS) * 1000, false, DEFAULT_PROCESS_OUTPUT_BYTES, context);
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
terminateAll(signal = "SIGTERM", escalate = false) {
|
|
76
77
|
this.processTracker.terminateAll(signal, escalate);
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
async run(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes =
|
|
80
|
+
async run(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = DEFAULT_PROCESS_OUTPUT_BYTES, context = {}, cwd = this.workspace, stdin = null) {
|
|
80
81
|
this.throwIfCancelled(context);
|
|
81
|
-
if (stdin !== null && Buffer.byteLength(String(stdin)) >
|
|
82
|
+
if (stdin !== null && Buffer.byteLength(String(stdin)) > MAX_PROCESS_STDIN_BYTES) {
|
|
82
83
|
throw new BridgeError("limit_exceeded", "process stdin exceeds 1 MiB");
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -2,16 +2,14 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import { basename } from "node:path";
|
|
4
4
|
import { executionEnv } from "./shell.mjs";
|
|
5
|
+
import { validateArgv } from "./process-contract.mjs";
|
|
6
|
+
import { terminateProcessTree, terminateProcessTreeWithEscalation } from "./process-tree.mjs";
|
|
5
7
|
import { createToolAuthorizer } from "./policy.mjs";
|
|
6
8
|
import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
|
|
7
9
|
import { clampInteger } from "./numbers.mjs";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const MAX_PROCESS_SESSIONS = 8;
|
|
12
|
-
const MAX_SESSION_OUTPUT_BYTES = 1024 * 1024;
|
|
13
|
-
const MAX_PROCESS_STDIN_BYTES = 64 * 1024;
|
|
14
|
-
const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
|
|
10
|
+
import {
|
|
11
|
+
MAX_PROCESS_SESSIONS, MAX_PROCESS_SESSION_OUTPUT_BYTES, MAX_PROCESS_SESSION_STDIN_BYTES, PROCESS_SESSION_RETENTION_MS,
|
|
12
|
+
} from "./execution-limits.mjs";
|
|
15
13
|
|
|
16
14
|
export class ProcessSessionManager {
|
|
17
15
|
constructor({ workspace, policy, authorizeTool = null, runtimeDir, processTracker, resolveCwd, displayPath, throwIfCancelled }) {
|
|
@@ -70,6 +68,7 @@ export class ProcessSessionManager {
|
|
|
70
68
|
signal: null,
|
|
71
69
|
stdinClosed: false,
|
|
72
70
|
waiters: new Set(),
|
|
71
|
+
terminationTimer: null,
|
|
73
72
|
};
|
|
74
73
|
this.sessions.set(session.id, session);
|
|
75
74
|
this.trackChild(child, context.callId);
|
|
@@ -94,6 +93,11 @@ export class ProcessSessionManager {
|
|
|
94
93
|
notifySessionWaiters(session);
|
|
95
94
|
});
|
|
96
95
|
|
|
96
|
+
child.on("error", (error) => {
|
|
97
|
+
appendSessionStream(session.stderr, Buffer.from(`${boundedErrorMessage(error)}\n`));
|
|
98
|
+
session.lastActivity = Date.now();
|
|
99
|
+
notifySessionWaiters(session);
|
|
100
|
+
});
|
|
97
101
|
try {
|
|
98
102
|
await waitForSpawn(child);
|
|
99
103
|
} catch (error) {
|
|
@@ -101,12 +105,6 @@ export class ProcessSessionManager {
|
|
|
101
105
|
this.untrackChild(child);
|
|
102
106
|
throw error;
|
|
103
107
|
}
|
|
104
|
-
|
|
105
|
-
child.on("error", (error) => {
|
|
106
|
-
appendSessionStream(session.stderr, Buffer.from(`${boundedErrorMessage(error)}\n`));
|
|
107
|
-
session.lastActivity = Date.now();
|
|
108
|
-
notifySessionWaiters(session);
|
|
109
|
-
});
|
|
110
108
|
this.throwIfCancelled(context);
|
|
111
109
|
return this.summary(session);
|
|
112
110
|
}
|
|
@@ -144,7 +142,7 @@ export class ProcessSessionManager {
|
|
|
144
142
|
const session = this.get(args.session_id);
|
|
145
143
|
if (session.closedAt !== null) throw new Error("process session has already exited");
|
|
146
144
|
const data = String(args.data ?? "");
|
|
147
|
-
if (Buffer.byteLength(data) >
|
|
145
|
+
if (Buffer.byteLength(data) > MAX_PROCESS_SESSION_STDIN_BYTES) throw new Error(`stdin data exceeds maximum size (${MAX_PROCESS_SESSION_STDIN_BYTES} bytes)`);
|
|
148
146
|
if (session.stdinClosed || session.child.stdin.destroyed) throw new Error("process session stdin is closed");
|
|
149
147
|
this.throwIfCancelled(context);
|
|
150
148
|
if (data) {
|
|
@@ -165,9 +163,18 @@ export class ProcessSessionManager {
|
|
|
165
163
|
const session = this.get(args.session_id);
|
|
166
164
|
this.throwIfCancelled(context);
|
|
167
165
|
const wasRunning = session.closedAt === null;
|
|
168
|
-
|
|
166
|
+
const force = args.force === true;
|
|
167
|
+
if (wasRunning && force) terminateProcessTree(session.child, "SIGKILL");
|
|
168
|
+
else if (wasRunning && !session.terminationTimer) {
|
|
169
|
+
session.terminationTimer = terminateProcessTreeWithEscalation(session.child);
|
|
170
|
+
}
|
|
169
171
|
session.lastActivity = Date.now();
|
|
170
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
...this.summary(session),
|
|
174
|
+
termination_requested: wasRunning,
|
|
175
|
+
force,
|
|
176
|
+
force_after_ms: wasRunning && !force ? 2000 : null,
|
|
177
|
+
};
|
|
171
178
|
}
|
|
172
179
|
|
|
173
180
|
get(sessionId) {
|
|
@@ -220,38 +227,6 @@ export class ProcessSessionManager {
|
|
|
220
227
|
}
|
|
221
228
|
}
|
|
222
229
|
|
|
223
|
-
export function validateArgv(value) {
|
|
224
|
-
if (!Array.isArray(value) || !value.length || value.length > MAX_ARGV_ITEMS) throw new Error(`argv must contain 1-${MAX_ARGV_ITEMS} strings`);
|
|
225
|
-
const argv = value.map((item) => {
|
|
226
|
-
if (typeof item !== "string" || item.includes("\0")) throw new Error("argv entries must be strings without NUL bytes");
|
|
227
|
-
return item;
|
|
228
|
-
});
|
|
229
|
-
if (!argv[0]) throw new Error("argv[0] must not be empty");
|
|
230
|
-
if (Buffer.byteLength(JSON.stringify(argv)) > MAX_COMMAND_BYTES) throw new Error(`argv exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
|
|
231
|
-
return argv;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function terminateProcessTree(child, signal) {
|
|
235
|
-
if (!child?.pid) return;
|
|
236
|
-
if (process.platform === "win32") {
|
|
237
|
-
try {
|
|
238
|
-
const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
239
|
-
killer.unref();
|
|
240
|
-
return;
|
|
241
|
-
} catch {}
|
|
242
|
-
}
|
|
243
|
-
try { process.kill(-child.pid, signal); } catch {
|
|
244
|
-
try { child.kill(signal); } catch {}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
export function terminateProcessTreeWithEscalation(child, options = {}) {
|
|
249
|
-
const graceMs = Number.isFinite(Number(options.graceMs)) ? Math.max(0, Number(options.graceMs)) : 2000;
|
|
250
|
-
const schedule = typeof options.setTimeout === "function" ? options.setTimeout : setTimeout;
|
|
251
|
-
terminateProcessTree(child, "SIGTERM");
|
|
252
|
-
return schedule(() => terminateProcessTree(child, "SIGKILL"), graceMs);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
230
|
function waitForSpawn(child) {
|
|
256
231
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
257
232
|
const onSpawn = () => { cleanup(); resolvePromise(); };
|
|
@@ -273,8 +248,8 @@ function appendSessionStream(stream, chunk) {
|
|
|
273
248
|
const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
|
|
274
249
|
stream.totalBytes += input.length;
|
|
275
250
|
let combined = stream.buffer.length ? Buffer.concat([stream.buffer, input]) : Buffer.from(input);
|
|
276
|
-
if (combined.length >
|
|
277
|
-
const dropped = combined.length -
|
|
251
|
+
if (combined.length > MAX_PROCESS_SESSION_OUTPUT_BYTES) {
|
|
252
|
+
const dropped = combined.length - MAX_PROCESS_SESSION_OUTPUT_BYTES;
|
|
278
253
|
combined = combined.subarray(dropped);
|
|
279
254
|
stream.baseOffset += dropped;
|
|
280
255
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_PROCESS_TERMINATION_GRACE_MS = 2000;
|
|
4
|
+
|
|
5
|
+
export function terminateProcessTree(child, signal = "SIGTERM", options = {}) {
|
|
6
|
+
if (!child?.pid) return false;
|
|
7
|
+
const platform = options.platform || process.platform;
|
|
8
|
+
if (platform === "win32") {
|
|
9
|
+
try {
|
|
10
|
+
const spawnProcess = typeof options.spawnProcess === "function" ? options.spawnProcess : spawn;
|
|
11
|
+
const force = signal === "SIGKILL";
|
|
12
|
+
const killer = spawnProcess("taskkill.exe", ["/PID", String(child.pid), "/T", ...(force ? ["/F"] : [])], {
|
|
13
|
+
stdio: "ignore",
|
|
14
|
+
windowsHide: true,
|
|
15
|
+
shell: false,
|
|
16
|
+
});
|
|
17
|
+
const fallback = () => {
|
|
18
|
+
try { child.kill(signal); } catch {}
|
|
19
|
+
};
|
|
20
|
+
killer?.once?.("error", fallback);
|
|
21
|
+
killer?.once?.("exit", (code) => { if (code !== 0) fallback(); });
|
|
22
|
+
killer?.unref?.();
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
// Fall through to ChildProcess.kill for environments without taskkill.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const killProcess = typeof options.killProcess === "function" ? options.killProcess : process.kill.bind(process);
|
|
30
|
+
try {
|
|
31
|
+
killProcess(-child.pid, signal);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
try {
|
|
35
|
+
return child.kill(signal) !== false;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function terminateProcessTreeWithEscalation(child, options = {}) {
|
|
43
|
+
const graceMs = Number.isFinite(Number(options.graceMs))
|
|
44
|
+
? Math.max(0, Number(options.graceMs))
|
|
45
|
+
: DEFAULT_PROCESS_TERMINATION_GRACE_MS;
|
|
46
|
+
const schedule = typeof options.setTimeout === "function" ? options.setTimeout : setTimeout;
|
|
47
|
+
const terminate = typeof options.terminate === "function" ? options.terminate : terminateProcessTree;
|
|
48
|
+
terminate(child, "SIGTERM", options);
|
|
49
|
+
return schedule(() => {
|
|
50
|
+
terminate(child, "SIGKILL", options);
|
|
51
|
+
options.onEscalated?.();
|
|
52
|
+
}, graceMs);
|
|
53
|
+
}
|
|
@@ -319,7 +319,11 @@ export class RelayConnection {
|
|
|
319
319
|
if (this.socket !== socket || this.closed) return;
|
|
320
320
|
this.lastInboundAt = this.now();
|
|
321
321
|
// Bind results to the generation that received this message.
|
|
322
|
-
const relayContext = {
|
|
322
|
+
const relayContext = {
|
|
323
|
+
sessionId: this.activeSessionId,
|
|
324
|
+
authenticated: this.authenticated === true,
|
|
325
|
+
ready: this.ready === true,
|
|
326
|
+
};
|
|
323
327
|
try {
|
|
324
328
|
const outcome = this.onMessage(data, relayContext);
|
|
325
329
|
if (outcome && typeof outcome.catch === "function") {
|
|
@@ -638,6 +642,12 @@ export function readinessMismatch(message, expectedServer = "", expectedVersion
|
|
|
638
642
|
return "";
|
|
639
643
|
}
|
|
640
644
|
|
|
645
|
+
export function isRelayReadyContext(relayContext = {}, relay = null) {
|
|
646
|
+
if (relayContext?.ready === true) return true;
|
|
647
|
+
if (relayContext?.ready === false) return false;
|
|
648
|
+
return Number(relayContext?.sessionId) > 0 && relay?.status?.()?.ready === true;
|
|
649
|
+
}
|
|
650
|
+
|
|
641
651
|
export function isSupersededClose(code, reason) {
|
|
642
652
|
return Number(code) === 1012 && String(reason || "") === "replaced by verified daemon";
|
|
643
653
|
}
|