machine-bridge-mcp 1.2.6 → 1.2.8

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.
@@ -0,0 +1,169 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ lstatSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ export const ACCEPTANCE_SCHEMA_VERSION = 1;
13
+ export const ACCEPTANCE_POLICY_VERSION = "1.2.8";
14
+ export const ACCEPTANCE_CONFIRMATION = "repository-owner-local-test";
15
+ const MAX_ACCEPTANCE_BYTES = 64 * 1024;
16
+
17
+ export function requiresLocalAcceptance(version) {
18
+ return compareVersions(parseVersion(version), parseVersion(ACCEPTANCE_POLICY_VERSION)) >= 0;
19
+ }
20
+
21
+ export function acceptancePath(root, version) {
22
+ return join(root, "release-acceptance", `v${version}.json`);
23
+ }
24
+
25
+ export function packProject(root, destination) {
26
+ const npmCli = process.env.npm_execpath;
27
+ if (!npmCli) {
28
+ throw new Error("release acceptance commands must run through npm so npm_execpath is available");
29
+ }
30
+ const result = spawnSync(process.execPath, [
31
+ npmCli,
32
+ "pack",
33
+ "--ignore-scripts",
34
+ "--silent",
35
+ "--json",
36
+ "--pack-destination",
37
+ destination,
38
+ ], {
39
+ cwd: root,
40
+ encoding: "utf8",
41
+ env: process.env,
42
+ windowsHide: true,
43
+ });
44
+ if (result.error) throw result.error;
45
+ if (result.status !== 0) {
46
+ throw new Error(`npm pack failed: ${String(result.stderr || result.stdout).trim()}`);
47
+ }
48
+ let value;
49
+ try {
50
+ value = JSON.parse(result.stdout);
51
+ } catch {
52
+ throw new Error("npm pack did not return valid JSON");
53
+ }
54
+ const pkg = readPackage(root);
55
+ const record = normalizePackRecord(value, pkg.name);
56
+ if (!record) throw new Error("npm pack did not return package metadata");
57
+ const metadata = {
58
+ package_name: pkg.name,
59
+ package_version: pkg.version,
60
+ filename: String(record.filename || ""),
61
+ shasum: String(record.shasum || ""),
62
+ integrity: String(record.integrity || ""),
63
+ };
64
+ validatePackMetadata(metadata);
65
+ verifyTarball(join(destination, metadata.filename), metadata);
66
+ return metadata;
67
+ }
68
+
69
+ export function readAcceptance(root, version) {
70
+ const path = acceptancePath(root, version);
71
+ const stat = lstatSync(path);
72
+ if (stat.isSymbolicLink() || !stat.isFile()) {
73
+ throw new Error(`release acceptance record must be a regular file: ${path}`);
74
+ }
75
+ if (stat.size > MAX_ACCEPTANCE_BYTES) {
76
+ throw new Error(`release acceptance record exceeds ${MAX_ACCEPTANCE_BYTES} bytes`);
77
+ }
78
+ let value;
79
+ try {
80
+ value = JSON.parse(readFileSync(path, "utf8"));
81
+ } catch (error) {
82
+ throw new Error(`release acceptance record is not valid JSON: ${error.message}`);
83
+ }
84
+ return value;
85
+ }
86
+
87
+ export function verifyAcceptanceRecord(record, metadata) {
88
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
89
+ throw new Error("release acceptance record must be an object");
90
+ }
91
+ if (record.schema_version !== ACCEPTANCE_SCHEMA_VERSION) {
92
+ throw new Error(`unsupported release acceptance schema: ${record.schema_version}`);
93
+ }
94
+ if (record.result !== "passed") throw new Error("local release acceptance result is not passed");
95
+ if (record.confirmation !== ACCEPTANCE_CONFIRMATION) {
96
+ throw new Error("local release acceptance confirmation is missing");
97
+ }
98
+ const acceptedAt = Date.parse(String(record.accepted_at || ""));
99
+ if (!Number.isFinite(acceptedAt)) throw new Error("local release acceptance timestamp is invalid");
100
+ for (const key of ["package_name", "package_version", "filename", "shasum", "integrity"]) {
101
+ if (record[key] !== metadata[key]) {
102
+ throw new Error(`local release acceptance ${key} does not match the current npm package`);
103
+ }
104
+ }
105
+ return record;
106
+ }
107
+
108
+ export function verifyCurrentReleaseAcceptance(root) {
109
+ const pkg = readPackage(root);
110
+ if (!requiresLocalAcceptance(pkg.version)) {
111
+ return { required: false, version: pkg.version };
112
+ }
113
+ const temp = mkdtempSync(join(tmpdir(), "mbm-release-acceptance-"));
114
+ try {
115
+ const metadata = packProject(root, temp);
116
+ const record = readAcceptance(root, pkg.version);
117
+ verifyAcceptanceRecord(record, metadata);
118
+ return { required: true, metadata, record };
119
+ } finally {
120
+ rmSync(temp, { recursive: true, force: true });
121
+ }
122
+ }
123
+
124
+ export function verifyTarball(path, metadata) {
125
+ const stat = lstatSync(path);
126
+ if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("release candidate tarball is not a regular file");
127
+ const bytes = readFileSync(path);
128
+ const shasum = createHash("sha1").update(bytes).digest("hex");
129
+ const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
130
+ if (shasum !== metadata.shasum || integrity !== metadata.integrity) {
131
+ throw new Error("release candidate tarball hash does not match npm pack metadata");
132
+ }
133
+ }
134
+
135
+ export function normalizePackRecord(value, packageName) {
136
+ if (Array.isArray(value)) return value[0] ?? null;
137
+ if (!value || typeof value !== "object") return null;
138
+ if (value[packageName] && typeof value[packageName] === "object") return value[packageName];
139
+ return Object.values(value).find((item) => item && typeof item === "object") ?? null;
140
+ }
141
+
142
+ function readPackage(root) {
143
+ const value = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
144
+ if (typeof value.name !== "string" || !value.name) throw new Error("package.json name is invalid");
145
+ if (!parseVersion(value.version)) throw new Error("package.json version is invalid");
146
+ return value;
147
+ }
148
+
149
+ function validatePackMetadata(metadata) {
150
+ if (!metadata.filename.endsWith(".tgz") || metadata.filename.includes("/") || metadata.filename.includes("\\")) {
151
+ throw new Error("npm pack returned an invalid filename");
152
+ }
153
+ if (!/^[0-9a-f]{40}$/.test(metadata.shasum)) throw new Error("npm pack returned an invalid SHA-1 shasum");
154
+ if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(metadata.integrity)) throw new Error("npm pack returned an invalid SHA-512 integrity value");
155
+ }
156
+
157
+ function parseVersion(value) {
158
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(String(value || ""));
159
+ if (!match) return null;
160
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
161
+ }
162
+
163
+ function compareVersions(left, right) {
164
+ if (!left || !right) throw new Error("version comparison requires semantic versions");
165
+ for (let index = 0; index < 3; index += 1) {
166
+ if (left[index] !== right[index]) return left[index] - right[index];
167
+ }
168
+ return 0;
169
+ }
@@ -25,20 +25,36 @@ const changed = new Set([
25
25
  ...lines(git(["ls-files", "--others", "--exclude-standard"])),
26
26
  ]);
