machine-bridge-mcp 0.15.0 → 0.16.1

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 (44) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +12 -2
  3. package/SECURITY.md +7 -0
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +5 -3
  6. package/docs/AUDIT.md +12 -1
  7. package/docs/LOGGING.md +7 -1
  8. package/docs/OPERATIONS.md +9 -2
  9. package/docs/POLICY_REFERENCE.md +88 -0
  10. package/docs/TESTING.md +7 -0
  11. package/package.json +14 -3
  12. package/scripts/coverage-check.mjs +108 -0
  13. package/scripts/generate-policy-reference.mjs +95 -0
  14. package/src/local/agent-context.mjs +1 -103
  15. package/src/local/app-automation.mjs +7 -11
  16. package/src/local/browser-bridge.mjs +26 -156
  17. package/src/local/browser-extension-protocol.mjs +75 -0
  18. package/src/local/browser-pairing-store.mjs +83 -0
  19. package/src/local/call-registry.mjs +113 -0
  20. package/src/local/capability-ranking.mjs +103 -0
  21. package/src/local/cli-local-admin.mjs +308 -0
  22. package/src/local/cli-options.mjs +151 -0
  23. package/src/local/cli-policy.mjs +89 -0
  24. package/src/local/cli.mjs +16 -521
  25. package/src/local/errors.mjs +122 -0
  26. package/src/local/git-service.mjs +88 -0
  27. package/src/local/lifecycle.mjs +64 -0
  28. package/src/local/log.mjs +50 -19
  29. package/src/local/managed-job-plan.mjs +235 -0
  30. package/src/local/managed-jobs.mjs +16 -220
  31. package/src/local/observability.mjs +83 -0
  32. package/src/local/policy.mjs +148 -0
  33. package/src/local/process-execution.mjs +153 -0
  34. package/src/local/process-sessions.mjs +10 -20
  35. package/src/local/process-tracker.mjs +55 -0
  36. package/src/local/runtime.mjs +154 -672
  37. package/src/local/service.mjs +1 -0
  38. package/src/local/stdio.mjs +3 -11
  39. package/src/local/tool-executor.mjs +102 -0
  40. package/src/local/tools.mjs +21 -104
  41. package/src/local/workspace-file-service.mjs +451 -0
  42. package/src/shared/policy-contract.json +54 -0
  43. package/src/shared/tool-catalog.json +4 -4
  44. package/src/worker/index.ts +69 -524
