machine-bridge-mcp 1.2.9 → 1.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +1 -1
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +15 -8
  6. package/docs/AUDIT.md +22 -0
  7. package/docs/LOGGING.md +4 -2
  8. package/docs/MULTI_ACCOUNT.md +2 -2
  9. package/docs/OPERATIONS.md +5 -4
  10. package/docs/RELEASING.md +1 -1
  11. package/docs/TESTING.md +5 -1
  12. package/docs/TOOL_REFERENCE.md +4 -4
  13. package/package.json +6 -2
  14. package/scripts/check-plan.mjs +3 -0
  15. package/scripts/check-runner.mjs +75 -0
  16. package/scripts/coverage-check.mjs +1 -1
  17. package/scripts/github-backlog.mjs +116 -0
  18. package/scripts/github-push.mjs +4 -0
  19. package/scripts/release-acceptance.mjs +29 -13
  20. package/scripts/run-checks.mjs +11 -21
  21. package/src/local/bounded-output.mjs +20 -3
  22. package/src/local/errors.mjs +5 -1
  23. package/src/local/execution-limits.mjs +6 -1
  24. package/src/local/process-execution.mjs +94 -14
  25. package/src/local/process-output-stream.mjs +82 -0
  26. package/src/local/process-result-projection.mjs +25 -0
  27. package/src/local/process-sessions.mjs +37 -51
  28. package/src/local/relay-call-recovery.mjs +148 -0
  29. package/src/local/relay-connection.mjs +5 -0
  30. package/src/local/runtime-relay.mjs +16 -0
  31. package/src/local/runtime.mjs +60 -39
  32. package/src/local/secure-file.mjs +24 -4
  33. package/src/local/tools.mjs +4 -9
  34. package/src/shared/result-projection.d.mts +6 -0
  35. package/src/shared/result-projection.json +4 -0
  36. package/src/shared/result-projection.mjs +50 -0
  37. package/src/shared/server-metadata.json +1 -0
  38. package/src/shared/tool-catalog.json +4 -4
  39. package/src/worker/daemon-sockets.ts +10 -1
  40. package/src/worker/errors.ts +18 -4
  41. package/src/worker/index.ts +45 -9
  42. package/src/worker/mcp-jsonrpc.ts +4 -2
  43. package/src/worker/pending-call-contract.ts +26 -0
  44. package/src/worker/pending-calls.ts +41 -25
  45. package/tsconfig.local.json +1 -0
