machine-bridge-mcp 1.2.10 → 2.0.0

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 (73) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +11 -6
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +23 -11
  6. package/docs/AUDIT.md +37 -3
  7. package/docs/CLIENTS.md +2 -2
  8. package/docs/ENGINEERING.md +2 -2
  9. package/docs/GETTING_STARTED.md +1 -1
  10. package/docs/LOCAL_AUTHORIZATION.md +111 -0
  11. package/docs/LOGGING.md +7 -5
  12. package/docs/MULTI_ACCOUNT.md +5 -5
  13. package/docs/OPERATIONS.md +33 -7
  14. package/docs/OVERVIEW.md +12 -8
  15. package/docs/PROJECT_STANDARDS.md +1 -1
  16. package/docs/RELEASING.md +4 -4
  17. package/docs/TESTING.md +9 -2
  18. package/docs/THREAT_MODEL.md +7 -6
  19. package/docs/TOOL_REFERENCE.md +4 -4
  20. package/docs/UPGRADING.md +10 -6
  21. package/package.json +8 -2
  22. package/scripts/check-plan.mjs +5 -0
  23. package/scripts/check-runner.mjs +75 -0
  24. package/scripts/coverage-check.mjs +1 -1
  25. package/scripts/github-backlog.mjs +116 -0
  26. package/scripts/github-push.mjs +4 -0
  27. package/scripts/local-release-acceptance.mjs +2 -2
  28. package/scripts/release-acceptance.mjs +36 -17
  29. package/scripts/run-checks.mjs +11 -21
  30. package/src/local/account-admin.mjs +28 -3
  31. package/src/local/bounded-output.mjs +20 -3
  32. package/src/local/cli-approval.mjs +117 -0
  33. package/src/local/cli-options.mjs +8 -2
  34. package/src/local/cli.mjs +13 -2
  35. package/src/local/device-identity.mjs +108 -0
  36. package/src/local/errors.mjs +5 -1
  37. package/src/local/execution-limits.mjs +6 -1
  38. package/src/local/operation-authorization.mjs +366 -0
  39. package/src/local/operation-risk.mjs +221 -0
  40. package/src/local/operation-state-lock.mjs +92 -0
  41. package/src/local/process-execution.mjs +94 -14
  42. package/src/local/process-output-stream.mjs +82 -0
  43. package/src/local/process-result-projection.mjs +25 -0
  44. package/src/local/process-sessions.mjs +37 -51
  45. package/src/local/relay-connection.mjs +12 -5
  46. package/src/local/runtime-relay.mjs +13 -5
  47. package/src/local/runtime.mjs +14 -7
  48. package/src/local/secure-file.mjs +24 -4
  49. package/src/local/state.mjs +9 -3
  50. package/src/local/tool-executor.mjs +4 -2
  51. package/src/local/tools.mjs +4 -9
  52. package/src/local/worker-deployment.mjs +4 -3
  53. package/src/local/worker-secret-file.mjs +2 -1
  54. package/src/shared/admin-auth.d.mts +10 -0
  55. package/src/shared/admin-auth.mjs +36 -0
  56. package/src/shared/daemon-auth.d.mts +20 -0
  57. package/src/shared/daemon-auth.mjs +55 -0
  58. package/src/shared/result-projection.d.mts +6 -0
  59. package/src/shared/result-projection.json +4 -0
  60. package/src/shared/result-projection.mjs +50 -0
  61. package/src/shared/server-metadata.json +1 -0
  62. package/src/shared/tool-catalog.json +4 -4
  63. package/src/worker/account-admin.ts +73 -4
  64. package/src/worker/daemon-auth.ts +205 -0
  65. package/src/worker/daemon-sockets.ts +15 -2
  66. package/src/worker/errors.ts +18 -4
  67. package/src/worker/index.ts +59 -10
  68. package/src/worker/mcp-jsonrpc.ts +4 -2
  69. package/src/worker/nonce-store.ts +79 -0
  70. package/src/worker/oauth-controller.ts +11 -6
  71. package/src/worker/oauth-refresh-families.ts +172 -0
  72. package/src/worker/oauth-state.ts +48 -3
  73. package/src/worker/oauth-tokens.ts +49 -40