@@ -0,0 +1,153 @@
1
+ import { spawn } from "node:child_process";
2
+ import { stat } from "node:fs/promises";
3
+ import { executionEnv, workspaceShellCommand } from "./shell.mjs";
4
+ import { MAX_COMMAND_BYTES, terminateProcessTreeWithEscalation, validateArgv } from "./process-sessions.mjs";
5
+ import { BridgeError } from "./errors.mjs";
6
+
7
+ const MAX_STDIN_BYTES = 1024 * 1024;
8
+ const DEFAULT_OUTPUT_BYTES = 512 * 1024;
9
+
10
+ export class ProcessExecutionService {
11
+ constructor({ workspace, policy, policyGate, runtimeDir, processTracker, resolveExistingPath, resolveLocalCommand, displayPath, throwIfCancelled }) {
12
+ this.workspace = workspace;
13
+ this.policy = policy;
14
+ this.policyGate = policyGate;
15
+ this.runtimeDir = runtimeDir;
16
+ this.processTracker = processTracker;
17
+ this.resolveExistingPath = resolveExistingPath;
18
+ this.resolveLocalCommand = resolveLocalCommand;
19
+ this.displayPath = displayPath;
20
+ this.throwIfCancelled = throwIfCancelled;
21
+ }
22
+
23
+ async runDirect(args, context = {}) {
24
+ this.policyGate.assert("run_process");
25
+ const argv = validateArgv(args.argv);
26
+ const cwd = await this.resolveExistingPath(args.cwd || ".");
27
+ if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "cwd is not a directory");
28
+ return this.run(argv[0], argv.slice(1), clampInt(args.timeout_seconds, 120, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context, cwd);
29
+ }
30
+
31
+ async runRegistered(args, context = {}) {
32
+ this.policyGate.assert("run_local_command");
33
+ const command = await this.resolveLocalCommand(args, context);
34
+ const argv = validateArgv(command.argv);
35
+ const cwd = await this.resolveExistingPath(command.cwd);
36
+ if (!(await stat(cwd)).isDirectory()) throw new BridgeError("invalid_request", "registered command cwd is not a directory");
37
+ const requested = args.timeout_seconds === undefined
38
+ ? command.timeoutSeconds
39
+ : clampInt(args.timeout_seconds, command.timeoutSeconds, 1, 600);
40
+ const timeoutSeconds = Math.min(requested, command.timeoutSeconds);
41
+ const result = await this.run(argv[0], argv.slice(1), timeoutSeconds * 1000, false, DEFAULT_OUTPUT_BYTES, context, cwd);
42
+ return { name: command.name, cwd: this.displayPath(cwd), timeout_seconds: timeoutSeconds, ...result };
43
+ }
44
+
45
+ async probeShell(context = {}) {
46
+ const shell = workspaceShellCommand(process.platform === "win32" ? "cd" : "pwd");
47
+ return this.run(shell.cmd, shell.args, 5000, true, 64 * 1024, context);
48
+ }
49
+
50
+ async runShell(command, timeoutSeconds, context = {}) {
51
+ this.policyGate.assert("exec_command");
52
+ if (!command || typeof command !== "string") throw new BridgeError("invalid_request", "command is required");
53
+ if (command.includes("\0")) throw new BridgeError("invalid_request", "command contains a NUL byte");
54
+ if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new BridgeError("limit_exceeded", `command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
55
+ const shell = workspaceShellCommand(command);
56
+ return this.run(shell.cmd, shell.args, clampInt(timeoutSeconds, 120, 1, 600) * 1000, false, DEFAULT_OUTPUT_BYTES, context);
57
+ }
58
+
59
+ terminateAll(signal = "SIGTERM", escalate = false) {
60
+ this.processTracker.terminateAll(signal, escalate);
61
+ }
62
+
63
+ async run(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = DEFAULT_OUTPUT_BYTES, context = {}, cwd = this.workspace, stdin = null) {
64
+ this.throwIfCancelled(context);
65
+ if (stdin !== null && Buffer.byteLength(String(stdin)) > MAX_STDIN_BYTES) {
66
+ throw new BridgeError("limit_exceeded", "process stdin exceeds 1 MiB");
67
+ }
68
+ return new Promise((resolvePromise, rejectPromise) => {
69
+ const child = spawn(cmd, args, {
70
+ cwd,
71
+ env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false, runtimeDir: this.runtimeDir }),
72
+ detached: process.platform !== "win32",
73
+ windowsHide: true,
74
+ });
75
+ this.processTracker.track(child, context.callId);
76
+ if (stdin !== null) {
77
+ child.stdin.on("error", () => {});
78
+ child.stdin.end(String(stdin));
79
+ }
80
+ let stdout = "";
81
+ let stderr = "";
82
+ let stdoutTruncated = 0;
83
+ let stderrTruncated = 0;
84
+ let timedOut = false;
85
+ let settled = false;
86
+ let killTimer = null;
87
+ const timer = setTimeout(() => {
88
+ timedOut = true;
89
+ killTimer = terminateProcessTreeWithEscalation(child);
90
+ }, timeoutMs);
91
+ timer.unref?.();
92
+ const cleanup = () => {
93
+ clearTimeout(timer);
94
+ if (killTimer && !timedOut) clearTimeout(killTimer);
95
+ this.processTracker.untrack(child);
96
+ };
97
+ const finish = (callback) => {
98
+ if (settled) return;
99
+ settled = true;
100
+ cleanup();
101
+ callback();
102
+ };
103
+ child.stdout.on("data", (chunk) => {
104
+ const next = appendLimited(stdout, chunk, maxOutputBytes);
105
+ stdout = next.value;
106
+ stdoutTruncated += next.truncated;
107
+ });
108
+ child.stderr.on("data", (chunk) => {
109
+ const next = appendLimited(stderr, chunk, maxOutputBytes);
110
+ stderr = next.value;
111
+ stderrTruncated += next.truncated;
112
+ });
113
+ child.on("error", (error) => finish(() => {
114
+ if (allowFailure) resolvePromise({ code: 127, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: boundedErrorMessage(error) });
115
+ else rejectPromise(error);
116
+ }));
117
+ child.on("close", (code) => finish(() => {
118
+ const result = { code, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: finalizeOutput(stderr, stderrTruncated) };
119
+ try { this.throwIfCancelled(context); } catch (error) { rejectPromise(error); return; }
120
+ if (timedOut) {
121
+ rejectPromise(new BridgeError("timeout", `command timed out after ${timeoutMs}ms`, { retryable: true }));
122
+ return;
123
+ }
124
+ if (code === 0 || allowFailure) resolvePromise(result);
125
+ else rejectPromise(new BridgeError("execution_failed", stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
126
+ }));
127
+ });
128
+ }
129
+ }
130
+
131
+ export function boundedErrorMessage(error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ return message.replace(/[\r\n]+/g, " ").slice(0, 4096) || "tool call failed";
134
+ }
135
+
136
+ function appendLimited(current, chunk, maximum) {
137
+ const text = String(chunk || "");
138
+ const budget = Math.max(0, maximum - Buffer.byteLength(current));
139
+ const textBytes = Buffer.byteLength(text);
140
+ if (textBytes <= budget) return { value: current + text, truncated: 0 };
141
+ const slice = Buffer.from(text).subarray(0, budget).toString();
142
+ return { value: current + slice, truncated: textBytes - Buffer.byteLength(slice) };
143
+ }
144
+
145
+ function finalizeOutput(value, truncated) {
146
+ return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
147
+ }
148
+
149
+ function clampInt(value, fallback, minimum, maximum) {
150
+ const parsed = Number.parseInt(String(value ?? ""), 10);
151
+ const number = Number.isFinite(parsed) ? parsed : fallback;
152
+ return Math.min(Math.max(number, minimum), maximum);
153
+ }
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { basename } from "node:path";
4
4
  import { executionEnv } from "./shell.mjs";
5
+ import { createToolAuthorizer } from "./policy.mjs";
5
6
 
6
7
  export const MAX_COMMAND_BYTES = 64 * 1024;
7
8
  const MAX_ARGV_ITEMS = 256;
@@ -11,12 +12,12 @@ const MAX_PROCESS_STDIN_BYTES = 64 * 1024;
11
12
  const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
12
13
 
13
14
  export class ProcessSessionManager {
14
- constructor({ workspace, policy, runtimeDir, activeProcesses, callProcesses, resolveCwd, displayPath, throwIfCancelled }) {
15
+ constructor({ workspace, policy, authorizeTool = null, runtimeDir, processTracker, resolveCwd, displayPath, throwIfCancelled }) {
15
16
  this.workspace = workspace;
16
17
  this.policy = policy;
18
+ this.authorizeTool = createToolAuthorizer(this.policy, authorizeTool);
17
19
  this.runtimeDir = runtimeDir;
18
- this.activeProcesses = activeProcesses;
19
- this.callProcesses = callProcesses;
20
+ this.processTracker = processTracker;
20
21
  this.resolveCwd = resolveCwd;
21
22
  this.displayPath = displayPath;
22
23
  this.throwIfCancelled = throwIfCancelled;
@@ -40,7 +41,7 @@ export class ProcessSessionManager {
40
41
  }
41
42
 
42
43
  async start(args, context = {}) {
43
- this.assertEnabled("start_process");
44
+ this.authorizeTool("start_process");
44
45
  const argv = validateArgv(args.argv);
45
46
  const cwd = await this.resolveCwd(args.cwd || ".");
46
47
  this.prune();
@@ -109,7 +110,7 @@ export class ProcessSessionManager {
109
110
  }
110
111
 
111
112
  async read(args, context = {}) {
112
- this.assertEnabled("read_process");
113
+ this.authorizeTool("read_process");
113
114
  const session = this.get(args.session_id);
114
115
  const stdoutOffset = clampInt(args.stdout_offset, 0, 0, Number.MAX_SAFE_INTEGER);
115
116
  const stderrOffset = clampInt(args.stderr_offset, 0, 0, Number.MAX_SAFE_INTEGER);
@@ -137,7 +138,7 @@ export class ProcessSessionManager {
137
138
  }
138
139
 
139
140
  async write(args, context = {}) {
140
- this.assertEnabled("write_process");
141
+ this.authorizeTool("write_process");
141
142
  const session = this.get(args.session_id);
142
143
  if (session.closedAt !== null) throw new Error("process session has already exited");
143
144
  const data = String(args.data ?? "");
@@ -158,7 +159,7 @@ export class ProcessSessionManager {
158
159
  }
159
160
 
160
161
  async kill(args, context = {}) {
161
- this.assertEnabled("kill_process");
162
+ this.authorizeTool("kill_process");
162
163
  const session = this.get(args.session_id);
163
164
  this.throwIfCancelled(context);
164
165
  const wasRunning = session.closedAt === null;
@@ -207,24 +208,13 @@ export class ProcessSessionManager {
207
208
  }
208
209
  }
209
210
 
210
- assertEnabled(tool) {
211
- if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error(`${tool} is disabled by daemon policy`);
212
- }
213
211
 
214
212
  trackChild(child, callId) {
215
- this.activeProcesses.add(child);
216
- if (!callId) return;
217
- const set = this.callProcesses.get(callId) || new Set();
218
- set.add(child);
219
- this.callProcesses.set(callId, set);
213
+ this.processTracker.track(child, callId);
220
214
  }
221
215
 
222
216
  untrackChild(child) {
223
- this.activeProcesses.delete(child);
224
- for (const [callId, set] of this.callProcesses) {
225
- set.delete(child);
226
- if (!set.size) this.callProcesses.delete(callId);
227
- }
217
+ this.processTracker.untrack(child);
228
218
  }
229
219
  }
230
220
 
@@ -0,0 +1,55 @@
1
+ import { terminateProcessTree, terminateProcessTreeWithEscalation } from "./process-sessions.mjs";
2
+
3
+ export class ProcessTracker {
4
+ constructor(options = {}) {
5
+ this.terminate = typeof options.terminate === "function" ? options.terminate : terminateProcessTree;
6
+ this.terminateWithEscalation = typeof options.terminateWithEscalation === "function"
7
+ ? options.terminateWithEscalation
8
+ : terminateProcessTreeWithEscalation;
9
+ this.active = new Set();
10
+ this.byCall = new Map();
11
+ }
12
+
13
+ track(child, callId = "") {
14
+ if (!child) return;
15
+ this.active.add(child);
16
+ if (!callId) return;
17
+ const id = String(callId);
18
+ const children = this.byCall.get(id) || new Set();
19
+ children.add(child);
20
+ this.byCall.set(id, children);
21
+ }
22
+
23
+ untrack(child) {
24
+ if (!child) return;
25
+ this.active.delete(child);
26
+ for (const [callId, children] of this.byCall) {
27
+ children.delete(child);
28
+ if (!children.size) this.byCall.delete(callId);
29
+ }
30
+ }
31
+
32
+ releaseCall(callId) {
33
+ if (callId) this.byCall.delete(String(callId));
34
+ }
35
+
36
+ terminateCall(callId, { force = false } = {}) {
37
+ const children = [...(this.byCall.get(String(callId || "")) || [])];
38
+ for (const child of children) {
39
+ if (force) this.terminate(child, "SIGKILL");
40
+ else this.terminateWithEscalation(child);
41
+ }
42
+ return children.length;
43
+ }
44
+
45
+ terminateAll(signal = "SIGTERM", escalate = false) {
46
+ for (const child of [...this.active]) {
47
+ if (escalate && signal !== "SIGKILL") this.terminateWithEscalation(child);
48
+ else this.terminate(child, signal);
49
+ }
50
+ }
51
+
52
+ snapshot() {
53
+ return { active_processes: this.active.size, calls_with_processes: this.byCall.size };
54
+ }
55
+ }