machine-bridge-mcp 1.2.10 → 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.
@@ -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
  }
@@ -135,6 +135,7 @@ export class LocalRuntime {
135
135
  resolveLocalCommand: (args, context) => this.agentContextManager.resolveLocalCommand(args, context),
136
136
  displayPath: (value) => this.displayPath(value),
137
137
  throwIfCancelled: (context) => this.throwIfCancelled(context),
138
+ retainCompletedOutput: (value) => this.processSessionManager.retainCompletedOutput(value),
138
139
  });
139
140
  this.gitService = new GitService({
140
141
  resolveExistingPath: (value) => this.resolveExistingPath(value),
@@ -16,6 +16,11 @@ export function openRegularFileSync(file, flags, options = {}) {
16
16
  try {
17
17
  const info = fstatSync(fd);
18
18
  if (!info.isFile()) throw new Error(`${label} is not a regular file`);
19
+ if (options.verifyPathIdentity === true) {
20
+ const pathInfo = lstatSync(file);
21
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw new Error(`${label} must be a regular file and not a symbolic link`);
22
+ if (!sameFileIdentity(info, pathInfo)) throw new Error(`${label} identity changed while opening`);
23
+ }
19
24
  if (Number.isInteger(options.chmod)) setDescriptorMode(fd, options.chmod);
20
25
  return { fd, info };
21
26
  } catch (error) {
@@ -37,15 +42,19 @@ export function chmodRegularFileSync(file, mode, label = "path") {
37
42
  return withRegularFileSync(file, fsConstants.O_RDONLY, { label, chmod: mode }, () => undefined);
38
43
  }
39
44
 
40
- export function readBoundedRegularFileSync(file, maxBytes, label = "path") {
41
- return readBoundedRegularFileWithInfoSync(file, maxBytes, label).buffer;
45
+ export function readBoundedRegularFileSync(file, maxBytes, label = "path", options = {}) {
46
+ return readBoundedRegularFileWithInfoSync(file, maxBytes, label, options).buffer;
42
47
  }
43
48
 
44
- export function readBoundedRegularFileWithInfoSync(file, maxBytes, label = "path") {
49
+ export function readBoundedRegularFileWithInfoSync(file, maxBytes, label = "path", options = {}) {
45
50
  const limit = Number(maxBytes);
46
51
  if (!Number.isSafeInteger(limit) || limit < 0) throw new Error("maximum file size must be a non-negative safe integer");
47
- return withRegularFileSync(file, fsConstants.O_RDONLY, { label }, (fd, info) => {
52
+ return withRegularFileSync(file, fsConstants.O_RDONLY, {
53
+ label,
54
+ verifyPathIdentity: options.verifyPathIdentity === true,
55
+ }, (fd, info) => {
48
56
  if (info.size > limit) throw new Error(`file exceeds ${limit} bytes`);
57
+ options.afterOpen?.({ fd, info });
49
58
  const buffer = Buffer.alloc(info.size);
50
59
  let offset = 0;
51
60
  while (offset < buffer.length) {
@@ -104,3 +113,14 @@ function setDescriptorMode(fd, mode) {
104
113
  if (process.platform !== "win32") throw error;
105
114
  }
106
115
  }
116
+
117
+ function sameFileIdentity(left, right) {
118
+ const leftDevice = Number(left.dev);
119
+ const rightDevice = Number(right.dev);
120
+ const leftInode = Number(left.ino);
121
+ const rightInode = Number(right.ino);
122
+ if ([leftDevice, rightDevice, leftInode, rightInode].every((value) => Number.isSafeInteger(value) && value >= 0)) {
123
+ return leftDevice === rightDevice && leftInode === rightInode;
124
+ }
125
+ return true;
126
+ }
@@ -1,4 +1,5 @@
1
1
  import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
2
+ import { projectMcpResult } from "../shared/result-projection.mjs";
2
3
  export {
3
4
  DEFAULT_POLICY_PROFILE,
4
5
  DEFAULT_POLICY_REVISION,
@@ -28,10 +29,9 @@ export const MCP_INSTRUCTIONS = Object.freeze(serverMetadata.instructions.map((v
28
29
  export function toolResult(value, isError = false) {
29
30
  const special = specialMcpResult(value);
30
31
  if (special) return { ...special, isError };
31
- const structuredContent = toStructuredContent(value);
32
- const text = typeof value === "string" ? value : JSON.stringify(value, null, 2);
33
- const result = { content: [{ type: "text", text }], isError };
34
- if (structuredContent) result.structuredContent = structuredContent;
32
+ const projection = projectMcpResult(value);
33
+ const result = { content: [{ type: "text", text: projection.text }], isError };
34
+ if (projection.structuredContent) result.structuredContent = projection.structuredContent;
35
35
  return result;
36
36
  }
37
37
 
@@ -55,8 +55,3 @@ function specialMcpResult(value) {
55
55
  }
56
56
  return result;
57
57
  }
58
-
59
- function toStructuredContent(value) {
60
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
61
- return value;
62
- }
@@ -0,0 +1,6 @@
1
+ export const MCP_TEXT_PROJECTION_KEY: "$mcpText";
2
+ export function mirroredResultText(value: unknown): string;
3
+ export function projectMcpResult(value: unknown): {
4
+ text: string;
5
+ structuredContent: Record<string, unknown> | null;
6
+ };
@@ -0,0 +1,4 @@
1
+ {
2
+ "maxMirroredJsonBytes": 16384,
3
+ "maximumSummaryKeys": 8
4
+ }
@@ -0,0 +1,50 @@
1
+ // @ts-check
2
+
3
+ import resultProjection from "./result-projection.json" with { type: "json" };
4
+
5
+ export const MCP_TEXT_PROJECTION_KEY = "$mcpText";
6
+
7
+ /**
8
+ * Keep MCP text content useful for human-only clients without duplicating a
9
+ * large object that is already present in structuredContent.
10
+ * @param {unknown} value
11
+ */
12
+ export function mirroredResultText(value) {
13
+ if (typeof value === "string") return value;
14
+ const serialized = JSON.stringify(value, null, 2);
15
+ if (typeof serialized !== "string") return String(value ?? "");
16
+ const bytes = new TextEncoder().encode(serialized).byteLength;
17
+ if (bytes <= Number(resultProjection.maxMirroredJsonBytes)) return serialized;
18
+ const object = asObject(value);
19
+ const keys = Object.keys(object)
20
+ .slice(0, Number(resultProjection.maximumSummaryKeys))
21
+ .map((key) => key.replace(/[\r\n\t]+/g, " ").slice(0, 80));
22
+ const fields = keys.length ? ` Fields: ${keys.join(", ")}.` : "";
23
+ return `Structured result is ${bytes} bytes and is available in structuredContent.${fields}`;
24
+ }
25
+
26
+ /**
27
+ * Extract an optional domain-provided text projection while keeping the
28
+ * authoritative structured object single-copy and marker-free.
29
+ * @param {unknown} value
30
+ */
31
+ export function projectMcpResult(value) {
32
+ const object = asObject(value);
33
+ if (Object.prototype.hasOwnProperty.call(object, MCP_TEXT_PROJECTION_KEY)
34
+ && typeof object[MCP_TEXT_PROJECTION_KEY] === "string") {
35
+ const structuredContent = { ...object };
36
+ const text = String(structuredContent[MCP_TEXT_PROJECTION_KEY]).slice(0, 4096);
37
+ delete structuredContent[MCP_TEXT_PROJECTION_KEY];
38
+ return { text, structuredContent };
39
+ }
40
+ return {
41
+ text: mirroredResultText(value),
42
+ structuredContent: value && typeof value === "object" && !Array.isArray(value) ? value : null,
43
+ };
44
+ }
45
+
46
+ /** @param {unknown} value */
47
+ function asObject(value) {
48
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
49
+ return value;
50
+ }
@@ -24,6 +24,7 @@
24
24
  "Managed-job resource redaction is defense in depth, not a guarantee against transformed or partial secret output. Use capture_output=discard for steps that may echo credentials.",
25
25
  "Do not use shell encoding, renaming, or alternate tools to bypass host or platform safety policy.",
26
26
  "run_process avoids shell parsing but is not an OS sandbox. exec_command is exposed only in shell mode and has the local user's authority.",
27
+ "For commands that may produce substantial output, prefer start_process/read_process or use the output_session_id returned by a truncated run_process, run_local_command, or exec_command result. Read output in bounded pages instead of repeatedly requesting a larger one-shot response.",
27
28
  "Per-tool success, failure, cancellation, and timing events are debug-only; default logs contain lifecycle and infrastructure events, not a tool-call transcript.",
28
29
  "Inspect before editing, prefer read_file line ranges, edit_file, or apply_patch over whole-file replacement, and report commands that were run."
29
30
  ]