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.
- package/CHANGELOG.md +9 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +5 -1
- package/docs/AUDIT.md +12 -0
- package/docs/LOGGING.md +3 -1
- package/docs/OPERATIONS.md +3 -2
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +5 -1
- package/docs/TOOL_REFERENCE.md +4 -4
- package/package.json +6 -2
- package/scripts/check-plan.mjs +3 -0
- package/scripts/check-runner.mjs +75 -0
- package/scripts/coverage-check.mjs +1 -1
- package/scripts/github-backlog.mjs +116 -0
- package/scripts/github-push.mjs +4 -0
- package/scripts/release-acceptance.mjs +29 -13
- package/scripts/run-checks.mjs +11 -21
- package/src/local/bounded-output.mjs +20 -3
- package/src/local/errors.mjs +5 -1
- package/src/local/execution-limits.mjs +6 -1
- package/src/local/process-execution.mjs +94 -14
- package/src/local/process-output-stream.mjs +82 -0
- package/src/local/process-result-projection.mjs +25 -0
- package/src/local/process-sessions.mjs +37 -51
- package/src/local/runtime.mjs +1 -0
- package/src/local/secure-file.mjs +24 -4
- package/src/local/tools.mjs +4 -9
- package/src/shared/result-projection.d.mts +6 -0
- package/src/shared/result-projection.json +4 -0
- package/src/shared/result-projection.mjs +50 -0
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +4 -4
- package/src/worker/errors.ts +18 -4
- package/src/worker/index.ts +1 -1
- package/src/worker/mcp-jsonrpc.ts +4 -2
|
@@ -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:
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
151
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
63
|
-
stderr:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
136
|
-
stderr:
|
|
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
|
}
|
package/src/local/runtime.mjs
CHANGED
|
@@ -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, {
|
|
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
|
+
}
|
package/src/local/tools.mjs
CHANGED
|
@@ -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
|
|
32
|
-
const
|
|
33
|
-
|
|
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,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
|
]
|