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.
- package/CHANGELOG.md +27 -0
- package/README.md +12 -2
- package/SECURITY.md +7 -0
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +5 -3
- package/docs/AUDIT.md +12 -1
- package/docs/LOGGING.md +7 -1
- package/docs/OPERATIONS.md +9 -2
- package/docs/POLICY_REFERENCE.md +88 -0
- package/docs/TESTING.md +7 -0
- package/package.json +14 -3
- package/scripts/coverage-check.mjs +108 -0
- package/scripts/generate-policy-reference.mjs +95 -0
- package/src/local/agent-context.mjs +1 -103
- package/src/local/app-automation.mjs +7 -11
- package/src/local/browser-bridge.mjs +26 -156
- package/src/local/browser-extension-protocol.mjs +75 -0
- package/src/local/browser-pairing-store.mjs +83 -0
- package/src/local/call-registry.mjs +113 -0
- package/src/local/capability-ranking.mjs +103 -0
- package/src/local/cli-local-admin.mjs +308 -0
- package/src/local/cli-options.mjs +151 -0
- package/src/local/cli-policy.mjs +89 -0
- package/src/local/cli.mjs +16 -521
- package/src/local/errors.mjs +122 -0
- package/src/local/git-service.mjs +88 -0
- package/src/local/lifecycle.mjs +64 -0
- package/src/local/log.mjs +50 -19
- package/src/local/managed-job-plan.mjs +235 -0
- package/src/local/managed-jobs.mjs +16 -220
- package/src/local/observability.mjs +83 -0
- package/src/local/policy.mjs +148 -0
- package/src/local/process-execution.mjs +153 -0
- package/src/local/process-sessions.mjs +10 -20
- package/src/local/process-tracker.mjs +55 -0
- package/src/local/runtime.mjs +154 -672
- package/src/local/service.mjs +1 -0
- package/src/local/stdio.mjs +3 -11
- package/src/local/tool-executor.mjs +102 -0
- package/src/local/tools.mjs +21 -104
- package/src/local/workspace-file-service.mjs +451 -0
- package/src/shared/policy-contract.json +54 -0
- package/src/shared/tool-catalog.json +4 -4
- 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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|