27
27
  const relevant = [...changed].sort();
28
+ const packageRelevant = relevant.filter((path) => isPackageRelevant(path, pkg.files));
28
29
 
29
30
  if (!relevant.length) {
30
- process.stderr.write(`release impact check ok: no release-relevant changes since ${latestTag}\n`);
31
+ process.stderr.write(`release impact check ok: no repository changes since ${latestTag}\n`);
32
+ process.exit(0);
33
+ }
34
+ if (!packageRelevant.length) {
35
+ process.stderr.write(`release impact check ok: ${relevant.length} repository-only file(s) changed since ${latestTag}; npm package content is unchanged\n`);
31
36
  process.exit(0);
32
37
  }
33
38
  if (compareVersions(current, latest) <= 0) {
34
- fail(`release-relevant changes exist since ${latestTag}, but package.json is still ${pkg.version}; bump the npm version and add a CHANGELOG section before merging`);
39
+ fail(`npm-package changes exist since ${latestTag}, but package.json is still ${pkg.version}; bump the npm version and add a CHANGELOG section before merging`);
35
40
  }
36
41
 
37
42
  const changelog = readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8");
38
43
  const heading = new RegExp(`^## ${escapeRegExp(pkg.version)}(?:\\s+-[^\\n]*)?$`, "m");
39
44
  if (!heading.test(changelog)) fail(`CHANGELOG.md has no section for ${pkg.version}`);
40
45
 