@@ -0,0 +1,221 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+
4
+ export const OPERATION_APPROVAL_SCOPES = Object.freeze([
5
+ "shell",
6
+ "external-write",
7
+ "sensitive-write",
8
+ "external-read",
9
+ "sensitive-read",
10
+ "browser-session",
11
+ "data-export",
12
+ "persistent-job",
13
+ "application-control",
14
+ "credential-operation",
15
+ "full",
16
+ ]);
17
+
18
+ const SHELL_TOOLS = new Set(["exec_command", "run_process", "start_process", "run_local_command", "read_process", "write_process", "kill_process"]);
19
+ const PERSISTENT_TOOLS = new Set(["stage_job", "start_job", "list_jobs", "read_job", "cancel_job"]);
20
+ const APPLICATION_CONTROL_TOOLS = new Set(["open_local_application", "inspect_local_application", "operate_local_application"]);
21
+ const CREDENTIAL_TOOLS = new Set(["generate_ssh_key_resource"]);
22
+ const BROWSER_PROFILE_TOOLS = new Set([
23
+ "pair_browser_extension", "browser_list_tabs", "browser_manage_tabs", "browser_get_source",
24
+ "browser_inspect_page", "browser_wait", "browser_action", "browser_fill_form", "browser_screenshot",
25
+ ]);
26
+ const AUTOMATIC_TOOLS = new Set([
27
+ "server_info", "project_overview", "list_local_applications", "browser_status", "list_roots",
28
+ "diagnose_runtime", "list_local_resources",
29
+ ]);
30
+ const FILE_WRITE_TOOLS = new Set(["write_file", "edit_file"]);
31
+ const FILE_READ_TOOLS = new Set([
32
+ "session_bootstrap", "resolve_task_capabilities", "agent_context", "list_local_skills", "load_local_skill", "list_local_commands",
33
+ "list_dir", "list_files", "read_file", "view_image", "search_text",
34
+ "git_status", "git_diff", "git_log", "git_show",
35
+ ]);
36
+ const SENSITIVE_SEGMENTS = new Set([
37
+ ".ssh", ".aws", ".azure", ".gnupg", ".kube", ".docker", ".npmrc", ".pypirc",
38
+ "keychains", "cookies", "login data", "credentials", "secrets",
39
+ ]);
40
+
41
+ export function reviewedOperationToolNames() {
42
+ return new Set([
43
+ ...AUTOMATIC_TOOLS,
44
+ ...SHELL_TOOLS,
45
+ ...PERSISTENT_TOOLS,
46
+ ...APPLICATION_CONTROL_TOOLS,
47
+ ...CREDENTIAL_TOOLS,
48
+ ...BROWSER_PROFILE_TOOLS,
49
+ ...FILE_WRITE_TOOLS,
50
+ ...FILE_READ_TOOLS,
51
+ "browser_upload_files",
52
+ "apply_patch",
53
+ ]);
54
+ }
55
+
56
+ export async function classifyOperation(tool, args = {}, options = {}) {
57
+ const name = String(tool || "");
58
+ if (SHELL_TOOLS.has(name)) return requirement("shell", "remote shell or process control", name, args);
59
+ if (PERSISTENT_TOOLS.has(name)) return requirement("persistent-job", "persistent managed job", name, args);
60
+ if (name === "operate_local_application" && args.value_resource) {
61
+ return requirement(["application-control", "data-export"], "desktop application control using protected local data", name, {
62
+ application: args.application,
63
+ action: args.action,
64
+ value_resource: args.value_resource,
65
+ });
66
+ }
67
+ if (APPLICATION_CONTROL_TOOLS.has(name)) return requirement("application-control", "desktop application control", name, args);
68
+ if (CREDENTIAL_TOOLS.has(name)) return requirement("credential-operation", "credential or key operation", name, args);
69
+ if (name === "browser_upload_files") {
70
+ return requirement(["browser-session", "data-export"], "existing browser profile file upload", name, args);
71
+ }
72
+ if (name === "browser_action" && args.value_resource) {
73
+ return requirement(["browser-session", "data-export"], "existing browser profile input from protected local data", name, {
74
+ action: args.action,
75
+ tab_id: args.tab_id,
76
+ value_resource: args.value_resource,
77
+ });
78
+ }
79
+ if (name === "browser_fill_form" && Array.isArray(args.fields) && args.fields.some((field) => field?.value_resource || field?.sensitive === true)) {
80
+ return requirement(["browser-session", "data-export"], "existing browser profile form containing protected local data", name, {
81
+ tab_id: args.tab_id,
82
+ resource_fields: args.fields.filter((field) => field?.value_resource || field?.sensitive === true).length,
83
+ submit: args.submit === true,
84
+ });
85
+ }
86
+ if (BROWSER_PROFILE_TOOLS.has(name)) {
87
+ return requirement("browser-session", "access to the existing browser profile", name, {
88
+ action: args.action,
89
+ tab_id: args.tab_id,
90
+ frame_id: args.frame_id,
91
+ submit: args.submit === true,
92
+ });
93
+ }
94
+ if (FILE_WRITE_TOOLS.has(name)) {
95
+ const target = await canonicalWritePath(args.path, options);
96
+ const scopes = pathWriteScopes(options.workspace, target);
97
+ if (scopes.length) return requirement(scopes, pathWriteCategory(scopes), name, { path: target });
98
+ }
99
+ if (name === "apply_patch") {
100
+ const paths = patchPaths(args.patch);
101
+ const targets = [];
102
+ for (const candidate of paths) targets.push(await canonicalWritePath(candidate, options));
103
+ const scopes = normalizeScopes(targets.flatMap((target) => pathWriteScopes(options.workspace, target)));
104
+ if (scopes.length) return requirement(scopes, pathWriteCategory(scopes, "patch"), name, { paths: targets });
105
+ }
106
+ if (FILE_READ_TOOLS.has(name) && args.path !== undefined) {
107
+ const target = await canonicalExistingPath(args.path, options);
108
+ const scopes = [];
109
+ if (!isWithin(options.workspace, target)) scopes.push("external-read");
110
+ if (isSensitivePath(target)) scopes.push("sensitive-read");
111
+ if (scopes.length) return requirement(scopes, pathReadCategory(scopes), name, { path: target });
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function requirement(scopes, category, tool, target) {
117
+ const normalized = normalizeScopes(Array.isArray(scopes) ? scopes : [scopes]);
118
+ if (!normalized.length) throw new Error("operation approval requirement is missing a scope");
119
+ return {
120
+ scope: normalized[0],
121
+ scopes: normalized,
122
+ category,
123
+ targetHash: createHash("sha256").update(JSON.stringify({ tool, target: redactTarget(target) })).digest("hex"),
124
+ };
125
+ }
126
+
127
+ function normalizeScopes(scopes) {
128
+ const requested = new Set(scopes.map((scope) => String(scope || "")));
129
+ return OPERATION_APPROVAL_SCOPES.filter((scope) => requested.has(scope));
130
+ }
131
+
132
+ function pathWriteScopes(workspace, target) {
133
+ const scopes = [];
134
+ if (!isWithin(workspace, target)) scopes.push("external-write");
135
+ if (isSensitiveWritePath(target)) scopes.push("sensitive-write");
136
+ return scopes;
137
+ }
138
+
139
+ function pathWriteCategory(scopes, subject = "write") {
140
+ const external = scopes.includes("external-write");
141
+ const sensitive = scopes.includes("sensitive-write");
142
+ if (external && sensitive) return `${subject} outside the selected workspace to a credential or persistence-sensitive path`;
143
+ if (sensitive) return `${subject} to a credential or persistence-sensitive path`;
144
+ return `${subject} outside the selected workspace`;
145
+ }
146
+
147
+ function pathReadCategory(scopes) {
148
+ const external = scopes.includes("external-read");
149
+ const sensitive = scopes.includes("sensitive-read");
150
+ if (external && sensitive) return "read outside the selected workspace from a credential-sensitive location";
151
+ if (sensitive) return "read from a credential-sensitive location";
152
+ return "read outside the selected workspace";
153
+ }
154
+
155
+ function redactTarget(value) {
156
+ if (!value || typeof value !== "object") return String(value || "");
157
+ const out = {};
158
+ for (const [key, item] of Object.entries(value)) {
159
+ if (["content", "command", "stdin", "value", "old_text", "new_text", "patch"].includes(key)) {
160
+ out[key] = `<sha256:${createHash("sha256").update(String(item || "")).digest("hex")}>`;
161
+ } else out[key] = item;
162
+ }
163
+ return out;
164
+ }
165
+
166
+ async function canonicalExistingPath(value, options) {
167
+ if (typeof options.resolveExistingPath === "function") return options.resolveExistingPath(value);
168
+ return path.resolve(options.workspace, String(value || "."));
169
+ }
170
+
171
+ async function canonicalWritePath(value, options) {
172
+ if (typeof options.resolveWritePath === "function") return options.resolveWritePath(value);
173
+ return path.resolve(options.workspace, String(value || "."));
174
+ }
175
+
176
+ function isWithin(workspace, target) {
177
+ const relative = path.relative(path.resolve(workspace), path.resolve(target));
178
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
179
+ }
180
+
181
+ function isSensitivePath(target) {
182
+ const lower = path.resolve(target).toLowerCase();
183
+ const segments = lower.split(/[\\/]+/);
184
+ if (segments.some((segment) => SENSITIVE_SEGMENTS.has(segment))) return true;
185
+ const base = path.basename(lower);
186
+ return isSensitiveEnvironmentFile(base) || /(?:token|secret|credential|private[-_]?key)/.test(base);
187
+ }
188
+
189
+ function isSensitiveWritePath(target) {
190
+ const lower = path.resolve(target).toLowerCase();
191
+ const normalized = lower.split(path.sep).join("/");
192
+ const base = path.basename(lower);
193
+ if (isSensitivePath(lower)) return true;
194
+ if ([".zshrc", ".bashrc", ".bash_profile", ".profile", ".gitconfig", ".netrc", ".curlrc", "authorized_keys", "sshd_config", "sudoers"].includes(base)) return true;
195
+ return normalized.includes("/.git/hooks/")
196
+ || normalized.endsWith("/.git/config")
197
+ || normalized.endsWith("/.config/git/config")
198
+ || normalized.includes("/library/launchagents/")
199
+ || normalized.includes("/library/launchdaemons/")
200
+ || normalized.includes("/.config/autostart/")
201
+ || normalized.includes("/.config/systemd/user/")
202
+ || normalized.includes("/start menu/programs/startup/")
203
+ || normalized.endsWith("/.config/fish/config.fish");
204
+ }
205
+
206
+ function isSensitiveEnvironmentFile(base) {
207
+ if (base === ".env") return true;
208
+ if (!base.startsWith(".env.")) return false;
209
+ return ![".env.example", ".env.sample", ".env.template", ".env.defaults"].includes(base);
210
+ }
211
+
212
+ function patchPaths(patch) {
213
+ const paths = [];
214
+ for (const line of String(patch || "").split(/\r?\n/)) {
215
+ const match = /^(?:\*\*\* (?:Add|Update|Delete) File:|\*\*\* Move to:|\+\+\+|---)\s+(.+)$/.exec(line);
216
+ if (!match) continue;
217
+ const value = match[1].replace(/^[ab]\//, "").trim();
218
+ if (value && value !== "/dev/null") paths.push(value);
219
+ }
220
+ return paths;
221
+ }
@@ -0,0 +1,92 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { createExclusiveFileSync, removeOwnedJsonFileSync } from "./exclusive-file.mjs";
5
+ import { createMonotonicDeadline } from "./monotonic-deadline.mjs";
6
+ import { currentProcessStartTimeMs, inspectProcessInstance } from "./process-identity.mjs";
7
+ import { ensureOwnerOnlyDirectorySync, readBoundedRegularFileSync } from "./secure-file.mjs";
8
+
9
+ const LOCK_PURPOSE = "operation-authorization";
10
+ const LOCK_FILE = "operation-authorization.lock";
11
+ const MAX_LOCK_BYTES = 8 * 1024;
12
+ const DEFAULT_WAIT_MS = 5_000;
13
+ const DEFAULT_POLL_MS = 25;
14
+
15
+ export async function withOperationStateLock(root, callback, options = {}) {
16
+ if (typeof callback !== "function") throw new TypeError("operation-state lock requires a callback");
17
+ const requestedRoot = String(root || "").trim();
18
+ if (!requestedRoot) throw new Error("operation-state lock root is missing");
19
+ const directory = path.resolve(requestedRoot);
20
+ ensureOwnerOnlyDirectorySync(directory);
21
+ const lockPath = path.join(directory, LOCK_FILE);
22
+ const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_WAIT_MS);
23
+ const pollMs = positiveInteger(options.pollMs, DEFAULT_POLL_MS);
24
+ const deadline = createMonotonicDeadline(timeoutMs);
25
+ const owner = lockOwner();
26
+
27
+ while (true) {
28
+ try {
29
+ createExclusiveFileSync(lockPath, `${JSON.stringify(owner, null, 2)}\n`, { mode: 0o600 });
30
+ break;
31
+ } catch (error) {
32
+ if (error?.code !== "EEXIST") throw error;
33
+ const existing = readLockOwner(lockPath);
34
+ if (!existing) throw new Error("operation authorization lock is malformed; inspect the owner-only state directory");
35
+ if (existing.purpose !== LOCK_PURPOSE) throw new Error("operation authorization lock contains mismatched purpose metadata");
36
+ const identity = inspectProcessInstance(existing, { maxAgeMs: Number.POSITIVE_INFINITY });
37
+ if (identity.reclaimable) {
38
+ if (removeOwnedJsonFileSync(lockPath, { token: existing.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES })) continue;
39
+ }
40
+ if (deadline.expired()) {
41
+ const pid = Number.isInteger(existing.pid) ? `pid ${existing.pid}` : "unknown process";
42
+ throw new Error(`operation authorization state is busy (${pid}); retry after the current approval operation finishes`);
43
+ }
44
+ await delay(Math.min(pollMs, Math.max(1, deadline.remainingMs())));
45
+ }
46
+ }
47
+
48
+ let result;
49
+ let callbackError;
50
+ try {
51
+ result = await callback();
52
+ } catch (error) {
53
+ callbackError = error;
54
+ }
55
+ const released = removeOwnedJsonFileSync(lockPath, { token: owner.token, purpose: LOCK_PURPOSE }, { maxBytes: MAX_LOCK_BYTES });
56
+ if (!released) throw new Error("operation authorization lock changed before release; approval state may require inspection");
57
+ if (callbackError) throw callbackError;
58
+ return result;
59
+ }
60
+
61
+ function lockOwner() {
62
+ return {
63
+ pid: process.pid,
64
+ token: randomBytes(16).toString("hex"),
65
+ purpose: LOCK_PURPOSE,
66
+ startedAt: new Date().toISOString(),
67
+ processStartedAt: new Date(currentProcessStartTimeMs()).toISOString(),
68
+ entryScript: String(process.argv[1] || "").slice(0, 4096),
69
+ };
70
+ }
71
+
72
+ function readLockOwner(file) {
73
+ if (!existsSync(file)) return null;
74
+ let parsed;
75
+ try { parsed = JSON.parse(readBoundedRegularFileSync(file, MAX_LOCK_BYTES, "operation authorization lock").toString("utf8")); } catch { return null; }
76
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
77
+ if (!Number.isInteger(parsed.pid) || parsed.pid <= 0) return null;
78
+ if (!/^[a-f0-9]{32}$/.test(String(parsed.token || ""))) return null;
79
+ if (parsed.purpose !== LOCK_PURPOSE) return null;
80
+ if (!Number.isFinite(Date.parse(String(parsed.startedAt || "")))) return null;
81
+ if (!Number.isFinite(Date.parse(String(parsed.processStartedAt || "")))) return null;
82
+ return parsed;
83
+ }
84
+
85
+ function positiveInteger(value, fallback) {
86
+ const number = Number(value);
87
+ return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
88
+ }
89
+
90
+ function delay(milliseconds) {
91
+ return new Promise((resolvePromise) => { setTimeout(resolvePromise, milliseconds); });
92
+ }
@@ -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
+ }