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.
@@ -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 Set();
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.add(Number(owner.pid));
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 liveSignalled = [...signalled].filter((pid) => isPidAlive(pid));
97
- const currentOwnerAlive = Boolean(owner?.pid && isPidAlive(owner.pid));
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 matches = argv.includes("--daemon-only")
206
+ const explicitIdentity = Boolean(workspaceArg && stateRootArg)
172
207
  && sameCanonicalPath(workspaceArg, state.workspace.path)
173
- && sameCanonicalPath(stateRootArg, state.paths.stateRoot)
174
- && entryMatches;
175
- return { verified_service_daemon: matches, reason: matches ? "service_command" : "command_mismatch" };
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
+ }
@@ -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 { terminateProcessTree } from "./process-sessions.mjs";
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
- terminateProcessTree(child, "SIGTERM");
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 = setTimeout(() => {
480
- terminateProcessTree(child, "SIGKILL");
481
- cancellationEscalation = null;
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
- child = spawn(process.execPath, args, {
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 child.pid;
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, terminateProcessTreeWithEscalation, validateArgv } from "./process-sessions.mjs";
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
- const MAX_STDIN_BYTES = 1024 * 1024;
10
- const DEFAULT_OUTPUT_BYTES = 512 * 1024;
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, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context, cwd);
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, 1, 600);
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, DEFAULT_OUTPUT_BYTES, context, cwd);
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, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context);
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 = DEFAULT_OUTPUT_BYTES, context = {}, cwd = this.workspace, stdin = null) {
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)) > MAX_STDIN_BYTES) {
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
- export const MAX_COMMAND_BYTES = 64 * 1024;
10
- const MAX_ARGV_ITEMS = 256;
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) > MAX_PROCESS_STDIN_BYTES) throw new Error(`stdin data exceeds maximum size (${MAX_PROCESS_STDIN_BYTES} bytes)`);
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
- if (wasRunning) terminateProcessTree(session.child, args.force === true ? "SIGKILL" : "SIGTERM");
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 { ...this.summary(session), termination_requested: wasRunning, force: args.force === true };
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 > MAX_SESSION_OUTPUT_BYTES) {
277
- const dropped = combined.length - MAX_SESSION_OUTPUT_BYTES;
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
  }
@@ -1,4 +1,4 @@
1
- import { terminateProcessTree, terminateProcessTreeWithEscalation } from "./process-sessions.mjs";
1
+ import { terminateProcessTree, terminateProcessTreeWithEscalation } from "./process-tree.mjs";
2
2
 
3
3
  export class ProcessTracker {
4
4
  constructor(options = {}) {
@@ -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 = { sessionId: this.activeSessionId, authenticated: this.authenticated, ready: this.ready };
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
  }