machine-bridge-mcp 0.4.2 → 0.6.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.
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
4
- import { chmod, link, lstat, mkdir, opendir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import { chmod, link, lstat, mkdir, open, opendir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import path, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import WebSocket from "ws";
@@ -9,7 +9,9 @@ import { applyUpdateHunks, parsePatchEnvelope } from "./patch.mjs";
9
9
  import { executionEnv, workspaceShellCommand } from "./shell.mjs";
10
10
  import { MAX_COMMAND_BYTES, ProcessSessionManager, terminateProcessTree, validateArgv } from "./process-sessions.mjs";
11
11
  export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
12
- import { MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, toolNamesForPolicy } from "./tools.mjs";
12
+ import { MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, SERVER_NAME, toolNamesForPolicy } from "./tools.mjs";
13
+ import { classifyOperationalError } from "./log.mjs";
14
+ import { ManagedJobManager } from "./managed-jobs.mjs";
13
15
 
14
16
  export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
15
17
  const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
@@ -18,9 +20,10 @@ const MAX_DIRECTORY_ENTRIES = 10_000;
18
20
  const MAX_PATH_RESULT_BYTES = 4 * 1024 * 1024;
19
21
  const MAX_WALK_ENTRIES = 200_000;
20
22
  const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
23
+ const SLOW_TOOL_CALL_MS = 30_000;
21
24
 
22
25
  export class LocalDaemon {
23
- constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null }) {
26
+ constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null, jobRoot = "", resources = {}, resourceStatePath = "", recoverJobs = true }) {
24
27
  this.workerUrl = workerUrl ? normalizeWorkerUrl(workerUrl) : "";
25
28
  if (this.workerUrl && (typeof secret !== "string" || secret.length < 16)) throw new Error("daemon secret is missing or too short");
26
29
  this.secret = secret || "";
@@ -44,6 +47,16 @@ export class LocalDaemon {
44
47
  this.reconnectAttempt = 0;
45
48
  this.mutationQueue = Promise.resolve();
46
49
  this.runtimeDir = createRuntimeDir();
50
+ if (typeof jobRoot !== "string" || !jobRoot.trim()) throw new Error("persistent managed-job root is required");
51
+ this.managedJobManager = new ManagedJobManager({
52
+ jobRoot,
53
+ workspace: this.workspace,
54
+ policy: this.policy,
55
+ resources,
56
+ resourceStatePath,
57
+ logger: this.logger,
58
+ recover: recoverJobs,
59
+ });
47
60
  this.processSessionManager = new ProcessSessionManager({
48
61
  workspace: this.workspace,
49
62
  policy: this.policy,
@@ -66,17 +79,25 @@ export class LocalDaemon {
66
79
 
67
80
  runtimeInfo() {
68
81
  return {
69
- name: "machine-bridge-mcp",
82
+ name: SERVER_NAME,
70
83
  protocol_version: MCP_PROTOCOL_VERSION,
71
84
  supported_protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
72
85
  workspace: this.displayPath(this.workspace),
73
86
  workspace_name: basename(this.workspace),
74
87
  policy: this.policy,
88
+ enforcement: {
89
+ filesystem_scope: this.policy.unrestrictedPaths ? "local-user-accessible" : "workspace",
90
+ sensitive_filename_filter: false,
91
+ operating_system_permissions_apply: true,
92
+ host_policy_is_independent: true,
93
+ },
75
94
  tools: ["server_info", ...this.tools()],
76
95
  runtime: {
77
96
  environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
78
97
  runtime_dir: this.policy.exposeAbsolutePaths ? this.runtimeDir : "<private-runtime-dir>",
79
98
  process_sessions: this.processSessionManager.status(),
99
+ managed_jobs: this.managedJobManager.status(),
100
+ local_resources: this.managedJobManager.resourceInfo(),
80
101
  },
81
102
  };
82
103
  }
@@ -109,7 +130,7 @@ export class LocalDaemon {
109
130
  connect() {
110
131
  if (this.closed) return;
111
132
  const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
112
- this.logger.info?.("connecting daemon websocket", { endpoint: redactUrl(wsUrl) });
133
+ this.logger.debug?.("connecting to remote relay", { endpoint: redactUrl(wsUrl) });
113
134
  const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret } });
114
135
  this.ws = socket;
115
136
 
@@ -119,7 +140,7 @@ export class LocalDaemon {
119
140
  return;
120
141
  }
121
142
  this.reconnectAttempt = 0;
122
- this.logger.info?.("daemon websocket connected");
143
+ this.logger.info?.("remote relay connected");
123
144
  this.send({ type: "hello", tools: this.tools(), policy: this.policy, protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS });
124
145
  if (this.connectedOnceResolve) {
125
146
  this.connectedOnceResolve(true);
@@ -138,7 +159,7 @@ export class LocalDaemon {
138
159
  return;
139
160
  }
140
161
  void this.handleMessage(raw).catch(error => {
141
- this.logger.error?.("daemon message handler failed", { error: this.safeErrorMessage(error) });
162
+ this.logger.error?.("daemon message handler failed", { error_class: classifyOperationalError(error) });
142
163
  });
143
164
  });
144
165
 
@@ -157,13 +178,13 @@ export class LocalDaemon {
157
178
  this.logger.warn?.("daemon connection permanently superseded", fields);
158
179
  queueMicrotask(() => {
159
180
  try { this.onSuperseded?.(); } catch (error) {
160
- this.logger.error?.("daemon superseded callback failed", { error_class: classifyError(error) });
181
+ this.logger.error?.("daemon superseded callback failed", { error_class: classifyOperationalError(error) });
161
182
  }
162
183
  });
163
184
  return;
164
185
  }
165
- if (this.closed) this.logger.info?.("daemon websocket closed", fields);
166
- else this.logger.warn?.("daemon websocket disconnected", fields);
186
+ if (this.closed) this.logger.debug?.("remote relay closed", fields);
187
+ else this.logger.warn?.("remote relay disconnected", fields);
167
188
  if (!this.closed) {
168
189
  const delay = reconnectDelay(this.reconnectAttempt++);
169
190
  this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay });
@@ -174,8 +195,8 @@ export class LocalDaemon {
174
195
 
175
196
  socket.on("error", error => {
176
197
  if (this.ws !== socket) return;
177
- if (this.closed) this.logger.info?.("daemon websocket closed during shutdown", { error: boundedErrorMessage(error) });
178
- else this.logger.error?.("daemon websocket error", { error: boundedErrorMessage(error) });
198
+ if (this.closed) this.logger.debug?.("remote relay closed during shutdown", { error_class: classifyOperationalError(error) });
199
+ else this.logger.error?.("remote relay error", { error_class: classifyOperationalError(error) });
179
200
  });
180
201
  }
181
202
 
@@ -185,7 +206,7 @@ export class LocalDaemon {
185
206
  this.ws.send(JSON.stringify(value));
186
207
  return true;
187
208
  } catch (error) {
188
- this.logger.warn?.("daemon websocket send failed", { error: boundedErrorMessage(error) });
209
+ this.logger.warn?.("remote relay send failed", { error_class: classifyOperationalError(error) });
189
210
  return false;
190
211
  }
191
212
  }
@@ -229,11 +250,15 @@ export class LocalDaemon {
229
250
  const result = await this.executeTool(tool, argumentsValue, { callId: id });
230
251
  if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
231
252
  this.send({ type: "tool_result", id, ok: true, result });
232
- this.logger.info?.("tool call completed", { call_id: shortCallId(id), tool, duration_ms: Date.now() - started, ok: true });
253
+ const durationMs = Date.now() - started;
254
+ if (durationMs >= SLOW_TOOL_CALL_MS) this.logger.info?.("slow tool call completed", { tool, duration_ms: durationMs });
255
+ else this.logger.debug?.("tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
233
256
  } catch (error) {
234
257
  const safeError = this.safeErrorMessage(error);
235
258
  this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
236
- this.logger.warn?.("tool call failed", { call_id: shortCallId(id), tool, duration_ms: Date.now() - started, ok: false, error_class: classifyError(error) });
259
+ const durationMs = Date.now() - started;
260
+ this.logger.warn?.("tool call failed", { tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
261
+ this.logger.debug?.("tool call failure correlation", { call_id: shortCallId(id) });
237
262
  } finally {
238
263
  clearTimeout(deadline);
239
264
  this.activeToolCalls -= 1;
@@ -258,7 +283,7 @@ export class LocalDaemon {
258
283
  }, 2000);
259
284
  timer.unref?.();
260
285
  }
261
- this.logger.info?.("tool call cancellation requested", { call_id: shortCallId(callId), reason });
286
+ this.logger.debug?.("tool call cancellation requested", { call_id: shortCallId(callId), reason });
262
287
  }
263
288
 
264
289
  async executeTool(tool, args, context = {}) {
@@ -279,6 +304,13 @@ export class LocalDaemon {
279
304
  case "git_diff": return this.gitDiff(args, context);
280
305
  case "git_log": return this.gitLog(args, context);
281
306
  case "git_show": return this.gitShow(args, context);
307
+ case "diagnose_runtime": return this.diagnoseRuntime(context);
308
+ case "list_local_resources": return this.managedJobManager.listResources();
309
+ case "stage_job": return this.managedJobManager.stage(args);
310
+ case "start_job": return this.managedJobManager.start(args);
311
+ case "list_jobs": return this.managedJobManager.list(args);
312
+ case "read_job": return this.managedJobManager.read(args);
313
+ case "cancel_job": return this.managedJobManager.cancel(args);
282
314
  case "run_process": return this.runDirectProcess(args, context);
283
315
  case "start_process": return this.processSessionManager.start(args, context);
284
316
  case "read_process": return this.processSessionManager.read(args, context);
@@ -366,11 +398,9 @@ export class LocalDaemon {
366
398
  }
367
399
  if (!args.path) throw new Error("path is required");
368
400
  const full = await this.resolveExistingPath(args.path);
369
- const info = await stat(full);
370
- if (!info.isFile()) throw new Error("path is not a file");
371
- if (info.size > MAX_WRITE_BYTES) throw new Error(`file exceeds maximum readable text size (${info.size} > ${MAX_WRITE_BYTES})`);
372
401
  this.throwIfCancelled(context);
373
- const content = await readUtf8File(full);
402
+ const { buffer, info } = await readBoundedFile(full, MAX_WRITE_BYTES, "readable text file");
403
+ const content = decodeUtf8(buffer);
374
404
  this.throwIfCancelled(context);
375
405
  const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
376
406
  const startLine = args.start_line === undefined ? 1 : clampInt(args.start_line, 1, 1, Number.MAX_SAFE_INTEGER);
@@ -399,11 +429,8 @@ export class LocalDaemon {
399
429
  async viewImage(args, context = {}) {
400
430
  if (!args.path) throw new Error("path is required");
401
431
  const full = await this.resolveExistingPath(args.path);
402
- const info = await stat(full);
403
- if (!info.isFile()) throw new Error("path is not a file");
404
- if (info.size > MAX_IMAGE_BYTES) throw new Error(`image exceeds maximum size (${info.size} > ${MAX_IMAGE_BYTES})`);
405
432
  this.throwIfCancelled(context);
406
- const buffer = await readFile(full);
433
+ const { buffer, info } = await readBoundedFile(full, MAX_IMAGE_BYTES, "image");
407
434
  this.throwIfCancelled(context);
408
435
  const mimeType = detectImageMime(buffer);
409
436
  if (!mimeType) throw new Error("unsupported image format; expected PNG, JPEG, GIF, or WebP");
@@ -545,10 +572,9 @@ export class LocalDaemon {
545
572
 
546
573
  async searchOneFile(full, query, matches, max, context = {}) {
547
574
  this.throwIfCancelled(context);
548
- const info = await stat(full).catch(() => null);
549
- if (!info?.isFile() || info.size > 1024 * 1024) return;
550
- const buffer = await readFile(full).catch(() => null);
551
- if (!buffer || buffer.includes(0)) return;
575
+ const bounded = await readBoundedFile(full, 1024 * 1024, "search file").catch(() => null);
576
+ if (!bounded || bounded.buffer.includes(0)) return;
577
+ const buffer = bounded.buffer;
552
578
  let text;
553
579
  try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch { return; }
554
580
  if (!text) return;
@@ -623,6 +649,86 @@ export class LocalDaemon {
623
649
  return { ok: true, target, root, pathspec: repoRelative || "" };
624
650
  }
625
651
 
652
+ async diagnoseRuntime(context = {}) {
653
+ this.throwIfCancelled(context);
654
+ const checks = [];
655
+ checks.push({
656
+ layer: "mcp-host-to-daemon",
657
+ ok: true,
658
+ detail: "This diagnostic request reached the local Machine Bridge runtime.",
659
+ });
660
+ checks.push({
661
+ layer: "machine-bridge-policy",
662
+ ok: this.policy.execMode === "direct" || this.policy.execMode === "shell",
663
+ detail: `profile=${this.policy.profile}; exec_mode=${this.policy.execMode}; unrestricted_paths=${this.policy.unrestrictedPaths}`,
664
+ });
665
+
666
+ const probe = join(this.runtimeDir, `.diagnostic-${process.pid}-${randomBytes(6).toString("hex")}`);
667
+ try {
668
+ await writeFile(probe, "ok\n", { mode: 0o600, flag: "wx" });
669
+ const { buffer } = await readBoundedFile(probe, 64, "diagnostic file");
670
+ checks.push({ layer: "local-filesystem", ok: buffer.toString("utf8") === "ok\n", error_class: null });
671
+ } catch (error) {
672
+ checks.push({ layer: "local-filesystem", ok: false, error_class: classifyOperationalError(error) });
673
+ } finally {
674
+ await rm(probe, { force: true }).catch(() => {});
675
+ }
676
+
677
+ if (this.policy.execMode === "direct" || this.policy.execMode === "shell") {
678
+ const direct = await this.runProcess(
679
+ process.execPath,
680
+ ["-e", "process.stdout.write('ok')"],
681
+ 5000,
682
+ true,
683
+ 1024,
684
+ context,
685
+ this.workspace,
686
+ ).catch((error) => ({ code: 127, stdout: "", stderr: "", error_class: classifyOperationalError(error) }));
687
+ checks.push({
688
+ layer: "local-process-spawn",
689
+ ok: direct.code === 0 && direct.stdout === "ok",
690
+ error_class: direct.error_class || (direct.code === 0 ? null : classifyOperationalError(direct.stderr || direct.stdout || "execution failed")),
691
+ });
692
+ } else {
693
+ checks.push({ layer: "local-process-spawn", ok: false, skipped: true, error_class: "policy_denied" });
694
+ }
695
+
696
+ if (this.policy.execMode === "shell") {
697
+ const shell = workspaceShellCommand(process.platform === "win32" ? "cd" : "pwd");
698
+ const result = await this.runProcess(shell.cmd, shell.args, 5000, true, 4096, context, this.workspace)
699
+ .catch((error) => ({ code: 127, error_class: classifyOperationalError(error) }));
700
+ checks.push({
701
+ layer: "local-shell",
702
+ ok: result.code === 0,
703
+ error_class: result.error_class || (result.code === 0 ? null : classifyOperationalError(result.stderr || result.stdout || "execution failed")),
704
+ });
705
+ } else {
706
+ checks.push({ layer: "local-shell", ok: false, skipped: true, error_class: "policy_denied" });
707
+ }
708
+
709
+ const storage = this.managedJobManager.diagnoseStorage();
710
+ checks.push({ layer: "managed-job-storage", ...storage });
711
+ const resources = this.managedJobManager.listResources();
712
+ checks.push({
713
+ layer: "local-resource-registry",
714
+ ok: resources.resources.every((resource) => resource.available),
715
+ registered: resources.count,
716
+ unavailable: resources.resources.filter((resource) => !resource.available).map((resource) => ({ name: resource.name, error_class: resource.error_class })),
717
+ });
718
+
719
+ return {
720
+ request_reached_local_runtime: true,
721
+ interpretation: {
722
+ tool_call_blocked_before_response: "host/platform or connector gateway",
723
+ diagnostic_reached_daemon_but_spawn_failed: "local OS, endpoint security, shell configuration, or Machine Bridge policy",
724
+ managed_job_accepted_then_later_tools_blocked: "job continues independently; inspect with local CLI or a later read_job call",
725
+ },
726
+ policy: this.policy,
727
+ checks,
728
+ ok: checks.filter((check) => !check.skipped).every((check) => check.ok),
729
+ };
730
+ }
731
+
626
732
  async runDirectProcess(args, context = {}) {
627
733
  if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error("run_process is disabled by daemon policy");
628
734
  const argv = validateArgv(args.argv);
@@ -850,13 +956,36 @@ function assertContainedPath(root, target) {
850
956
  throw new Error("path is outside the configured workspace; restart with --unrestricted-paths to allow it");
851
957
  }
852
958
 
853
- async function readUtf8File(filePath) {
854
- const buffer = await readFile(filePath);
959
+ async function readBoundedFile(filePath, maxBytes, label) {
960
+ const handle = await open(filePath, "r");
961
+ try {
962
+ const info = await handle.stat();
963
+ if (!info.isFile()) throw new Error(`${label} is not a regular file`);
964
+ if (info.size > maxBytes) throw new Error(`${label} exceeds maximum size (${info.size} > ${maxBytes})`);
965
+ const buffer = Buffer.alloc(info.size);
966
+ let offset = 0;
967
+ while (offset < buffer.length) {
968
+ const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
969
+ if (bytesRead === 0) break;
970
+ offset += bytesRead;
971
+ }
972
+ return { buffer: buffer.subarray(0, offset), info };
973
+ } finally {
974
+ await handle.close();
975
+ }
976
+ }
977
+
978
+ function decodeUtf8(buffer) {
855
979
  try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
856
980
  throw new Error("file is not valid UTF-8 text");
857
981
  }
858
982
  }
859
983
 
984
+ async function readUtf8File(filePath) {
985
+ const { buffer } = await readBoundedFile(filePath, MAX_WRITE_BYTES, "text file");
986
+ return decodeUtf8(buffer);
987
+ }
988
+
860
989
  async function atomicWriteText(full, content, existing = null, options = {}) {
861
990
  await mkdir(dirname(full), { recursive: true });
862
991
  const temp = join(dirname(full), `.${basename(full)}.mbm-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
@@ -1010,18 +1139,6 @@ function isPlainRecord(value) {
1010
1139
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1011
1140
  }
1012
1141
 
1013
- function classifyError(error) {
1014
- const message = error instanceof Error ? error.message : String(error);
1015
- if (/cancel/i.test(message)) return "cancelled";
1016
- if (/timed out/i.test(message)) return "timeout";
1017
- if (/outside the configured workspace/i.test(message)) return "path_boundary";
1018
- if (/disabled by daemon policy|requires .* mode/i.test(message)) return "policy_denied";
1019
- if (/not found|ENOENT/i.test(message)) return "not_found";
1020
- if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
1021
- if (/maximum|exceeds|max_bytes|too many/i.test(message)) return "limit_exceeded";
1022
- if (/invalid|must|requires|ambiguous|mismatch/i.test(message)) return "invalid_request";
1023
- return "execution_failed";
1024
- }
1025
1142
 
1026
1143
  function equivalentPathPrefixes(...values) {
1027
1144
  const prefixes = new Set(values.filter(Boolean).map((value) => String(value)));