@@ -1,6 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import {
3
- lstatSync,
4
3
  mkdtempSync,
5
4
  readFileSync,
6
5
  rmSync,
@@ -8,6 +7,7 @@ import {
8
7
  import { tmpdir } from "node:os";
9
8
  import { join } from "node:path";
10
9
  import { spawnSync } from "node:child_process";
10
+ import { readBoundedRegularFileSync } from "../src/local/secure-file.mjs";
11
11
 
12
12
  export const ACCEPTANCE_SCHEMA_VERSION = 1;
13
13
  export const ACCEPTANCE_POLICY_VERSION = "1.2.8";
@@ -15,6 +15,7 @@ export const AGENT_VERIFIED_ACCEPTANCE_VERSION = "1.2.9";
15
15
  export const ACCEPTANCE_CONFIRMATION = "owner-started-agent-verified-local-candidate";
16
16
  export const LEGACY_ACCEPTANCE_CONFIRMATION = "repository-owner-local-test";
17
17
  const MAX_ACCEPTANCE_BYTES = 64 * 1024;
18
+ const MAX_RELEASE_TARBALL_BYTES = 64 * 1024 * 1024;
18
19
 
19
20
  export function requiresLocalAcceptance(version) {
20
21
  return compareVersions(parseVersion(version), parseVersion(ACCEPTANCE_POLICY_VERSION)) >= 0;
@@ -76,20 +77,25 @@ export function packProject(root, destination) {
76
77
 
77
78
  export function readAcceptance(root, version) {
78
79
  const path = acceptancePath(root, version);
79
- const stat = lstatSync(path);
80
- if (stat.isSymbolicLink() || !stat.isFile()) {
81
- throw new Error(`release acceptance record must be a regular file: ${path}`);
82
- }
83
- if (stat.size > MAX_ACCEPTANCE_BYTES) {
84
- throw new Error(`release acceptance record exceeds ${MAX_ACCEPTANCE_BYTES} bytes`);
80
+ let bytes;
81
+ try {
82
+ bytes = readBoundedRegularFileSync(
83
+ path, MAX_ACCEPTANCE_BYTES, "release acceptance record",
84
+ { verifyPathIdentity: true },
85
+ );
86
+ } catch (error) {
87
+ const message = String(error?.message || error);
88
+ if (message.includes("exceeds")) throw new Error(`release acceptance record exceeds ${MAX_ACCEPTANCE_BYTES} bytes`);
89
+ if (message.includes("regular file") || message.includes("symbolic link") || message.includes("identity changed")) {
90
+ throw new Error(`release acceptance record must be a regular file: ${path}`);
91
+ }
92
+ throw error;
85
93
  }
86
- let value;
87
94
  try {
88
- value = JSON.parse(readFileSync(path, "utf8"));
95
+ return JSON.parse(bytes.toString("utf8"));
89
96
  } catch (error) {
90
97
  throw new Error(`release acceptance record is not valid JSON: ${error.message}`);
91
98
  }
92
- return value;
93
99
  }
94
100
 
95
101
  export function verifyAcceptanceRecord(record, metadata) {
@@ -134,9 +140,19 @@ export function verifyCurrentReleaseAcceptance(root) {
134
140
  }
135
141
 
136
142
  export function verifyTarball(path, metadata) {
137
- const stat = lstatSync(path);
138
- if (stat.isSymbolicLink() || !stat.isFile()) throw new Error("release candidate tarball is not a regular file");
139
- const bytes = readFileSync(path);
143
+ let bytes;
144
+ try {
145
+ bytes = readBoundedRegularFileSync(
146
+ path, MAX_RELEASE_TARBALL_BYTES, "release candidate tarball",
147
+ { verifyPathIdentity: true },
148
+ );
149
+ } catch (error) {
150
+ const message = String(error?.message || error);
151
+ if (message.includes("regular file") || message.includes("symbolic link") || message.includes("identity changed")) {
152
+ throw new Error("release candidate tarball is not a regular file");
153
+ }
154
+ throw error;
155
+ }
140
156
  const shasum = createHash("sha1").update(bytes).digest("hex");
141
157
  const integrity = `sha512-${createHash("sha512").update(bytes).digest("base64")}`;
142
158
  if (shasum !== metadata.shasum || integrity !== metadata.integrity) {
@@ -1,29 +1,19 @@
1
- import { spawnSync } from "node:child_process";
2
- import { performance } from "node:perf_hooks";
3
1
  import { checkTasks } from "./check-plan.mjs";
2
+ import { runVerificationPlan } from "./check-runner.mjs";
4
3
 
5
4
  const mode = process.argv[2] || "full";
6
5
  const tasks = checkTasks(mode);
7
- const npmCli = process.env.npm_execpath;
8
- if (!npmCli) throw new Error("check runner must run through npm so npm_execpath is available");
9
- const planStartedAt = performance.now();
10
6
 
11
- console.log(`running ${mode} verification plan (${tasks.length} tasks)`);
12
- for (const [index, task] of tasks.entries()) {
13
- const taskStartedAt = performance.now();
14
- console.log(`\n[${index + 1}/${tasks.length}] npm run ${task}`);
15
- const result = spawnSync(process.execPath, [npmCli, "run", task], {
16
- cwd: process.cwd(),
17
- env: process.env,
18
- stdio: "inherit",
7
+ try {
8
+ await runVerificationPlan({
9
+ mode,
10
+ tasks,
11
+ npmCli: process.env.npm_execpath,
12
+ verbose: process.env.MBM_CHECK_VERBOSE === "1",
19
13
  });
20
- const elapsedSeconds = ((performance.now() - taskStartedAt) / 1000).toFixed(1);
21
- if (result.error) throw result.error;
22
- if (result.status !== 0) {
23
- console.error(`verification task failed after ${elapsedSeconds}s: ${task}`);
24
- process.exit(result.status || 1);
14
+ } catch (error) {
15
+ if (error?.message && !String(error.message).startsWith("verification task failed:")) {
16
+ console.error(error.message);
25
17
  }
26
- console.log(`completed ${task} in ${elapsedSeconds}s`);
18
+ process.exit(Number(error?.exitCode) || 1);
27
19
  }
28
- const totalSeconds = ((performance.now() - planStartedAt) / 1000).toFixed(1);
29
- console.log(`\n${mode} verification plan passed in ${totalSeconds}s`);
@@ -39,12 +39,29 @@ export class BoundedOutput {
39
39
  }
40
40
 
41
41
  get truncatedBytes() {
42
- return Math.max(0, this.totalBytes - this.maximum);
42
+ if (this.full) return 0;
43
+ const head = decodeUtf8Boundary(this.head, "head");
44
+ const tail = decodeUtf8Boundary(this.tail, "tail");
45
+ return Math.max(0, this.totalBytes - head.bytes - tail.bytes);
43
46
  }
44
47
 
45
48
  text() {
46
49
  if (this.full) return this.full.toString("utf8");
47
- const marker = `\n\n[truncated ${this.truncatedBytes} bytes; preserved beginning and end]\n\n`;
48
- return `${this.head.toString("utf8")}${marker}${this.tail.toString("utf8")}`;
50
+ const head = decodeUtf8Boundary(this.head, "head");
51
+ const tail = decodeUtf8Boundary(this.tail, "tail");
52
+ const omitted = Math.max(0, this.totalBytes - head.bytes - tail.bytes);
53
+ const marker = `\n\n[truncated ${omitted} bytes; preserved beginning and end]\n\n`;
54
+ return `${head.text}${marker}${tail.text}`;
49
55
  }
50
56
  }
57
+
58
+ function decodeUtf8Boundary(buffer, side) {
59
+ const decoder = new TextDecoder("utf-8", { fatal: true });
60
+ for (let trim = 0; trim <= Math.min(3, buffer.length); trim += 1) {
61
+ const slice = side === "head"
62
+ ? buffer.subarray(0, buffer.length - trim)
63
+ : buffer.subarray(trim);
64
+ try { return { text: decoder.decode(slice), bytes: slice.length }; } catch { /* try the next code-point boundary */ }
65
+ }
66
+ return { text: new TextDecoder("utf-8").decode(buffer), bytes: buffer.length };
67
+ }
@@ -54,6 +54,7 @@ export function publicError(error, options = {}) {
54
54
  code: normalized.code,
55
55
  message: normalized.expose ? normalized.message : defaultMessage(normalized.code),
56
56
  retryable: normalized.retryable,
57
+ ...(normalized.expose && normalized.details ? { details: normalized.details } : {}),
57
58
  };
58
59
  }
59
60
 
@@ -61,7 +62,10 @@ export function remoteBridgeError(value, fallbackMessage = "remote operation fai
61
62
  if (isRecord(value)) {
62
63
  const code = normalizeErrorCode(value.code);
63
64
  const message = typeof value.message === "string" && value.message ? value.message : fallbackMessage;
64
- return new BridgeError(code, message, { retryable: value.retryable === true });
65
+ return new BridgeError(code, message, {
66
+ retryable: value.retryable === true,
67
+ details: isRecord(value.details) ? value.details : undefined,
68
+ });
65
69
  }
66
70
  return new BridgeError("execution_failed", fallbackMessage);
67
71
  }
@@ -3,6 +3,7 @@ export const MAX_PROCESS_SESSIONS = 8;
3
3
  export const MIN_PROCESS_TIMEOUT_SECONDS = 1;
4
4
  export const MAX_PROCESS_TIMEOUT_SECONDS = 600;
5
5
  export const DEFAULT_PROCESS_OUTPUT_BYTES = 512 * 1024;
6
+ export const PUBLIC_PROCESS_INLINE_OUTPUT_BYTES = 32 * 1024;
6
7
  export const MAX_PROCESS_STDIN_BYTES = 1024 * 1024;
7
8
  export const MAX_PROCESS_SESSION_OUTPUT_BYTES = 1024 * 1024;
8
9
  export const MAX_PROCESS_SESSION_STDIN_BYTES = 64 * 1024;
@@ -16,7 +17,11 @@ export function executionGuardrailsSnapshot() {
16
17
  one_shot_processes: {
17
18
  timeout_seconds: { minimum: MIN_PROCESS_TIMEOUT_SECONDS, maximum: MAX_PROCESS_TIMEOUT_SECONDS },
18
19
  stdin_max_bytes: MAX_PROCESS_STDIN_BYTES,
19
- output_max_bytes_per_stream: DEFAULT_PROCESS_OUTPUT_BYTES,
20
+ output_max_bytes_per_stream: PUBLIC_PROCESS_INLINE_OUTPUT_BYTES,
21
+ inline_output_max_bytes_per_stream: PUBLIC_PROCESS_INLINE_OUTPUT_BYTES,
22
+ retained_output_max_bytes_per_stream: MAX_PROCESS_SESSION_OUTPUT_BYTES,
23
+ continuation_tool: "read_process",
24
+ continuation_retention: "best-effort-until-session-expiry-or-capacity-eviction",
20
25
  process_tree_termination: "sigterm-then-sigkill",
21
26
  },
22
27
  process_sessions: {
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { basename } from "node:path";
2
3
  import { stat } from "node:fs/promises";
3
4
  import { BoundedOutput } from "./bounded-output.mjs";
4
5
  import { executionEnv, workspaceShellCommand } from "./shell.mjs";
@@ -6,10 +7,21 @@ import { MAX_COMMAND_BYTES, validateArgv } from "./process-contract.mjs";
6
7
  import { terminateProcessTreeWithEscalation } from "./process-tree.mjs";
7
8
  import { BridgeError } from "./errors.mjs";
8
9
  import { clampInteger } from "./numbers.mjs";
10
+ import { ProcessOutputStream } from "./process-output-stream.mjs";
11
+ import { processFailureMessage, publicProcessToolResult } from "./process-result-projection.mjs";
9
12
  import {
10
- DEFAULT_PROCESS_OUTPUT_BYTES, MAX_PROCESS_STDIN_BYTES, MAX_PROCESS_TIMEOUT_SECONDS, MIN_PROCESS_TIMEOUT_SECONDS,
13
+ DEFAULT_PROCESS_OUTPUT_BYTES,
14
+ MAX_PROCESS_SESSION_OUTPUT_BYTES,
15
+ MAX_PROCESS_STDIN_BYTES,
16
+ MAX_PROCESS_TIMEOUT_SECONDS,
17
+ MIN_PROCESS_TIMEOUT_SECONDS,
18
+ PROCESS_SESSION_RETENTION_MS,
19
+ PUBLIC_PROCESS_INLINE_OUTPUT_BYTES,
11
20
  } from "./execution-limits.mjs";
12
21
 
22
+ const PROCESS_OUTPUT_CAPTURE = Symbol("process-output-capture");
23
+ const CONTINUATION_READ_BYTES = 64 * 1024;
24
+
13
25
  function spawnDirectProcess(command, args, options) {
14
26
  // Keep the production child_process API call structurally separate from the
15
27
  // injectable test seam and enforce non-shell execution at the final boundary.
@@ -23,7 +35,7 @@ function spawnDirectProcess(command, args, options) {
23
35
  }
24
36
 
25
37
  export class ProcessExecutionService {
26
- constructor({ workspace, policy, policyGate, runtimeDir, processTracker, resolveExistingPath, resolveLocalCommand, displayPath, throwIfCancelled, spawnProcess = spawnDirectProcess, terminateProcess = terminateProcessTreeWithEscalation }) {
38
+ constructor({ workspace, policy, policyGate, runtimeDir, processTracker, resolveExistingPath, resolveLocalCommand, displayPath, throwIfCancelled, retainCompletedOutput = null, spawnProcess = spawnDirectProcess, terminateProcess = terminateProcessTreeWithEscalation }) {
27
39
  this.workspace = workspace;
28
40
  this.policy = policy;
29
41
  this.policyGate = policyGate;
@@ -33,6 +45,7 @@ export class ProcessExecutionService {
33
45
  this.resolveLocalCommand = resolveLocalCommand;
34
46
  this.displayPath = displayPath;
35
47
  this.throwIfCancelled = throwIfCancelled;
48
+ this.retainCompletedOutput = typeof retainCompletedOutput === "function" ? retainCompletedOutput : null;
36
49
  this.spawnProcess = spawnProcess;
37
50
  this.terminateProcess = terminateProcess;
38
51
  }
@@ -42,7 +55,12 @@ export class ProcessExecutionService {
42
55
  const argv = validateArgv(args.argv);
43
56
  const cwd = await this.resolveExistingPath(args.cwd || ".");
44
57
  if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "cwd is not a directory");
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);
58
+ const result = await this.runPublic(
59
+ argv[0], argv.slice(1),
60
+ clampInteger(args.timeout_seconds, 120, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS) * 1000,
61
+ context, cwd,
62
+ );
63
+ return publicProcessToolResult(result);
46
64
  }
47
65
 
48
66
  async runRegistered(args, context = {}) {
@@ -55,8 +73,8 @@ export class ProcessExecutionService {
55
73
  ? command.timeoutSeconds
56
74
  : clampInteger(args.timeout_seconds, command.timeoutSeconds, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS);
57
75
  const timeoutSeconds = Math.min(requested, command.timeoutSeconds);
58
- const result = await this.run(argv[0], argv.slice(1), timeoutSeconds * 1000, false, DEFAULT_PROCESS_OUTPUT_BYTES, context, cwd);
59
- return { name: command.name, cwd: this.displayPath(cwd), timeout_seconds: timeoutSeconds, ...result };
76
+ const result = await this.runPublic(argv[0], argv.slice(1), timeoutSeconds * 1000, context, cwd);
77
+ return publicProcessToolResult({ name: command.name, cwd: this.displayPath(cwd), timeout_seconds: timeoutSeconds, ...result });
60
78
  }
61
79
 
62
80
  async probeShell(context = {}) {
@@ -70,14 +88,61 @@ export class ProcessExecutionService {
70
88
  if (command.includes("\0")) throw new BridgeError("invalid_request", "command contains a NUL byte");
71
89
  if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new BridgeError("limit_exceeded", `command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
72
90
  const shell = workspaceShellCommand(command);
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);
91
+ const result = await this.runPublic(
92
+ shell.cmd, shell.args,
93
+ clampInteger(timeoutSeconds, 120, MIN_PROCESS_TIMEOUT_SECONDS, MAX_PROCESS_TIMEOUT_SECONDS) * 1000,
94
+ context, this.workspace,
95
+ );
96
+ return publicProcessToolResult(result);
74
97
  }
75
98
 
76
99
  terminateAll(signal = "SIGTERM", escalate = false) {
77
100
  this.processTracker.terminateAll(signal, escalate);
78
101
  }
79
102
 
80
- async run(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = DEFAULT_PROCESS_OUTPUT_BYTES, context = {}, cwd = this.workspace, stdin = null) {
103
+ async runPublic(cmd, args, timeoutMs, context, cwd) {
104
+ const startedAt = Date.now();
105
+ const result = await this.run(
106
+ cmd, args, timeoutMs, true, PUBLIC_PROCESS_INLINE_OUTPUT_BYTES,
107
+ context, cwd, null, { retainOutput: true },
108
+ );
109
+ const capture = result[PROCESS_OUTPUT_CAPTURE];
110
+ const needsContinuation = result.stdout_truncated_bytes > 0 || result.stderr_truncated_bytes > 0;
111
+ let continuation = {};
112
+ if (needsContinuation && capture && this.retainCompletedOutput) {
113
+ const session = this.retainCompletedOutput({
114
+ command: basename(cmd), cwd,
115
+ stdout: capture.stdout, stderr: capture.stderr,
116
+ exitCode: result.code, startedAt, closedAt: Date.now(),
117
+ });
118
+ if (session) {
119
+ continuation = {
120
+ output_session_id: session.session_id,
121
+ output_continuation: {
122
+ tool: "read_process",
123
+ session_id: session.session_id,
124
+ stdout_offset: 0,
125
+ stderr_offset: 0,
126
+ max_bytes: CONTINUATION_READ_BYTES,
127
+ retained_bytes_per_stream: MAX_PROCESS_SESSION_OUTPUT_BYTES,
128
+ expires_after_ms: PROCESS_SESSION_RETENTION_MS,
129
+ retention: "best-effort-until-session-expiry-or-capacity-eviction",
130
+ },
131
+ };
132
+ } else {
133
+ continuation = { output_continuation_unavailable: "process session capacity reached" };
134
+ }
135
+ }
136
+ const publicResult = { ...result, ...continuation };
137
+ if (result.code !== 0) {
138
+ throw new BridgeError("execution_failed", processFailureMessage(publicResult), {
139
+ details: { process: publicResult },
140
+ });
141
+ }
142
+ return publicResult;
143
+ }
144
+
145
+ async run(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = DEFAULT_PROCESS_OUTPUT_BYTES, context = {}, cwd = this.workspace, stdin = null, options = {}) {
81
146
  this.throwIfCancelled(context);
82
147
  if (stdin !== null && Buffer.byteLength(String(stdin)) > MAX_PROCESS_STDIN_BYTES) {
83
148
  throw new BridgeError("limit_exceeded", "process stdin exceeds 1 MiB");
@@ -86,6 +151,8 @@ export class ProcessExecutionService {
86
151
  return new Promise((resolvePromise, rejectPromise) => {
87
152
  const stdout = new BoundedOutput(maxOutputBytes);
88
153
  const stderr = new BoundedOutput(maxOutputBytes);
154
+ const retainedStdout = options.retainOutput ? new ProcessOutputStream(MAX_PROCESS_SESSION_OUTPUT_BYTES) : null;
155
+ const retainedStderr = options.retainOutput ? new ProcessOutputStream(MAX_PROCESS_SESSION_OUTPUT_BYTES) : null;
89
156
  let child;
90
157
  try {
91
158
  child = this.spawnProcess(cmd, args, {
@@ -147,13 +214,19 @@ export class ProcessExecutionService {
147
214
  }, timeoutMs);
148
215
  timeoutTimer.unref?.();
149
216
 
150
- child.stdout?.on?.("data", (chunk) => stdout.append(chunk));
151
- child.stderr?.on?.("data", (chunk) => stderr.append(chunk));
217
+ child.stdout?.on?.("data", (chunk) => {
218
+ stdout.append(chunk);
219
+ retainedStdout?.append(chunk);
220
+ });
221
+ child.stderr?.on?.("data", (chunk) => {
222
+ stderr.append(chunk);
223
+ retainedStderr?.append(chunk);
224
+ });
152
225
 
153
226
  child.on("error", (error) => {
154
227
  cleanupAfterClose();
155
228
  settle(() => {
156
- if (allowFailure) resolvePromise(processResult(127, stdout, error.message || stderr.text()));
229
+ if (allowFailure) resolvePromise(processResult(127, stdout, error.message || stderr.text(), retainedStdout, retainedStderr));
157
230
  else rejectPromise(error);
158
231
  });
159
232
  });
@@ -165,9 +238,9 @@ export class ProcessExecutionService {
165
238
  settle(() => rejectPromise(error));
166
239
  return;
167
240
  }
168
- const result = processResult(code, stdout, stderr);
241
+ const result = processResult(code, stdout, stderr, retainedStdout, retainedStderr);
169
242
  if (code === 0 || allowFailure) settle(() => resolvePromise(result));
170
- else settle(() => rejectPromise(new BridgeError("execution_failed", result.stderr.trim() || result.stdout.trim() || `${cmd} exited ${code}`)));
243
+ else settle(() => rejectPromise(new BridgeError("execution_failed", processFailureMessage(result), { details: { process: result } })));
171
244
  });
172
245
 
173
246
  if (signal?.aborted) rejectCancelled();
@@ -175,15 +248,22 @@ export class ProcessExecutionService {
175
248
  }
176
249
  }
177
250
 
178
- function processResult(code, stdout, stderr) {
251
+ function processResult(code, stdout, stderr, retainedStdout = null, retainedStderr = null) {
179
252
  const stderrBuffer = stderr instanceof BoundedOutput ? stderr : null;
180
- return {
253
+ const result = {
181
254
  code,
182
255
  stdout: stdout instanceof BoundedOutput ? stdout.text() : String(stdout || ""),
183
256
  stderr: stderrBuffer ? stderrBuffer.text() : boundedErrorMessage(stderr),
184
257
  stdout_truncated_bytes: stdout instanceof BoundedOutput ? stdout.truncatedBytes : 0,
185
258
  stderr_truncated_bytes: stderrBuffer ? stderrBuffer.truncatedBytes : 0,
186
259
  };
260
+ if (retainedStdout && retainedStderr) {
261
+ Object.defineProperty(result, PROCESS_OUTPUT_CAPTURE, {
262
+ value: { stdout: retainedStdout, stderr: retainedStderr },
263
+ enumerable: false,
264
+ });
265
+ }
266
+ return result;
187
267
  }
188
268
 
189
269
  export function boundedErrorMessage(error) {
@@ -0,0 +1,82 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * Bounded byte stream shared by interactive process sessions and completed
5
+ * one-shot command continuations. It retains the newest bytes while keeping
6
+ * monotonic offsets so callers can detect data that aged out of the buffer.
7
+ */
8
+ export class ProcessOutputStream {
9
+ /** @param {number} maximumBytes */
10
+ constructor(maximumBytes) {
11
+ const maximum = Number(maximumBytes);
12
+ if (!Number.isSafeInteger(maximum) || maximum < 1) {
13
+ throw new Error("maximum retained output bytes must be a positive safe integer");
14
+ }
15
+ this.maximumBytes = maximum;
16
+ this.buffer = Buffer.alloc(0);
17
+ this.baseOffset = 0;
18
+ this.totalBytes = 0;
19
+ }
20
+
21
+ /** @param {unknown} chunk */
22
+ append(chunk) {
23
+ const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
24
+ if (!input.length) return;
25
+ this.totalBytes += input.length;
26
+ let combined = this.buffer.length ? Buffer.concat([this.buffer, input]) : Buffer.from(input);
27
+ if (combined.length > this.maximumBytes) {
28
+ const dropped = combined.length - this.maximumBytes;
29
+ combined = combined.subarray(dropped);
30
+ this.baseOffset += dropped;
31
+ }
32
+ this.buffer = combined;
33
+ }
34
+
35
+ /** @param {unknown} requestedOffset @param {unknown} maximumBytes */
36
+ read(requestedOffset, maximumBytes) {
37
+ const requested = nonNegativeInteger(requestedOffset);
38
+ const maximum = positiveInteger(maximumBytes);
39
+ const clampedOffset = Math.min(requested, this.totalBytes);
40
+ const effectiveOffset = Math.max(clampedOffset, this.baseOffset);
41
+ const start = effectiveOffset - this.baseOffset;
42
+ const slice = this.buffer.subarray(start, Math.min(this.buffer.length, start + maximum));
43
+ const decoded = decodeProcessBytes(slice);
44
+ return {
45
+ ...decoded,
46
+ requested_offset: requested,
47
+ start_offset: effectiveOffset,
48
+ next_offset: effectiveOffset + slice.length,
49
+ total_offset: this.totalBytes,
50
+ retained_start_offset: this.baseOffset,
51
+ truncated_before: requested < this.baseOffset,
52
+ truncated_after: effectiveOffset + slice.length < this.totalBytes,
53
+ };
54
+ }
55
+ }
56
+
57
+ /** @param {Buffer} bytes */
58
+ function decodeProcessBytes(bytes) {
59
+ try {
60
+ return { data: new TextDecoder("utf-8", { fatal: true }).decode(bytes), encoding: "utf8" };
61
+ } catch {
62
+ return {
63
+ data: new TextDecoder("utf-8").decode(bytes),
64
+ data_base64: bytes.toString("base64"),
65
+ encoding: "base64",
66
+ };
67
+ }
68
+ }
69
+
70
+ /** @param {unknown} value */
71
+ function nonNegativeInteger(value) {
72
+ const number = Number(value);
73
+ if (!Number.isSafeInteger(number) || number < 0) return 0;
74
+ return number;
75
+ }
76
+
77
+ /** @param {unknown} value */
78
+ function positiveInteger(value) {
79
+ const number = Number(value);
80
+ if (!Number.isSafeInteger(number) || number < 1) return 1;
81
+ return number;
82
+ }
@@ -0,0 +1,25 @@
1
+ // @ts-check
2
+
3
+ import { MCP_TEXT_PROJECTION_KEY } from "../shared/result-projection.mjs";
4
+
5
+ /** @param {Record<string, any>} value */
6
+ export function publicProcessToolResult(value) {
7
+ const truncated = Number(value.stdout_truncated_bytes) + Number(value.stderr_truncated_bytes);
8
+ const continuation = value.output_session_id
9
+ ? ` Continue with read_process session ${value.output_session_id}.`
10
+ : truncated > 0 ? " Additional output could not be retained because the process-session limit was reached." : "";
11
+ const summary = `Process exited with code ${value.code}.${truncated > 0 ? ` ${truncated} inline byte(s) omitted.` : ""}${continuation}`;
12
+ return { ...value, [MCP_TEXT_PROJECTION_KEY]: summary };
13
+ }
14
+
15
+ /** @param {Record<string, any>} result */
16
+ export function processFailureMessage(result) {
17
+ const source = String(result.stderr || result.stdout || `process exited ${result.code}`)
18
+ .replace(/[\r\n]+/g, " ").trim().slice(0, 2000);
19
+ const suffix = result.output_session_id
20
+ ? `; additional output is available through read_process session ${result.output_session_id}`
21
+ : result.stdout_truncated_bytes > 0 || result.stderr_truncated_bytes > 0
22
+ ? "; additional output was truncated"
23
+ : "";
24
+ return `${source || `process exited ${result.code}`}${suffix}`;
25
+ }
@@ -7,6 +7,7 @@ import { terminateProcessTree, terminateProcessTreeWithEscalation } from "./proc
7
7
  import { createToolAuthorizer } from "./policy.mjs";
8
8
  import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
9
9
  import { clampInteger } from "./numbers.mjs";
10
+ import { ProcessOutputStream } from "./process-output-stream.mjs";
10
11
  import {
11
12
  MAX_PROCESS_SESSIONS, MAX_PROCESS_SESSION_OUTPUT_BYTES, MAX_PROCESS_SESSION_STDIN_BYTES, PROCESS_SESSION_RETENTION_MS,
12
13
  } from "./execution-limits.mjs";
@@ -40,11 +41,37 @@ export class ProcessSessionManager {
40
41
  for (const session of this.sessions.values()) notifySessionWaiters(session);
41
42
  }
42
43
 
44
+ retainCompletedOutput({ command, cwd, stdout, stderr, exitCode, startedAt, closedAt = Date.now() }) {
45
+ this.prune();
46
+ this.evictExitedForCapacity();
47
+ if (this.sessions.size >= MAX_PROCESS_SESSIONS) return null;
48
+ const completedAt = Number.isFinite(Number(closedAt)) ? Number(closedAt) : Date.now();
49
+ const session = {
50
+ id: `proc_${randomBytes(24).toString("base64url")}`,
51
+ child: null,
52
+ argv0: basename(String(command || "process")),
53
+ cwd,
54
+ stdout,
55
+ stderr,
56
+ startedAt: Number.isFinite(Number(startedAt)) ? Number(startedAt) : completedAt,
57
+ lastActivity: completedAt,
58
+ closedAt: completedAt,
59
+ exitCode: Number.isInteger(exitCode) ? exitCode : null,
60
+ signal: null,
61
+ stdinClosed: true,
62
+ waiters: new Set(),
63
+ terminationTimer: null,
64
+ };
65
+ this.sessions.set(session.id, session);
66
+ return this.summary(session);
67
+ }
68
+
43
69
  async start(args, context = {}) {
44
70
  this.authorizeTool("start_process");
45
71
  const argv = validateArgv(args.argv);
46
72
  const cwd = await this.resolveCwd(args.cwd || ".");
47
73
  this.prune();
74
+ this.evictExitedForCapacity();
48
75
  if (this.sessions.size >= MAX_PROCESS_SESSIONS) throw new Error(`process session limit reached (${MAX_PROCESS_SESSIONS})`);
49
76
  this.throwIfCancelled(context);
50
77
 
@@ -59,8 +86,8 @@ export class ProcessSessionManager {
59
86
  child,
60
87
  argv0: basename(argv[0]),
61
88
  cwd,
62
- stdout: createSessionStream(),
63
- stderr: createSessionStream(),
89
+ stdout: new ProcessOutputStream(MAX_PROCESS_SESSION_OUTPUT_BYTES),
90
+ stderr: new ProcessOutputStream(MAX_PROCESS_SESSION_OUTPUT_BYTES),
64
91
  startedAt: Date.now(),
65
92
  lastActivity: Date.now(),
66
93
  closedAt: null,
@@ -74,12 +101,12 @@ export class ProcessSessionManager {
74
101
  this.trackChild(child, context.callId);
75
102
 
76
103
  child.stdout.on("data", (chunk) => {
77
- appendSessionStream(session.stdout, chunk);
104
+ session.stdout.append(chunk);
78
105
  session.lastActivity = Date.now();
79
106
  notifySessionWaiters(session);
80
107
  });
81
108
  child.stderr.on("data", (chunk) => {
82
- appendSessionStream(session.stderr, chunk);
109
+ session.stderr.append(chunk);
83
110
  session.lastActivity = Date.now();
84
111
  notifySessionWaiters(session);
85
112
  });
@@ -94,7 +121,7 @@ export class ProcessSessionManager {
94
121
  });
95
122
 
96
123
  child.on("error", (error) => {
97
- appendSessionStream(session.stderr, Buffer.from(`${boundedErrorMessage(error)}\n`));
124
+ session.stderr.append(Buffer.from(`${boundedErrorMessage(error)}\n`));
98
125
  session.lastActivity = Date.now();
99
126
  notifySessionWaiters(session);
100
127
  });
@@ -132,8 +159,8 @@ export class ProcessSessionManager {
132
159
  session.lastActivity = Date.now();
133
160
  return {
134
161
  ...this.summary(session),
135
- stdout: readSessionStream(session.stdout, stdoutOffset, maxBytes),
136
- stderr: readSessionStream(session.stderr, stderrOffset, maxBytes),
162
+ stdout: session.stdout.read(stdoutOffset, maxBytes),
163
+ stderr: session.stderr.read(stderrOffset, maxBytes),
137
164
  };
138
165
  }
139
166
 
@@ -207,6 +234,9 @@ export class ProcessSessionManager {
207
234
  for (const [id, session] of this.sessions) {
208
235
  if (session.closedAt !== null && session.lastActivity < cutoff) this.sessions.delete(id);
209
236
  }
237
+ }
238
+
239
+ evictExitedForCapacity() {
210
240
  if (this.sessions.size < MAX_PROCESS_SESSIONS) return;
211
241
  const exited = [...this.sessions.values()]
212
242
  .filter((session) => session.closedAt !== null)
@@ -240,50 +270,6 @@ function waitForSpawn(child) {
240
270
  });
241
271
  }
242
272
 
243
- function createSessionStream() {
244
- return { buffer: Buffer.alloc(0), baseOffset: 0, totalBytes: 0 };
245
- }
246
-
247
- function appendSessionStream(stream, chunk) {
248
- const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
249
- stream.totalBytes += input.length;
250
- let combined = stream.buffer.length ? Buffer.concat([stream.buffer, input]) : Buffer.from(input);
251
- if (combined.length > MAX_PROCESS_SESSION_OUTPUT_BYTES) {
252
- const dropped = combined.length - MAX_PROCESS_SESSION_OUTPUT_BYTES;
253
- combined = combined.subarray(dropped);
254
- stream.baseOffset += dropped;
255
- }
256
- stream.buffer = combined;
257
- }
258
-
259
- function readSessionStream(stream, requestedOffset, maxBytes) {
260
- const clampedOffset = Math.min(requestedOffset, stream.totalBytes);
261
- const effectiveOffset = Math.max(clampedOffset, stream.baseOffset);
262
- const start = effectiveOffset - stream.baseOffset;
263
- const slice = stream.buffer.subarray(start, Math.min(stream.buffer.length, start + maxBytes));
264
- let data;
265
- let dataBase64;
266
- let encoding = "utf8";
267
- try {
268
- data = new TextDecoder("utf-8", { fatal: true }).decode(slice);
269
- } catch {
270
- data = new TextDecoder("utf-8").decode(slice);
271
- dataBase64 = slice.toString("base64");
272
- encoding = "base64";
273
- }
274
- return {
275
- data,
276
- ...(dataBase64 ? { data_base64: dataBase64 } : {}),
277
- encoding,
278
- requested_offset: requestedOffset,
279
- start_offset: effectiveOffset,
280
- next_offset: effectiveOffset + slice.length,
281
- total_offset: stream.totalBytes,
282
- truncated_before: requestedOffset < stream.baseOffset,
283
- truncated_after: effectiveOffset + slice.length < stream.totalBytes,
284
- };
285
- }
286
-
287
273
  function sessionHasOutputAfter(session, stdoutOffset, stderrOffset) {
288
274
  return session.stdout.totalBytes > stdoutOffset || session.stderr.totalBytes > stderrOffset;
289
275
  }