machine-bridge-mcp 0.1.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.
@@ -0,0 +1,441 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir, mkdtemp, opendir, readFile, rm, stat, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import path, { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
7
+ import WebSocket from "ws";
8
+ import { executionEnv, workspaceShellCommand } from "./shell.mjs";
9
+
10
+ const ALL_TOOL_NAMES = [
11
+ "project_overview",
12
+ "list_roots",
13
+ "list_dir",
14
+ "list_files",
15
+ "read_file",
16
+ "write_file",
17
+ "search_text",
18
+ "git_status",
19
+ "git_diff",
20
+ "exec_command",
21
+ ];
22
+
23
+ export class LocalDaemon {
24
+ constructor({ workerUrl, secret, workspace, policy, logger = console }) {
25
+ this.workerUrl = String(workerUrl || "").replace(/\/+$/, "");
26
+ this.secret = secret;
27
+ this.workspace = resolve(workspace || process.cwd());
28
+ this.policy = {
29
+ allowWrite: policy?.allowWrite !== false,
30
+ allowExec: policy?.allowExec !== false,
31
+ unrestrictedPaths: policy?.unrestrictedPaths !== false,
32
+ minimalEnv: policy?.minimalEnv !== false,
33
+ };
34
+ this.logger = logger;
35
+ this.closed = false;
36
+ this.ws = null;
37
+ this.heartbeat = null;
38
+ this.reconnectTimer = null;
39
+ this.connectedOnce = null;
40
+ this.connectedOnceResolve = null;
41
+ this.connectedOnceReject = null;
42
+ }
43
+
44
+ tools() {
45
+ return ALL_TOOL_NAMES.filter(name => {
46
+ if (name === "write_file") return this.policy.allowWrite;
47
+ if (name === "exec_command") return this.policy.allowExec;
48
+ return true;
49
+ });
50
+ }
51
+
52
+ start() {
53
+ this.closed = false;
54
+ this.connectedOnce = new Promise((resolvePromise, rejectPromise) => {
55
+ this.connectedOnceResolve = resolvePromise;
56
+ this.connectedOnceReject = rejectPromise;
57
+ });
58
+ this.connect();
59
+ return this.connectedOnce;
60
+ }
61
+
62
+ stop() {
63
+ this.closed = true;
64
+ if (this.heartbeat) clearInterval(this.heartbeat);
65
+ this.heartbeat = null;
66
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
67
+ this.reconnectTimer = null;
68
+ this.ws?.close();
69
+ this.ws = null;
70
+ }
71
+
72
+ connect() {
73
+ if (this.closed) return;
74
+ const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
75
+ this.logger.info?.(`connecting daemon websocket: ${wsUrl}`);
76
+ this.ws = new WebSocket(wsUrl, {
77
+ headers: {
78
+ "X-Bridge-Token": this.secret,
79
+ "X-Daemon-Id": `local-${process.pid}`,
80
+ },
81
+ });
82
+
83
+ this.ws.on("open", () => {
84
+ this.logger.info?.("daemon websocket connected");
85
+ this.send({
86
+ type: "hello",
87
+ workspace_hash: sha256(this.workspace),
88
+ workspace_name: basename(this.workspace),
89
+ tools: this.tools(),
90
+ policy: this.policy,
91
+ });
92
+ if (this.connectedOnceResolve) {
93
+ this.connectedOnceResolve(true);
94
+ this.connectedOnceResolve = null;
95
+ this.connectedOnceReject = null;
96
+ }
97
+ if (this.heartbeat) clearInterval(this.heartbeat);
98
+ this.heartbeat = setInterval(() => this.send({ type: "heartbeat", ts: Date.now() }), 25_000);
99
+ this.heartbeat.unref?.();
100
+ });
101
+
102
+ this.ws.on("message", data => {
103
+ void this.handleMessage(String(data)).catch(error => {
104
+ this.logger.error?.(`daemon message handler failed: ${error.message}`);
105
+ });
106
+ });
107
+
108
+ this.ws.on("close", (code, reason) => {
109
+ if (this.heartbeat) clearInterval(this.heartbeat);
110
+ this.heartbeat = null;
111
+ const text = `daemon websocket closed: ${code} ${String(reason || "")}`;
112
+ if (this.closed) this.logger.info?.(text);
113
+ else this.logger.warn?.(text);
114
+ if (!this.closed) {
115
+ this.reconnectTimer = setTimeout(() => this.connect(), 3000);
116
+ this.reconnectTimer.unref?.();
117
+ }
118
+ });
119
+
120
+ this.ws.on("error", error => {
121
+ // Do not reject the first-connection promise: the close handler schedules
122
+ // reconnects, and first deploy propagation can briefly race WebSocket setup.
123
+ if (this.closed) this.logger.info?.(`daemon websocket closed during shutdown: ${error.message}`);
124
+ else this.logger.error?.(`daemon websocket error: ${error.message}`);
125
+ });
126
+ }
127
+
128
+ send(value) {
129
+ if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(value));
130
+ }
131
+
132
+ async handleMessage(raw) {
133
+ let message;
134
+ try {
135
+ message = JSON.parse(raw);
136
+ } catch {
137
+ this.logger.warn?.("invalid websocket JSON");
138
+ return;
139
+ }
140
+ if (message.type === "welcome" || message.type === "hello_ack" || message.type === "pong") return;
141
+ if (message.type !== "tool_call") {
142
+ this.logger.warn?.(`unknown websocket message: ${message.type}`);
143
+ return;
144
+ }
145
+
146
+ const id = message.id;
147
+ try {
148
+ const result = await this.executeTool(message.tool, message.arguments || {});
149
+ this.send({ type: "tool_result", id, ok: true, result });
150
+ } catch (error) {
151
+ this.send({ type: "tool_result", id, ok: false, error: { message: error.message } });
152
+ }
153
+ }
154
+
155
+ async executeTool(tool, args) {
156
+ switch (tool) {
157
+ case "project_overview": return this.projectOverview();
158
+ case "list_roots": return this.listRoots();
159
+ case "list_dir": return this.listDir(args.path || ".");
160
+ case "list_files": return this.listFiles(args.path || ".", clampInt(args.max_files, 1000, 1, 10000));
161
+ case "read_file": return this.readFile(args.path, clampInt(args.max_bytes, 1024 * 1024, 1, 5 * 1024 * 1024));
162
+ case "write_file": return this.writeFile(args);
163
+ case "search_text": return this.searchText(args);
164
+ case "git_status": return this.runProcess("git", ["-C", this.workspace, "status", "--short"], 30_000, true);
165
+ case "git_diff": return this.gitDiff(args);
166
+ case "exec_command": return this.execCommand(args.command, clampInt(args.timeout_seconds, 120, 1, 600));
167
+ default: throw new Error(`unknown daemon tool: ${tool}`);
168
+ }
169
+ }
170
+
171
+ async projectOverview() {
172
+ const top = await this.listDir(".").catch(error => ({ error: error.message, entries: [] }));
173
+ const git = await this.runProcess("git", ["-C", this.workspace, "rev-parse", "--show-toplevel"], 10_000, true);
174
+ return {
175
+ workspace: this.workspace,
176
+ workspaceName: basename(this.workspace),
177
+ gitRoot: git.code === 0 ? git.stdout.trim() : "",
178
+ policy: this.policy,
179
+ tools: this.tools(),
180
+ topLevel: top.entries || [],
181
+ };
182
+ }
183
+
184
+ listRoots() {
185
+ const roots = [{ name: basename(this.workspace), path: this.workspace, default: true }];
186
+ const home = process.env.HOME;
187
+ if (home && home !== this.workspace) roots.push({ name: "home", path: home, default: false });
188
+ roots.push({ name: "filesystem-root", path: path.parse(this.workspace).root, default: false });
189
+ return { roots };
190
+ }
191
+
192
+ async listDir(inputPath) {
193
+ const full = this.resolvePath(inputPath);
194
+ const entries = [];
195
+ for await (const entry of await opendir(full)) {
196
+ const entryPath = resolve(full, entry.name);
197
+ const info = await stat(entryPath).catch(() => null);
198
+ entries.push({
199
+ name: entry.name,
200
+ path: entryPath,
201
+ type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other",
202
+ size: info?.size ?? 0,
203
+ });
204
+ }
205
+ entries.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
206
+ return { path: full, entries };
207
+ }
208
+
209
+ async listFiles(inputPath, maxFiles) {
210
+ const root = this.resolvePath(inputPath);
211
+ const info = await stat(root);
212
+ if (info.isFile()) return { path: root, files: [root], truncated: false };
213
+ if (!info.isDirectory()) throw new Error("path is not a file or directory");
214
+ const files = [];
215
+ await this.walk(root, async full => {
216
+ if (files.length >= maxFiles) return false;
217
+ files.push(full);
218
+ return true;
219
+ });
220
+ return { path: root, files, truncated: files.length >= maxFiles };
221
+ }
222
+
223
+ async readFile(inputPath, maxBytes) {
224
+ if (!inputPath) throw new Error("path is required");
225
+ const full = this.resolvePath(inputPath);
226
+ const info = await stat(full);
227
+ if (!info.isFile()) throw new Error("path is not a file");
228
+ if (info.size > maxBytes) throw new Error(`file exceeds max_bytes (${info.size} > ${maxBytes})`);
229
+ const content = await readFile(full, "utf8");
230
+ return { path: full, size: info.size, sha256: sha256(content), content };
231
+ }
232
+
233
+ async writeFile(args) {
234
+ if (!this.policy.allowWrite) throw new Error("write_file is disabled by daemon policy");
235
+ if (!args.path) throw new Error("path is required");
236
+ const full = this.resolvePath(args.path);
237
+ if (args.create_only && existsSync(full)) throw new Error("file exists and create_only=true");
238
+ if (args.expected_sha256 && existsSync(full)) {
239
+ const current = await readFile(full, "utf8");
240
+ if (sha256(current) !== args.expected_sha256) throw new Error("expected_sha256 mismatch");
241
+ }
242
+ await mkdir(dirname(full), { recursive: true });
243
+ await writeFile(full, String(args.content ?? ""), "utf8");
244
+ const content = await readFile(full, "utf8");
245
+ return { ok: true, path: full, sha256: sha256(content), bytes: Buffer.byteLength(content) };
246
+ }
247
+
248
+ async searchText(args) {
249
+ const query = String(args.query || "");
250
+ if (!query) throw new Error("query is required");
251
+ const root = this.resolvePath(args.path || ".");
252
+ const max = clampInt(args.max_matches, 100, 1, 1000);
253
+ const maxFiles = clampInt(args.max_files, 10000, 1, 100000);
254
+ let visitedFiles = 0;
255
+ const matches = [];
256
+ const rootInfo = await stat(root);
257
+ if (rootInfo.isFile()) {
258
+ await this.searchOneFile(root, query, matches, max);
259
+ return { query, root, matches, visited_files: 1, truncated: matches.length >= max };
260
+ }
261
+ if (!rootInfo.isDirectory()) throw new Error("path is not a file or directory");
262
+ await this.walk(root, async full => {
263
+ if (matches.length >= max || visitedFiles >= maxFiles) return false;
264
+ visitedFiles += 1;
265
+ await this.searchOneFile(full, query, matches, max);
266
+ return matches.length < max && visitedFiles < maxFiles;
267
+ });
268
+ return { query, root, matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles };
269
+ }
270
+
271
+ async searchOneFile(full, query, matches, max) {
272
+ const info = await stat(full).catch(() => null);
273
+ if (!info?.isFile() || info.size > 1024 * 1024) return;
274
+ const text = await readFile(full, "utf8").catch(() => "");
275
+ if (!text) return;
276
+ const lines = text.split(/\r?\n/);
277
+ for (let index = 0; index < lines.length; index += 1) {
278
+ if (lines[index].includes(query)) {
279
+ matches.push({ path: full, line: index + 1, text: lines[index].slice(0, 500) });
280
+ if (matches.length >= max) break;
281
+ }
282
+ }
283
+ }
284
+
285
+ async gitDiff(args) {
286
+ const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, 5 * 1024 * 1024);
287
+ const target = args.path ? this.resolvePath(args.path) : this.workspace;
288
+ const result = await this.runProcess("git", ["-C", this.workspace, "diff", "--", target], 60_000, true, maxBytes);
289
+ return { ...result, path: target };
290
+ }
291
+
292
+ async execCommand(command, timeoutSeconds) {
293
+ if (!this.policy.allowExec) throw new Error("exec_command is disabled by daemon policy");
294
+ if (!command || typeof command !== "string") throw new Error("command is required");
295
+ const shell = workspaceShellCommand(command);
296
+ return this.runProcess(shell.cmd, shell.args, clampInt(timeoutSeconds, 120, 1, 600) * 1000);
297
+ }
298
+
299
+ async runProcess(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = 512 * 1024) {
300
+ return new Promise((resolvePromise, reject) => {
301
+ const child = spawn(cmd, args, { cwd: this.workspace, env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false }) });
302
+ let stdout = "";
303
+ let stderr = "";
304
+ let stdoutTruncated = 0;
305
+ let stderrTruncated = 0;
306
+ let timedOut = false;
307
+ let killTimer = null;
308
+ const timer = setTimeout(() => {
309
+ timedOut = true;
310
+ child.kill("SIGTERM");
311
+ killTimer = setTimeout(() => child.kill("SIGKILL"), 2000);
312
+ killTimer.unref?.();
313
+ }, timeoutMs);
314
+ timer.unref?.();
315
+ const clearTimers = () => {
316
+ clearTimeout(timer);
317
+ if (killTimer) clearTimeout(killTimer);
318
+ };
319
+ child.stdout.on("data", chunk => {
320
+ const next = appendLimited(stdout, chunk, maxOutputBytes);
321
+ stdout = next.value;
322
+ stdoutTruncated += next.truncated;
323
+ });
324
+ child.stderr.on("data", chunk => {
325
+ const next = appendLimited(stderr, chunk, maxOutputBytes);
326
+ stderr = next.value;
327
+ stderrTruncated += next.truncated;
328
+ });
329
+ child.on("error", error => {
330
+ clearTimers();
331
+ if (allowFailure) resolvePromise({ code: 127, stdout, stderr: error.message });
332
+ else reject(error);
333
+ });
334
+ child.on("close", code => {
335
+ clearTimers();
336
+ const result = { code, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: finalizeOutput(stderr, stderrTruncated) };
337
+ if (timedOut) {
338
+ reject(new Error(`command timed out after ${timeoutMs}ms`));
339
+ return;
340
+ }
341
+ if (code === 0 || allowFailure) resolvePromise(result);
342
+ else reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
343
+ });
344
+ });
345
+ }
346
+
347
+ async walk(root, onFile) {
348
+ const stack = [root];
349
+ while (stack.length) {
350
+ const current = stack.pop();
351
+ const entries = await opendir(current).catch(() => null);
352
+ if (!entries) continue;
353
+ for await (const entry of entries) {
354
+ const full = resolve(current, entry.name);
355
+ if (entry.isDirectory()) stack.push(full);
356
+ else if (entry.isFile()) {
357
+ const keepGoing = await onFile(full);
358
+ if (keepGoing === false) return;
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ resolvePath(inputPath = ".") {
365
+ const raw = String(inputPath || ".");
366
+ return isAbsolute(raw) ? resolve(raw) : resolve(this.workspace, raw);
367
+ }
368
+ }
369
+
370
+ export function sha256(value) {
371
+ return createHash("sha256").update(String(value)).digest("hex");
372
+ }
373
+
374
+ function appendLimited(current, chunk, max) {
375
+ const text = String(chunk || "");
376
+ const budget = Math.max(0, max - Buffer.byteLength(current));
377
+ const textBytes = Buffer.byteLength(text);
378
+ if (textBytes <= budget) return { value: current + text, truncated: 0 };
379
+ const slice = Buffer.from(text).subarray(0, budget).toString();
380
+ return { value: current + slice, truncated: textBytes - Buffer.byteLength(slice) };
381
+ }
382
+
383
+ function finalizeOutput(value, truncated) {
384
+ return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
385
+ }
386
+
387
+ function clampInt(value, fallback, min, max) {
388
+ const parsed = Number.parseInt(String(value ?? ""), 10);
389
+ const number = Number.isFinite(parsed) ? parsed : fallback;
390
+ return Math.min(Math.max(number, min), max);
391
+ }
392
+
393
+ export async function daemonSelfTest() {
394
+ const workspace = await mkdtemp(join(tmpdir(), "mbm-daemon-workspace-"));
395
+ const outside = await mkdtemp(join(tmpdir(), "mbm-daemon-outside-"));
396
+ const daemon = new LocalDaemon({
397
+ workerUrl: "https://example.invalid",
398
+ secret: "test",
399
+ workspace,
400
+ policy: { allowWrite: true, allowExec: true, unrestrictedPaths: true },
401
+ logger: { info() {}, warn() {}, error() {} },
402
+ });
403
+ const previousSecret = process.env.MBM_DAEMON_SELFTEST_SECRET;
404
+ process.env.MBM_DAEMON_SELFTEST_SECRET = "should-not-leak";
405
+ try {
406
+ await writeFile(join(workspace, ".env"), "SECRET=visible", "utf8");
407
+ await writeFile(join(workspace, "visible.txt"), "needle", "utf8");
408
+ await writeFile(join(outside, "outside.txt"), "outside-needle", "utf8");
409
+
410
+ const envFile = await daemon.readFile(".env", 1024);
411
+ if (!envFile.content.includes("SECRET=visible")) throw new Error(".env should be readable by default");
412
+
413
+ const outsideFile = await daemon.readFile(join(outside, "outside.txt"), 1024);
414
+ if (!outsideFile.content.includes("outside-needle")) throw new Error("absolute outside path should be readable");
415
+
416
+ const parentFile = await daemon.readFile(path.relative(workspace, join(outside, "outside.txt")), 1024);
417
+ if (!parentFile.content.includes("outside-needle")) throw new Error("relative path escaping workspace should be readable");
418
+
419
+ const writtenPath = join(outside, "written.txt");
420
+ await daemon.writeFile({ path: writtenPath, content: "written" });
421
+ const written = await readFile(writtenPath, "utf8");
422
+ if (written !== "written") throw new Error("absolute outside path should be writable");
423
+
424
+ const cappedSearch = await daemon.searchText({ path: workspace, query: "definitely-not-present", max_files: 1, max_matches: 10 });
425
+ if (cappedSearch.visited_files !== 1 || cappedSearch.truncated !== true) {
426
+ throw new Error("search_text max_files cap did not apply");
427
+ }
428
+
429
+ const command = await daemon.execCommand("printf ${MBM_DAEMON_SELFTEST_SECRET-unset}", 5);
430
+ if (command.stdout !== "unset") throw new Error("exec_command inherited unallowlisted environment variables");
431
+
432
+ const roots = daemon.listRoots();
433
+ if (!roots.roots.some(root => root.path === workspace)) throw new Error("workspace root missing");
434
+ } finally {
435
+ if (previousSecret === undefined) delete process.env.MBM_DAEMON_SELFTEST_SECRET;
436
+ else process.env.MBM_DAEMON_SELFTEST_SECRET = previousSecret;
437
+ await rm(workspace, { recursive: true, force: true }).catch(() => {});
438
+ await rm(outside, { recursive: true, force: true }).catch(() => {});
439
+ }
440
+ return true;
441
+ }
@@ -0,0 +1,28 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { daemonSelfTest } from "./daemon.mjs";
5
+ import { ensureWorkerSecrets, loadState, previewSecret, redactState, selectedWorkspace, setSelectedWorkspace } from "./state.mjs";
6
+
7
+ await daemonSelfTest();
8
+ await stateSelfTest();
9
+ console.log("local daemon/state self-test ok");
10
+
11
+ async function stateSelfTest() {
12
+ const stateRoot = await mkdtemp(join(tmpdir(), "mbm-state-test-"));
13
+ const workspace = await mkdtemp(join(tmpdir(), "mbm-state-workspace-"));
14
+ try {
15
+ setSelectedWorkspace(workspace, stateRoot);
16
+ if (selectedWorkspace(stateRoot) !== workspace) throw new Error("selected workspace was not persisted");
17
+ const state = loadState(workspace, { stateDir: stateRoot });
18
+ ensureWorkerSecrets(state, { rotateSecrets: true });
19
+ const redacted = redactState(state);
20
+ if (redacted.worker.oauthPassword === state.worker.oauthPassword) throw new Error("oauthPassword was not redacted");
21
+ if (redacted.worker.daemonSecret === state.worker.daemonSecret) throw new Error("daemonSecret was not redacted");
22
+ if (redacted.worker.oauthTokenVersion === state.worker.oauthTokenVersion) throw new Error("oauthTokenVersion was not redacted");
23
+ if (!previewSecret(state.worker.oauthPassword).includes("...")) throw new Error("previewSecret did not preview long secret");
24
+ } finally {
25
+ await rm(stateRoot, { recursive: true, force: true }).catch(() => {});
26
+ await rm(workspace, { recursive: true, force: true }).catch(() => {});
27
+ }
28
+ }