41
- process.stderr.write(`release impact check ok: ${relevant.length} release-relevant file(s) since ${latestTag}; next npm version ${pkg.version}\n`);
46
+ process.stderr.write(`release impact check ok: ${packageRelevant.length} npm-package file(s) changed since ${latestTag}; next npm version ${pkg.version}\n`);
47
+
48
+ export function isPackageRelevant(path, packageFiles = []) {
49
+ const normalized = String(path || "").replaceAll("\\", "/").replace(/^\.\//, "");
50
+ if (!normalized) return false;
51
+ if (["package.json", "package-lock.json", "npm-shrinkwrap.json"].includes(normalized)) return true;
52
+ for (const entry of packageFiles || []) {
53
+ const candidate = String(entry || "").replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
54
+ if (candidate && (normalized === candidate || normalized.startsWith(`${candidate}/`))) return true;
55
+ }
56
+ return false;
57
+ }
42
58
 
43
59
  function git(args) {
44
60
  try {
@@ -14,13 +14,14 @@ import {
14
14
  } from "./browser-pairing-store.mjs";
15
15
  import { clampInt } from "./browser-command.mjs";
16
16
  import { BrowserOperationService } from "./browser-operation-service.mjs";
17
+ import { classifyOperationalError } from "./log.mjs";
17
18
 
18
19
  const MAX_PORT_ATTEMPTS = 10;
19
20
  const MAX_PENDING = 32;
20
21
  const EXTENSION_HANDSHAKE_MS = 3_000;
21
22
 
22
23
  export class BrowserBridgeManager {
23
- constructor({ policy, authorizeTool = null, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {} }) {
24
+ constructor({ policy, authorizeTool = null, stateRoot = "", runProcess, readResourceText, readResourceBinary, throwIfCancelled = () => {}, logger = null }) {
24
25
  this.policy = policy || {};
25
26
  this.authorizeTool = createToolAuthorizer(this.policy, authorizeTool);
26
27
  this.stateRoot = stateRoot ? resolve(stateRoot) : "";
@@ -28,6 +29,7 @@ export class BrowserBridgeManager {
28
29
  this.readResourceText = readResourceText;
29
30
  this.readResourceBinary = readResourceBinary;
30
31
  this.throwIfCancelled = throwIfCancelled;
32
+ this.logger = logger || { event() {} };
31
33
  this.server = null;
32
34
  this.wss = null;
33
35
  this.socket = null;
@@ -44,6 +46,7 @@ export class BrowserBridgeManager {
44
46
  this.stopping = false;
45
47
  this.pending = new Map();
46
48
  this.startPromise = null;
49
+ this.startGeneration = 0;
47
50
  this.port = 0;
48
51
  this.token = "";
49
52
  this.extensionPath = resolve(packageRoot, "browser-extension");
@@ -124,30 +127,59 @@ export class BrowserBridgeManager {
124
127
  if (this.stateRoot) assertStateMaintenanceAvailable(this.stateRoot);
125
128
  this.stopping = false;
126
129
  if (this.server || this.upstream?.readyState === 1) return;
127
- if (!this.startPromise) this.startPromise = this.start();
128
- try { await this.startPromise; } finally { this.startPromise = null; }
130
+ if (!this.startPromise) {
131
+ const generation = ++this.startGeneration;
132
+ this.startPromise = this.start(generation);
133
+ }
134
+ const pending = this.startPromise;
135
+ try { await pending; } finally {
136
+ if (this.startPromise === pending) this.startPromise = null;
137
+ }
129
138
  }
130
139
 
131
- async start() {
132
- const pairing = await loadOrCreatePairing(this.stateRoot);
133
- this.token = pairing.token;
134
- for (let offset = 0; offset < MAX_PORT_ATTEMPTS; offset += 1) {
135
- const port = pairing.port + offset;
136
- try {
137
- await this.listen(port);
138
- if (port !== pairing.port && this.stateRoot) await savePairing(this.stateRoot, { token: this.token, port });
139
- return;
140
- } catch (error) {
141
- if (error?.code !== "EADDRINUSE") throw error;
142
- if (await this.connectProxy(port)) {
143
- this.port = port;
140
+ async start(generation = this.startGeneration) {
141
+ try {
142
+ const pairing = await loadOrCreatePairing(this.stateRoot);
143
+ this.assertStartCurrent(generation);
144
+ this.token = pairing.token;
145
+ for (let offset = 0; offset < MAX_PORT_ATTEMPTS; offset += 1) {
146
+ const port = pairing.port + offset;
147
+ try {
148
+ await this.listen(port);
149
+ this.assertStartCurrent(generation);
150
+ if (port !== pairing.port && this.stateRoot) {
151
+ await savePairing(this.stateRoot, { token: this.token, port });
152
+ this.assertStartCurrent(generation);
153
+ }
144
154
  return;
155
+ } catch (error) {
156
+ this.assertStartCurrent(generation);
157
+ if (error?.code !== "EADDRINUSE") throw error;
158
+ if (await this.connectProxy(port, generation)) {
159
+ this.assertStartCurrent(generation);
160
+ this.port = port;
161
+ return;
162
+ }
163
+ this.assertStartCurrent(generation);
164
+ if (offset === MAX_PORT_ATTEMPTS - 1) throw error;
145
165
  }
146
- if (offset === MAX_PORT_ATTEMPTS - 1) throw error;
147
166
  }
167
+ } catch (error) {
168
+ const cancelled = !this.isStartCurrent(generation);
169
+ this.closeBrokerTransports(cancelled ? "browser bridge start cancelled" : "browser bridge start failed");
170
+ if (cancelled) throw new Error("browser bridge start cancelled");
171
+ throw error;
148
172
  }
149
173
  }
150
174
 
175
+ isStartCurrent(generation) {
176
+ return !this.stopping && generation === this.startGeneration;
177
+ }
178
+
179
+ assertStartCurrent(generation) {
180
+ if (!this.isStartCurrent(generation)) throw new Error("browser bridge start cancelled");
181
+ }
182
+
151
183
  async listen(port) {
152
184
  this.port = port;
153
185
  const server = createServer((request, response) => this.handleHttp(request, response));
@@ -192,7 +224,7 @@ export class BrowserBridgeManager {
192
224
  this.wss = wss;
193
225
  }
194
226
 
195
- async connectProxy(port) {
227
+ async connectProxy(port, generation = this.startGeneration) {
196
228
  const url = `ws://127.0.0.1:${port}/runtime`;
197
229
  return new Promise((resolvePromise) => {
198
230
  let settled = false;
@@ -220,6 +252,11 @@ export class BrowserBridgeManager {
220
252
  closeProtocolSocket(ws, 1002, "runtime hello required");
221
253
  return;
222
254
  }
255
+ if (!this.isStartCurrent(generation)) {
256
+ try { ws.close(1001, "runtime stopped"); } catch {}
257
+ finish(false);
258
+ return;
259
+ }
223
260
  this.upstream = ws;
224
261
  const claimedExtension = message.extension_connected === true;
225
262
  this.proxyExtensionInfo = claimedExtension ? normalizeCompatibleExtensionInfo(message.extension_info) : null;
@@ -450,7 +487,11 @@ export class BrowserBridgeManager {
450
487
  if (this.stopping || this.recoveryTimer) return;
451
488
  this.recoveryTimer = setTimeout(() => {
452
489
  this.recoveryTimer = null;
453
- void this.ensureStarted().catch(() => this.scheduleBrokerRecovery());
490
+ void this.ensureStarted().catch((error) => {
491
+ this.logger.event?.("debug", "browser.broker.recovery_failed", { error_class: classifyOperationalError(error) },
492
+ "browser broker recovery failed; retrying");
493
+ this.scheduleBrokerRecovery();
494
+ });
454
495
  }, 250);
455
496
  this.recoveryTimer.unref?.();
456
497
  }
@@ -525,9 +566,15 @@ export class BrowserBridgeManager {
525
566
 
526
567
  stop() {
527
568
  this.stopping = true;
569
+ this.startGeneration += 1;
528
570
  clearTimeout(this.recoveryTimer);
529
571
  this.recoveryTimer = null;
530
- this.rejectPending("browser bridge stopped");
572
+ this.closeBrokerTransports("browser bridge stopped");
573
+ }
574
+
575
+ closeBrokerTransports(message) {
576
+ this.rejectPending(message);
577
+ this.rejectProxyRoutes(message);
531
578
  try { this.upstream?.close(1001, "runtime stopped"); } catch {}
532
579
  try { this.socket?.close(1001, "runtime stopped"); } catch {}
533
580
  try { this.pendingExtensionSocket?.close(1001, "runtime stopped"); } catch {}
@@ -541,8 +588,6 @@ export class BrowserBridgeManager {
541
588
  this.proxyExtensionReloadRequired = false;
542
589
  this.extensionReloadRequiredFlag = false;
543
590
  this.runtimeClients.clear();
544
- for (const route of this.proxyRoutes.values()) clearTimeout(route.timeout);
545
- this.proxyRoutes.clear();
546
591
  try { this.wss?.close(); } catch {}
547
592
  try { this.server?.close(); } catch {}
548
593
  this.wss = null;
@@ -10,6 +10,7 @@ import {
10
10
  acquireStartupLockWithWait, expandHome, loadState, ownerOnlyFile, packageRoot, saveState,
11
11
  } from "./state.mjs";
12
12
  import { resolvePolicy } from "./cli-policy.mjs";
13
+ import { readLoopbackJson } from "./loopback-health.mjs";
13
14
 
14
15
  export function createLocalAdminCommands(dependencies) {
15
16
  const chooseWorkspace = dependencies.chooseWorkspace;
@@ -249,9 +250,7 @@ function readBrowserPairingState(pairingFile) {
249
250
  }
250
251
 
251
252
  function readBrowserHealth(healthUrl) {
252
- return fetch(healthUrl, { signal: AbortSignal.timeout(2000), cache: "no-store" })
253
- .then(async (response) => response.ok ? await response.json() : null)
254
- .catch(() => null);
253
+ return readLoopbackJson(healthUrl, { pathname: "/healthz" });
255
254
  }
256
255
 
257
256
  function browserStatusResult(health, extensionPath, pairingUrl) {
@@ -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
  }
@@ -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
+ }