machine-bridge-mcp 0.3.3 → 0.4.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,46 +1,34 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { existsSync, realpathSync } from "node:fs";
4
- import { chmod, lstat, mkdir, mkdtemp, opendir, readFile, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
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";
5
5
  import { tmpdir } from "node:os";
6
- import path, { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
6
+ import path, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
7
  import WebSocket from "ws";
8
+ import { applyUpdateHunks, parsePatchEnvelope } from "./patch.mjs";
8
9
  import { executionEnv, workspaceShellCommand } from "./shell.mjs";
10
+ import { MAX_COMMAND_BYTES, ProcessSessionManager, terminateProcessTree, validateArgv } from "./process-sessions.mjs";
11
+ export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
12
+ import { MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, toolNamesForPolicy } from "./tools.mjs";
9
13
 
10
- const MAX_WRITE_BYTES = 5 * 1024 * 1024;
14
+ export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
11
15
  const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
12
16
  const MAX_CONCURRENT_TOOL_CALLS = 16;
13
- const MAX_COMMAND_BYTES = 64 * 1024;
14
17
  const MAX_DIRECTORY_ENTRIES = 10_000;
15
18
  const MAX_PATH_RESULT_BYTES = 4 * 1024 * 1024;
16
19
  const MAX_WALK_ENTRIES = 200_000;
17
-
18
- const ALL_TOOL_NAMES = [
19
- "project_overview",
20
- "list_roots",
21
- "list_dir",
22
- "list_files",
23
- "read_file",
24
- "write_file",
25
- "search_text",
26
- "git_status",
27
- "git_diff",
28
- "exec_command",
29
- ];
20
+ const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
30
21
 
31
22
  export class LocalDaemon {
32
- constructor({ workerUrl, secret, workspace, policy, logger = console }) {
33
- this.workerUrl = normalizeWorkerUrl(workerUrl);
34
- if (typeof secret !== "string" || secret.length < 16) throw new Error("daemon secret is missing or too short");
35
- this.secret = secret;
36
- this.workspace = realpathSync(resolve(workspace || process.cwd()));
37
- this.policy = {
38
- allowWrite: policy?.allowWrite !== false,
39
- allowExec: policy?.allowExec !== false,
40
- unrestrictedPaths: policy?.unrestrictedPaths === true,
41
- minimalEnv: policy?.minimalEnv !== false,
42
- };
23
+ constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null }) {
24
+ this.workerUrl = workerUrl ? normalizeWorkerUrl(workerUrl) : "";
25
+ if (this.workerUrl && (typeof secret !== "string" || secret.length < 16)) throw new Error("daemon secret is missing or too short");
26
+ this.secret = secret || "";
27
+ this.workspaceInput = resolve(workspace || process.cwd());
28
+ this.workspace = realpathSync(this.workspaceInput);
29
+ this.policy = normalizePolicy(policy);
43
30
  this.logger = logger;
31
+ this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
44
32
  this.closed = false;
45
33
  this.ws = null;
46
34
  this.heartbeat = null;
@@ -50,18 +38,50 @@ export class LocalDaemon {
50
38
  this.connectedOnceReject = null;
51
39
  this.activeToolCalls = 0;
52
40
  this.activeProcesses = new Set();
41
+ this.callProcesses = new Map();
42
+ this.cancelledCalls = new Set();
53
43
  this.reconnectAttempt = 0;
44
+ this.mutationQueue = Promise.resolve();
45
+ this.runtimeDir = createRuntimeDir();
46
+ this.processSessionManager = new ProcessSessionManager({
47
+ workspace: this.workspace,
48
+ policy: this.policy,
49
+ runtimeDir: this.runtimeDir,
50
+ activeProcesses: this.activeProcesses,
51
+ callProcesses: this.callProcesses,
52
+ resolveCwd: async (input) => {
53
+ const cwd = await this.resolveExistingPath(input);
54
+ if (!(await stat(cwd)).isDirectory()) throw new Error("cwd is not a directory");
55
+ return cwd;
56
+ },
57
+ displayPath: (value) => this.displayPath(value),
58
+ throwIfCancelled: (context) => this.throwIfCancelled(context),
59
+ });
54
60
  }
55
61
 
56
62
  tools() {
57
- return ALL_TOOL_NAMES.filter(name => {
58
- if (name === "write_file") return this.policy.allowWrite;
59
- if (name === "exec_command") return this.policy.allowExec;
60
- return true;
61
- });
63
+ return toolNamesForPolicy(this.policy).filter((name) => name !== "server_info");
64
+ }
65
+
66
+ runtimeInfo() {
67
+ return {
68
+ name: "machine-bridge-mcp",
69
+ protocol_version: MCP_PROTOCOL_VERSION,
70
+ supported_protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
71
+ workspace: this.displayPath(this.workspace),
72
+ workspace_name: basename(this.workspace),
73
+ policy: this.policy,
74
+ tools: ["server_info", ...this.tools()],
75
+ runtime: {
76
+ environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
77
+ runtime_dir: this.policy.exposeAbsolutePaths ? this.runtimeDir : "<private-runtime-dir>",
78
+ process_sessions: this.processSessionManager.status(),
79
+ },
80
+ };
62
81
  }
63
82
 
64
83
  start() {
84
+ if (!this.workerUrl || !this.secret) throw new Error("remote daemon start requires a Worker URL and daemon secret");
65
85
  this.closed = false;
66
86
  this.connectedOnce = new Promise((resolvePromise, rejectPromise) => {
67
87
  this.connectedOnceResolve = resolvePromise;
@@ -80,18 +100,16 @@ export class LocalDaemon {
80
100
  this.ws?.close();
81
101
  this.ws = null;
82
102
  this.terminateActiveProcesses("SIGKILL");
103
+ this.processSessionManager.clear();
83
104
  this.reconnectAttempt = 0;
105
+ rmSync(this.runtimeDir, { recursive: true, force: true });
84
106
  }
85
107
 
86
108
  connect() {
87
109
  if (this.closed) return;
88
110
  const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
89
- this.logger.info?.(`connecting daemon websocket: ${wsUrl}`);
90
- const socket = new WebSocket(wsUrl, {
91
- headers: {
92
- "X-Bridge-Token": this.secret,
93
- },
94
- });
111
+ this.logger.info?.("connecting daemon websocket", { endpoint: redactUrl(wsUrl) });
112
+ const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret } });
95
113
  this.ws = socket;
96
114
 
97
115
  socket.on("open", () => {
@@ -101,11 +119,7 @@ export class LocalDaemon {
101
119
  }
102
120
  this.reconnectAttempt = 0;
103
121
  this.logger.info?.("daemon websocket connected");
104
- this.send({
105
- type: "hello",
106
- tools: this.tools(),
107
- policy: this.policy,
108
- });
122
+ this.send({ type: "hello", tools: this.tools(), policy: this.policy, protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS });
109
123
  if (this.connectedOnceResolve) {
110
124
  this.connectedOnceResolve(true);
111
125
  this.connectedOnceResolve = null;
@@ -123,7 +137,7 @@ export class LocalDaemon {
123
137
  return;
124
138
  }
125
139
  void this.handleMessage(raw).catch(error => {
126
- this.logger.error?.(`daemon message handler failed: ${error.message}`);
140
+ this.logger.error?.("daemon message handler failed", { error: this.safeErrorMessage(error) });
127
141
  });
128
142
  });
129
143
 
@@ -133,9 +147,22 @@ export class LocalDaemon {
133
147
  this.terminateActiveProcesses("SIGTERM", true);
134
148
  if (this.heartbeat) clearInterval(this.heartbeat);
135
149
  this.heartbeat = null;
136
- const text = `daemon websocket closed: ${code} ${String(reason || "")}`;
137
- if (this.closed) this.logger.info?.(text);
138
- else this.logger.warn?.(text);
150
+ const reasonText = String(reason || "").slice(0, 128);
151
+ const fields = { code, reason: reasonText };
152
+ if (isSupersededClose(code, reasonText)) {
153
+ this.closed = true;
154
+ this.terminateActiveProcesses("SIGKILL");
155
+ this.processSessionManager.clear();
156
+ this.logger.warn?.("daemon connection permanently superseded", fields);
157
+ queueMicrotask(() => {
158
+ try { this.onSuperseded?.(); } catch (error) {
159
+ this.logger.error?.("daemon superseded callback failed", { error_class: classifyError(error) });
160
+ }
161
+ });
162
+ return;
163
+ }
164
+ if (this.closed) this.logger.info?.("daemon websocket closed", fields);
165
+ else this.logger.warn?.("daemon websocket disconnected", fields);
139
166
  if (!this.closed) {
140
167
  const delay = reconnectDelay(this.reconnectAttempt++);
141
168
  this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay });
@@ -146,10 +173,8 @@ export class LocalDaemon {
146
173
 
147
174
  socket.on("error", error => {
148
175
  if (this.ws !== socket) return;
149
- // Do not reject the first-connection promise: the close handler schedules
150
- // reconnects, and first deploy propagation can briefly race WebSocket setup.
151
- if (this.closed) this.logger.info?.(`daemon websocket closed during shutdown: ${error.message}`);
152
- else this.logger.error?.(`daemon websocket error: ${error.message}`);
176
+ if (this.closed) this.logger.info?.("daemon websocket closed during shutdown", { error: boundedErrorMessage(error) });
177
+ else this.logger.error?.("daemon websocket error", { error: boundedErrorMessage(error) });
153
178
  });
154
179
  }
155
180
 
@@ -159,95 +184,146 @@ export class LocalDaemon {
159
184
  this.ws.send(JSON.stringify(value));
160
185
  return true;
161
186
  } catch (error) {
162
- this.logger.warn?.(`daemon websocket send failed: ${boundedErrorMessage(error)}`);
187
+ this.logger.warn?.("daemon websocket send failed", { error: boundedErrorMessage(error) });
163
188
  return false;
164
189
  }
165
190
  }
166
191
 
167
192
  async handleMessage(raw) {
168
193
  let message;
169
- try {
170
- message = JSON.parse(raw);
171
- } catch {
194
+ try { message = JSON.parse(raw); } catch {
172
195
  this.logger.warn?.("invalid websocket JSON");
173
196
  return;
174
197
  }
175
198
  if (message.type === "welcome" || message.type === "hello_ack" || message.type === "pong") return;
199
+ if (message.type === "cancel_call") {
200
+ if (typeof message.id === "string") this.cancelCall(message.id, "remote cancellation");
201
+ return;
202
+ }
176
203
  if (message.type !== "tool_call") {
177
- this.logger.warn?.(`unknown websocket message: ${message.type}`);
204
+ this.logger.warn?.("unknown websocket message", { type: String(message.type || "") });
178
205
  return;
179
206
  }
180
207
 
181
208
  const id = typeof message.id === "string" ? message.id : "";
182
- if (!id || typeof message.tool !== "string") {
209
+ const tool = typeof message.tool === "string" ? message.tool : "";
210
+ const argumentsValue = message.arguments === undefined ? {} : message.arguments;
211
+ if (!id || id.length > 256 || !tool || tool.length > 128 || !isPlainRecord(argumentsValue)) {
183
212
  this.logger.warn?.("invalid tool_call envelope");
213
+ if (id && id.length <= 256) this.send({ type: "tool_result", id, ok: false, error: { message: "invalid tool_call envelope" } });
184
214
  return;
185
215
  }
186
216
  if (this.activeToolCalls >= MAX_CONCURRENT_TOOL_CALLS) {
187
217
  this.send({ type: "tool_result", id, ok: false, error: { message: "too many concurrent tool calls" } });
188
218
  return;
189
219
  }
220
+
221
+ const relayTimeoutMs = clampInt(message.timeout_ms, 60_000, 1000, 610_000);
222
+ const deadline = setTimeout(() => this.cancelCall(id, "relay deadline exceeded"), relayTimeoutMs);
223
+ deadline.unref?.();
190
224
  this.activeToolCalls += 1;
225
+ const started = Date.now();
226
+ this.logger.debug?.("tool call started", { call_id: shortCallId(id), tool });
191
227
  try {
192
- const result = await this.executeTool(message.tool, message.arguments || {});
228
+ const result = await this.executeTool(tool, argumentsValue, { callId: id });
229
+ if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
193
230
  this.send({ type: "tool_result", id, ok: true, result });
231
+ this.logger.info?.("tool call completed", { call_id: shortCallId(id), tool, duration_ms: Date.now() - started, ok: true });
194
232
  } catch (error) {
195
- this.send({ type: "tool_result", id, ok: false, error: { message: boundedErrorMessage(error) } });
233
+ const safeError = this.safeErrorMessage(error);
234
+ this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
235
+ this.logger.warn?.("tool call failed", { call_id: shortCallId(id), tool, duration_ms: Date.now() - started, ok: false, error_class: classifyError(error) });
196
236
  } finally {
237
+ clearTimeout(deadline);
197
238
  this.activeToolCalls -= 1;
239
+ this.finishCall(id);
198
240
  }
199
241
  }
200
242
 
201
- async executeTool(tool, args) {
243
+ finishCall(callId) {
244
+ if (!callId) return;
245
+ this.cancelledCalls.delete(callId);
246
+ this.callProcesses.delete(callId);
247
+ }
248
+
249
+ cancelCall(callId, reason = "cancelled") {
250
+ this.cancelledCalls.add(callId);
251
+ this.processSessionManager.notifyCancellation();
252
+ for (const child of this.callProcesses.get(callId) || []) terminateProcessTree(child, "SIGTERM");
253
+ const children = [...(this.callProcesses.get(callId) || [])];
254
+ if (children.length) {
255
+ const timer = setTimeout(() => {
256
+ for (const child of children) if (this.activeProcesses.has(child)) terminateProcessTree(child, "SIGKILL");
257
+ }, 2000);
258
+ timer.unref?.();
259
+ }
260
+ this.logger.info?.("tool call cancellation requested", { call_id: shortCallId(callId), reason });
261
+ }
262
+
263
+ async executeTool(tool, args, context = {}) {
264
+ if (!["server_info", ...this.tools()].includes(tool)) throw new Error(`tool disabled or unknown: ${tool}`);
202
265
  switch (tool) {
203
- case "project_overview": return this.projectOverview();
266
+ case "server_info": return this.runtimeInfo();
267
+ case "project_overview": return this.projectOverview(context);
204
268
  case "list_roots": return this.listRoots();
205
- case "list_dir": return this.listDir(args.path || ".");
206
- case "list_files": return this.listFiles(args.path || ".", clampInt(args.max_files, 1000, 1, 10000));
207
- case "read_file": return this.readFile(args.path, clampInt(args.max_bytes, 1024 * 1024, 1, 5 * 1024 * 1024));
208
- case "write_file": return this.writeFile(args);
209
- case "search_text": return this.searchText(args);
210
- case "git_status": return this.gitStatus(args);
211
- case "git_diff": return this.gitDiff(args);
212
- case "exec_command": return this.execCommand(args.command, clampInt(args.timeout_seconds, 120, 1, 600));
269
+ case "list_dir": return this.listDir(args.path || ".", context);
270
+ case "list_files": return this.listFiles(args.path || ".", clampInt(args.max_files, 1000, 1, 10000), context);
271
+ case "read_file": return this.readFile(args, context);
272
+ case "view_image": return this.viewImage(args, context);
273
+ case "write_file": return this.writeFile(args, context);
274
+ case "edit_file": return this.editFile(args, context);
275
+ case "apply_patch": return this.applyPatch(args, context);
276
+ case "search_text": return this.searchText(args, context);
277
+ case "git_status": return this.gitStatus(args, context);
278
+ case "git_diff": return this.gitDiff(args, context);
279
+ case "git_log": return this.gitLog(args, context);
280
+ case "git_show": return this.gitShow(args, context);
281
+ case "run_process": return this.runDirectProcess(args, context);
282
+ case "start_process": return this.processSessionManager.start(args, context);
283
+ case "read_process": return this.processSessionManager.read(args, context);
284
+ case "write_process": return this.processSessionManager.write(args, context);
285
+ case "kill_process": return this.processSessionManager.kill(args, context);
286
+ case "exec_command": return this.execCommand(args.command, clampInt(args.timeout_seconds, 120, 1, 600), context);
213
287
  default: throw new Error(`unknown daemon tool: ${tool}`);
214
288
  }
215
289
  }
216
290
 
217
- async projectOverview() {
218
- const top = await this.listDir(".").catch(error => ({ error: error.message, entries: [] }));
219
- const git = await this.runProcess("git", ["-C", this.workspace, "rev-parse", "--show-toplevel"], 10_000, true);
291
+ async projectOverview(context = {}) {
292
+ this.throwIfCancelled(context);
293
+ const top = await this.listDir(".", context).catch(error => ({ error: this.safeErrorMessage(error), entries: [] }));
294
+ const git = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", this.workspace, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
220
295
  return {
221
- workspace: this.workspace,
296
+ workspace: this.displayPath(this.workspace),
222
297
  workspaceName: basename(this.workspace),
223
- gitRoot: git.code === 0 ? git.stdout.trim() : "",
298
+ gitRoot: git.code === 0 ? this.displayPath(git.stdout.trim()) : "",
224
299
  policy: this.policy,
225
- tools: this.tools(),
300
+ tools: ["server_info", ...this.tools()],
226
301
  topLevel: top.entries || [],
227
302
  };
228
303
  }
229
304
 
230
305
  listRoots() {
231
- const roots = [{ name: basename(this.workspace), path: this.workspace, default: true }];
306
+ const roots = [{ name: basename(this.workspace), path: this.displayPath(this.workspace), default: true }];
232
307
  if (this.policy.unrestrictedPaths) {
233
- const home = process.env.HOME;
234
- if (home && home !== this.workspace) roots.push({ name: "home", path: home, default: false });
235
- roots.push({ name: "filesystem-root", path: path.parse(this.workspace).root, default: false });
308
+ const home = process.env.HOME || process.env.USERPROFILE;
309
+ if (home && home !== this.workspace) roots.push({ name: "home", path: this.displayPath(resolve(home)), default: false });
310
+ roots.push({ name: "filesystem-root", path: this.displayPath(path.parse(this.workspace).root), default: false });
236
311
  }
237
312
  return { roots };
238
313
  }
239
314
 
240
- async listDir(inputPath) {
315
+ async listDir(inputPath, context = {}) {
241
316
  const full = await this.resolveExistingPath(inputPath);
242
317
  const entries = [];
243
318
  let resultBytes = 0;
244
319
  let truncated = false;
245
320
  for await (const entry of await opendir(full)) {
321
+ this.throwIfCancelled(context);
246
322
  const entryPath = resolve(full, entry.name);
247
323
  const info = await lstat(entryPath).catch(() => null);
248
324
  const item = {
249
325
  name: entry.name,
250
- path: entryPath,
326
+ path: this.displayPath(entryPath),
251
327
  type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other",
252
328
  size: info?.size ?? 0,
253
329
  };
@@ -260,70 +336,189 @@ export class LocalDaemon {
260
336
  resultBytes += itemBytes;
261
337
  }
262
338
  entries.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
263
- return { path: full, entries, truncated };
339
+ return { path: this.displayPath(full), entries, truncated };
264
340
  }
265
341
 
266
- async listFiles(inputPath, maxFiles) {
342
+ async listFiles(inputPath, maxFiles, context = {}) {
267
343
  const root = await this.resolveExistingPath(inputPath);
268
344
  const info = await stat(root);
269
- if (info.isFile()) return { path: root, files: [root], truncated: false };
345
+ if (info.isFile()) return { path: this.displayPath(root), files: [this.displayPath(root)], truncated: false };
270
346
  if (!info.isDirectory()) throw new Error("path is not a file or directory");
271
347
  const files = [];
272
348
  let resultBytes = 0;
273
349
  const walkResult = await this.walk(root, async full => {
274
- const pathBytes = Buffer.byteLength(full) + 8;
350
+ this.throwIfCancelled(context);
351
+ const shown = this.displayPath(full);
352
+ const pathBytes = Buffer.byteLength(shown) + 8;
275
353
  if (files.length >= maxFiles || resultBytes + pathBytes > MAX_PATH_RESULT_BYTES) return false;
276
- files.push(full);
354
+ files.push(shown);
277
355
  resultBytes += pathBytes;
278
356
  return true;
279
- });
280
- return { path: root, files, truncated: files.length >= maxFiles || resultBytes >= MAX_PATH_RESULT_BYTES || walkResult.truncated };
357
+ }, context);
358
+ return { path: this.displayPath(root), files, truncated: files.length >= maxFiles || resultBytes >= MAX_PATH_RESULT_BYTES || walkResult.truncated };
281
359
  }
282
360
 
283
- async readFile(inputPath, maxBytes) {
284
- if (!inputPath) throw new Error("path is required");
285
- const full = await this.resolveExistingPath(inputPath);
361
+ async readFile(args, context = {}) {
362
+ if (typeof args === "string") {
363
+ args = { path: args, max_bytes: typeof context === "number" ? context : undefined };
364
+ context = {};
365
+ }
366
+ if (!args.path) throw new Error("path is required");
367
+ const full = await this.resolveExistingPath(args.path);
286
368
  const info = await stat(full);
287
369
  if (!info.isFile()) throw new Error("path is not a file");
288
- if (info.size > maxBytes) throw new Error(`file exceeds max_bytes (${info.size} > ${maxBytes})`);
370
+ if (info.size > MAX_WRITE_BYTES) throw new Error(`file exceeds maximum readable text size (${info.size} > ${MAX_WRITE_BYTES})`);
371
+ this.throwIfCancelled(context);
289
372
  const content = await readUtf8File(full);
290
- return { path: full, size: info.size, sha256: sha256(content), content };
373
+ this.throwIfCancelled(context);
374
+ const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
375
+ const startLine = args.start_line === undefined ? 1 : clampInt(args.start_line, 1, 1, Number.MAX_SAFE_INTEGER);
376
+ const rawLines = content.split(/\r?\n/);
377
+ const totalLines = content.endsWith("\n") ? Math.max(1, rawLines.length - 1) : rawLines.length;
378
+ const endLine = args.end_line === undefined ? totalLines : clampInt(args.end_line, totalLines, 1, Number.MAX_SAFE_INTEGER);
379
+ if (endLine < startLine) throw new Error("end_line must be greater than or equal to start_line");
380
+ if (startLine > totalLines) throw new Error(`start_line exceeds total lines (${startLine} > ${totalLines})`);
381
+ const selectedEnd = Math.min(endLine, totalLines);
382
+ let selected = rawLines.slice(startLine - 1, selectedEnd).join("\n");
383
+ if (selectedEnd < totalLines || content.endsWith("\n")) selected += "\n";
384
+ const selectedBytes = Buffer.byteLength(selected);
385
+ if (selectedBytes > maxBytes) throw new Error(`selected content exceeds max_bytes (${selectedBytes} > ${maxBytes})`);
386
+ return {
387
+ path: this.displayPath(full),
388
+ size: info.size,
389
+ sha256: sha256(content),
390
+ content: selected,
391
+ start_line: startLine,
392
+ end_line: selectedEnd,
393
+ total_lines: totalLines,
394
+ complete: startLine === 1 && selectedEnd === totalLines,
395
+ };
291
396
  }
292
397
 
293
- async writeFile(args) {
294
- if (!this.policy.allowWrite) throw new Error("write_file is disabled by daemon policy");
398
+ async viewImage(args, context = {}) {
295
399
  if (!args.path) throw new Error("path is required");
296
- const content = String(args.content ?? "");
297
- const bytes = Buffer.byteLength(content);
298
- if (bytes > MAX_WRITE_BYTES) throw new Error(`content exceeds maximum write size (${bytes} > ${MAX_WRITE_BYTES})`);
299
- const full = await this.resolveWritePath(args.path);
300
- const existing = await lstat(full).catch(() => null);
301
- if (existing?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
302
- if (args.create_only && existing) throw new Error("file exists and create_only=true");
303
- if (existing && !existing.isFile()) throw new Error("path is not a regular file");
304
- if (args.expected_sha256) {
305
- if (!existing) throw new Error("expected_sha256 requires an existing file");
400
+ const full = await this.resolveExistingPath(args.path);
401
+ const info = await stat(full);
402
+ if (!info.isFile()) throw new Error("path is not a file");
403
+ if (info.size > MAX_IMAGE_BYTES) throw new Error(`image exceeds maximum size (${info.size} > ${MAX_IMAGE_BYTES})`);
404
+ this.throwIfCancelled(context);
405
+ const buffer = await readFile(full);
406
+ this.throwIfCancelled(context);
407
+ const mimeType = detectImageMime(buffer);
408
+ if (!mimeType) throw new Error("unsupported image format; expected PNG, JPEG, GIF, or WebP");
409
+ return {
410
+ $mcp: {
411
+ content: [{ type: "image", data: buffer.toString("base64"), mimeType }],
412
+ structuredContent: {
413
+ path: this.displayPath(full),
414
+ size: info.size,
415
+ sha256: createHash("sha256").update(buffer).digest("hex"),
416
+ mime_type: mimeType,
417
+ },
418
+ },
419
+ };
420
+ }
421
+
422
+ async writeFile(args, context = {}) {
423
+ return this.withMutationLock(async () => {
424
+ this.throwIfCancelled(context);
425
+ if (!this.policy.allowWrite) throw new Error("write_file is disabled by daemon policy");
426
+ if (!args.path) throw new Error("path is required");
427
+ const content = String(args.content ?? "");
428
+ const bytes = Buffer.byteLength(content);
429
+ if (bytes > MAX_WRITE_BYTES) throw new Error(`content exceeds maximum write size (${bytes} > ${MAX_WRITE_BYTES})`);
430
+ const full = await this.resolveWritePath(args.path);
431
+ const existing = await lstat(full).catch(() => null);
432
+ if (existing?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
433
+ if (args.create_only && existing) throw new Error("file exists and create_only=true");
434
+ if (existing && !existing.isFile()) throw new Error("path is not a regular file");
435
+ if (args.expected_sha256) {
436
+ if (!existing) throw new Error("expected_sha256 requires an existing file");
437
+ const current = await readUtf8File(full);
438
+ if (sha256(current) !== String(args.expected_sha256).toLowerCase()) throw new Error("expected_sha256 mismatch");
439
+ }
440
+ this.throwIfCancelled(context);
441
+ await atomicWriteText(full, content, existing, {
442
+ createOnly: args.create_only === true,
443
+ expectedHash: args.expected_sha256 ? String(args.expected_sha256).toLowerCase() : undefined,
444
+ });
445
+ return { ok: true, path: this.displayPath(full), sha256: sha256(content), bytes };
446
+ });
447
+ }
448
+
449
+ async editFile(args, context = {}) {
450
+ return this.withMutationLock(async () => {
451
+ this.throwIfCancelled(context);
452
+ if (!this.policy.allowWrite) throw new Error("edit_file is disabled by daemon policy");
453
+ if (!args.path) throw new Error("path is required");
454
+ const oldText = String(args.old_text ?? "");
455
+ const newText = String(args.new_text ?? "");
456
+ if (!oldText) throw new Error("old_text must not be empty");
457
+ const full = await this.resolveExistingPath(args.path);
458
+ const info = await lstat(full);
459
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error("path is not a regular non-symbolic-link file");
306
460
  const current = await readUtf8File(full);
307
- if (sha256(current) !== String(args.expected_sha256)) throw new Error("expected_sha256 mismatch");
308
- }
309
- await mkdir(dirname(full), { recursive: true });
310
- if (args.create_only) {
311
- await writeFile(full, content, { encoding: "utf8", flag: "wx", mode: 0o600 });
312
- } else {
313
- const temp = join(dirname(full), `.${basename(full)}.mbm-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
314
- try {
315
- await writeFile(temp, content, { encoding: "utf8", flag: "wx", mode: existing ? existing.mode & 0o777 : 0o600 });
316
- if (existing) await chmod(temp, existing.mode & 0o777).catch(() => {});
317
- await rename(temp, full);
318
- } catch (error) {
319
- await rm(temp, { force: true }).catch(() => {});
320
- throw error;
461
+ if (args.expected_sha256 && sha256(current) !== String(args.expected_sha256).toLowerCase()) throw new Error("expected_sha256 mismatch");
462
+ const occurrences = countOccurrences(current, oldText);
463
+ if (occurrences === 0) throw new Error("old_text was not found");
464
+ if (!args.replace_all && occurrences !== 1) throw new Error(`old_text occurs ${occurrences} times; provide a unique fragment or set replace_all=true`);
465
+ const updated = args.replace_all ? current.split(oldText).join(newText) : current.replace(oldText, newText);
466
+ const bytes = Buffer.byteLength(updated);
467
+ if (bytes > MAX_WRITE_BYTES) throw new Error(`edited content exceeds maximum write size (${bytes} > ${MAX_WRITE_BYTES})`);
468
+ this.throwIfCancelled(context);
469
+ await atomicWriteText(full, updated, info, { expectedHash: sha256(current) });
470
+ return { ok: true, path: this.displayPath(full), replacements: args.replace_all ? occurrences : 1, sha256: sha256(updated), bytes };
471
+ });
472
+ }
473
+
474
+ async applyPatch(args, context = {}) {
475
+ return this.withMutationLock(async () => {
476
+ this.throwIfCancelled(context);
477
+ if (!this.policy.allowWrite) throw new Error("apply_patch is disabled by daemon policy");
478
+ const patchText = String(args.patch ?? "");
479
+ if (!patchText) throw new Error("patch is required");
480
+ if (Buffer.byteLength(patchText) > MAX_WRITE_BYTES) throw new Error("patch exceeds maximum size");
481
+ const parsed = parsePatchEnvelope(patchText);
482
+ const prepared = [];
483
+ for (const operation of parsed) {
484
+ this.throwIfCancelled(context);
485
+ if (operation.kind === "add") {
486
+ const target = await this.resolveWritePath(operation.path);
487
+ if (await lstat(target).catch(() => null)) throw new Error(`add target already exists: ${operation.path}`);
488
+ assertTextSize(operation.content, operation.path);
489
+ prepared.push({ kind: "add", source: null, target, content: operation.content, mode: 0o600 });
490
+ continue;
491
+ }
492
+ const source = await this.resolveExistingPath(operation.path);
493
+ const sourceInfo = await lstat(source);
494
+ if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink()) throw new Error(`patch source is not a regular file: ${operation.path}`);
495
+ const original = await readUtf8File(source);
496
+ if (operation.kind === "delete") {
497
+ prepared.push({ kind: "delete", source, target: null, originalHash: sha256(original), mode: sourceInfo.mode & 0o777 });
498
+ continue;
499
+ }
500
+ const content = applyUpdateHunks(original, operation.hunks, operation.path);
501
+ assertTextSize(content, operation.path);
502
+ const target = operation.moveTo ? await this.resolveWritePath(operation.moveTo) : source;
503
+ if (target !== source && await lstat(target).catch(() => null)) throw new Error(`move target already exists: ${operation.moveTo}`);
504
+ prepared.push({ kind: operation.moveTo ? "move" : "update", source, target, content, originalHash: sha256(original), mode: sourceInfo.mode & 0o777 });
321
505
  }
322
- }
323
- return { ok: true, path: full, sha256: sha256(content), bytes };
506
+ assertNoResolvedPatchCollisions(prepared);
507
+ this.throwIfCancelled(context);
508
+ await commitPatchTransaction(prepared);
509
+ return {
510
+ ok: true,
511
+ files: prepared.map((item) => ({
512
+ operation: item.kind,
513
+ path: this.displayPath(item.target || item.source),
514
+ from: item.kind === "move" ? this.displayPath(item.source) : undefined,
515
+ sha256: item.content === undefined ? undefined : sha256(item.content),
516
+ })),
517
+ };
518
+ });
324
519
  }
325
520
 
326
- async searchText(args) {
521
+ async searchText(args, context = {}) {
327
522
  const query = String(args.query || "");
328
523
  if (!query) throw new Error("query is required");
329
524
  const root = await this.resolveExistingPath(args.path || ".");
@@ -333,20 +528,22 @@ export class LocalDaemon {
333
528
  const matches = [];
334
529
  const rootInfo = await stat(root);
335
530
  if (rootInfo.isFile()) {
336
- await this.searchOneFile(root, query, matches, max);
337
- return { query, root, matches, visited_files: 1, truncated: matches.length >= max };
531
+ await this.searchOneFile(root, query, matches, max, context);
532
+ return { query, root: this.displayPath(root), matches, visited_files: 1, truncated: matches.length >= max };
338
533
  }
339
534
  if (!rootInfo.isDirectory()) throw new Error("path is not a file or directory");
340
535
  const walkResult = await this.walk(root, async full => {
536
+ this.throwIfCancelled(context);
341
537
  if (matches.length >= max || visitedFiles >= maxFiles) return false;
342
538
  visitedFiles += 1;
343
- await this.searchOneFile(full, query, matches, max);
539
+ await this.searchOneFile(full, query, matches, max, context);
344
540
  return matches.length < max && visitedFiles < maxFiles;
345
- });
346
- return { query, root, matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles || walkResult.truncated };
541
+ }, context);
542
+ return { query, root: this.displayPath(root), matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles || walkResult.truncated };
347
543
  }
348
544
 
349
- async searchOneFile(full, query, matches, max) {
545
+ async searchOneFile(full, query, matches, max, context = {}) {
546
+ this.throwIfCancelled(context);
350
547
  const info = await stat(full).catch(() => null);
351
548
  if (!info?.isFile() || info.size > 1024 * 1024) return;
352
549
  const buffer = await readFile(full).catch(() => null);
@@ -357,51 +554,89 @@ export class LocalDaemon {
357
554
  const lines = text.split(/\r?\n/);
358
555
  for (let index = 0; index < lines.length; index += 1) {
359
556
  if (lines[index].includes(query)) {
360
- matches.push({ path: full, line: index + 1, text: lines[index].slice(0, 500) });
557
+ matches.push({ path: this.displayPath(full), line: index + 1, text: lines[index].slice(0, 500) });
361
558
  if (matches.length >= max) break;
362
559
  }
363
560
  }
364
561
  }
365
562
 
366
- async gitStatus(args = {}) {
367
- const context = await this.gitContext(args.path || ".");
368
- if (!context.ok) return context.result;
369
- const commandArgs = ["-c", "core.fsmonitor=false", "-C", context.root, "status", "--short"];
370
- if (context.pathspec) commandArgs.push("--", context.pathspec);
371
- return this.runProcess("git", commandArgs, 30_000, true);
563
+ async gitStatus(args = {}, context = {}) {
564
+ const git = await this.gitContext(args.path || ".", context);
565
+ if (!git.ok) return git.result;
566
+ const commandArgs = ["-c", "core.fsmonitor=false", "-C", git.root, "status", "--short", "--branch"];
567
+ if (git.pathspec) commandArgs.push("--", git.pathspec);
568
+ const result = await this.runProcess("git", commandArgs, 30_000, true, 512 * 1024, context);
569
+ return { ...result, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
570
+ }
571
+
572
+ async gitDiff(args = {}, context = {}) {
573
+ const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
574
+ const git = await this.gitContext(args.path || ".", context);
575
+ if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
576
+ const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", git.root, "diff", "--no-ext-diff", "--no-textconv"];
577
+ if (args.staged) commandArgs.push("--cached");
578
+ if (git.pathspec) commandArgs.push("--", git.pathspec);
579
+ const result = await this.runProcess("git", commandArgs, 60_000, true, maxBytes, context);
580
+ return { ...result, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root), staged: args.staged === true };
581
+ }
582
+
583
+ async gitLog(args = {}, context = {}) {
584
+ const git = await this.gitContext(args.path || ".", context);
585
+ if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
586
+ const maxCount = clampInt(args.max_count, 20, 1, 100);
587
+ const format = "%H%x1f%h%x1f%aI%x1f%an%x1f%ae%x1f%s%x1e";
588
+ const commandArgs = ["-c", "core.fsmonitor=false", "-C", git.root, "log", `--max-count=${maxCount}`, `--format=${format}`];
589
+ if (git.pathspec) commandArgs.push("--", git.pathspec);
590
+ const result = await this.runProcess("git", commandArgs, 30_000, true, 1024 * 1024, context);
591
+ const commits = result.stdout.split("\x1e").map((record) => record.trim()).filter(Boolean).map((record) => {
592
+ const [hash, short, authored_at, author_name, author_email, subject] = record.split("\x1f");
593
+ const commit = { hash, short, authored_at, author_name, subject };
594
+ if (args.include_author_email === true) commit.author_email = author_email;
595
+ return commit;
596
+ });
597
+ return { code: result.code, stderr: result.stderr, commits, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
372
598
  }
373
599
 
374
- async gitDiff(args = {}) {
375
- const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, 5 * 1024 * 1024);
376
- const context = await this.gitContext(args.path || ".");
377
- if (!context.ok) return { ...context.result, path: context.target };
378
- const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", context.root, "diff", "--no-ext-diff", "--no-textconv"];
379
- if (context.pathspec) commandArgs.push("--", context.pathspec);
380
- const result = await this.runProcess("git", commandArgs, 60_000, true, maxBytes);
381
- return { ...result, path: context.target, gitRoot: context.root };
600
+ async gitShow(args = {}, context = {}) {
601
+ const git = await this.gitContext(args.path || ".", context);
602
+ if (!git.ok) return { ...git.result, path: this.displayPath(git.target) };
603
+ const revision = validateRevision(args.revision || "HEAD");
604
+ const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
605
+ const commandArgs = ["-c", "core.fsmonitor=false", "-c", "diff.external=", "-C", git.root, "show", "--no-ext-diff", "--no-textconv", "--decorate=no", revision];
606
+ if (git.pathspec) commandArgs.push("--", git.pathspec);
607
+ const result = await this.runProcess("git", commandArgs, 60_000, true, maxBytes, context);
608
+ return { ...result, revision, path: this.displayPath(git.target), gitRoot: this.displayPath(git.root) };
382
609
  }
383
610
 
384
- async gitContext(inputPath) {
611
+ async gitContext(inputPath, context = {}) {
385
612
  const target = await this.resolveExistingPath(inputPath);
386
613
  const info = await stat(target);
387
614
  const cwd = info.isDirectory() ? target : dirname(target);
388
- const result = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", cwd, "rev-parse", "--show-toplevel"], 10_000, true);
615
+ const result = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", cwd, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
389
616
  if (result.code !== 0) return { ok: false, result, target };
390
617
  const root = result.stdout.trim();
391
- const relative = path.relative(root, target);
392
- if (relative.startsWith(`..${sep}`) || relative === ".." || isAbsolute(relative)) {
618
+ const repoRelative = relative(root, target);
619
+ if (repoRelative.startsWith(`..${sep}`) || repoRelative === ".." || isAbsolute(repoRelative)) {
393
620
  return { ok: false, target, result: { code: 128, stdout: "", stderr: "target is outside the detected git repository" } };
394
621
  }
395
- return { ok: true, target, root, pathspec: relative || "" };
622
+ return { ok: true, target, root, pathspec: repoRelative || "" };
396
623
  }
397
624
 
398
- async execCommand(command, timeoutSeconds) {
399
- if (!this.policy.allowExec) throw new Error("exec_command is disabled by daemon policy");
625
+ async runDirectProcess(args, context = {}) {
626
+ if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error("run_process is disabled by daemon policy");
627
+ const argv = validateArgv(args.argv);
628
+ const cwd = await this.resolveExistingPath(args.cwd || ".");
629
+ if (!(await stat(cwd)).isDirectory()) throw new Error("cwd is not a directory");
630
+ return this.runProcess(argv[0], argv.slice(1), clampInt(args.timeout_seconds, 120, 1, 600) * 1000, false, 512 * 1024, context, cwd);
631
+ }
632
+
633
+ async execCommand(command, timeoutSeconds, context = {}) {
634
+ if (this.policy.execMode !== "shell") throw new Error("exec_command requires shell execution mode");
400
635
  if (!command || typeof command !== "string") throw new Error("command is required");
401
636
  if (command.includes("\0")) throw new Error("command contains a NUL byte");
402
637
  if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new Error(`command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
403
638
  const shell = workspaceShellCommand(command);
404
- return this.runProcess(shell.cmd, shell.args, clampInt(timeoutSeconds, 120, 1, 600) * 1000);
639
+ return this.runProcess(shell.cmd, shell.args, clampInt(timeoutSeconds, 120, 1, 600) * 1000, false, 512 * 1024, context);
405
640
  }
406
641
 
407
642
  terminateActiveProcesses(signal = "SIGTERM", escalate = false) {
@@ -409,23 +644,27 @@ export class LocalDaemon {
409
644
  for (const child of children) terminateProcessTree(child, signal);
410
645
  if (escalate && signal !== "SIGKILL" && children.length) {
411
646
  const timer = setTimeout(() => {
412
- for (const child of children) {
413
- if (this.activeProcesses.has(child)) terminateProcessTree(child, "SIGKILL");
414
- }
647
+ for (const child of children) if (this.activeProcesses.has(child)) terminateProcessTree(child, "SIGKILL");
415
648
  }, 2000);
416
649
  timer.unref?.();
417
650
  }
418
651
  }
419
652
 
420
- async runProcess(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = 512 * 1024) {
653
+ async runProcess(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = 512 * 1024, context = {}, cwd = this.workspace) {
654
+ this.throwIfCancelled(context);
421
655
  return new Promise((resolvePromise, reject) => {
422
656
  const child = spawn(cmd, args, {
423
- cwd: this.workspace,
424
- env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false }),
657
+ cwd,
658
+ env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false, runtimeDir: this.runtimeDir }),
425
659
  detached: process.platform !== "win32",
426
660
  windowsHide: true,
427
661
  });
428
662
  this.activeProcesses.add(child);
663
+ if (context.callId) {
664
+ const set = this.callProcesses.get(context.callId) || new Set();
665
+ set.add(child);
666
+ this.callProcesses.set(context.callId, set);
667
+ }
429
668
  let stdout = "";
430
669
  let stderr = "";
431
670
  let stdoutTruncated = 0;
@@ -444,6 +683,11 @@ export class LocalDaemon {
444
683
  clearTimeout(timer);
445
684
  if (killTimer && !timedOut) clearTimeout(killTimer);
446
685
  this.activeProcesses.delete(child);
686
+ if (context.callId) {
687
+ const set = this.callProcesses.get(context.callId);
688
+ set?.delete(child);
689
+ if (!set?.size) this.callProcesses.delete(context.callId);
690
+ }
447
691
  };
448
692
  const finish = callback => {
449
693
  if (settled) return;
@@ -467,6 +711,10 @@ export class LocalDaemon {
467
711
  }));
468
712
  child.on("close", code => finish(() => {
469
713
  const result = { code, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: finalizeOutput(stderr, stderrTruncated) };
714
+ if (context.callId && this.cancelledCalls.has(context.callId)) {
715
+ reject(new Error("tool call cancelled"));
716
+ return;
717
+ }
470
718
  if (timedOut) {
471
719
  reject(new Error(`command timed out after ${timeoutMs}ms`));
472
720
  return;
@@ -477,14 +725,16 @@ export class LocalDaemon {
477
725
  });
478
726
  }
479
727
 
480
- async walk(root, onFile) {
728
+ async walk(root, onFile, context = {}) {
481
729
  const stack = [root];
482
730
  let visitedEntries = 0;
483
731
  while (stack.length) {
732
+ this.throwIfCancelled(context);
484
733
  const current = stack.pop();
485
734
  const entries = await opendir(current).catch(() => null);
486
735
  if (!entries) continue;
487
736
  for await (const entry of entries) {
737
+ this.throwIfCancelled(context);
488
738
  visitedEntries += 1;
489
739
  if (visitedEntries > MAX_WALK_ENTRIES) return { truncated: true, visitedEntries };
490
740
  const full = resolve(current, entry.name);
@@ -501,8 +751,7 @@ export class LocalDaemon {
501
751
  resolvePath(inputPath = ".") {
502
752
  const raw = String(inputPath || ".");
503
753
  if (raw.includes("\0")) throw new Error("path contains a NUL byte");
504
- const candidate = isAbsolute(raw) ? resolve(raw) : resolve(this.workspace, raw);
505
- return candidate;
754
+ return isAbsolute(raw) ? resolve(raw) : resolve(this.workspace, raw);
506
755
  }
507
756
 
508
757
  async resolveExistingPath(inputPath = ".") {
@@ -525,6 +774,37 @@ export class LocalDaemon {
525
774
  assertContainedPath(this.workspace, canonicalAncestor);
526
775
  return candidate;
527
776
  }
777
+
778
+ displayPath(fullPath) {
779
+ const absolute = resolve(fullPath);
780
+ if (this.policy.exposeAbsolutePaths || this.policy.unrestrictedPaths) return absolute;
781
+ assertContainedPath(this.workspace, absolute);
782
+ const shown = relative(this.workspace, absolute);
783
+ return shown ? shown.split(sep).join("/") : ".";
784
+ }
785
+
786
+ safeErrorMessage(error) {
787
+ let message = boundedErrorMessage(error);
788
+ if (!this.policy.exposeAbsolutePaths) {
789
+ for (const prefix of equivalentPathPrefixes(this.workspace, this.workspaceInput)) message = replacePathPrefix(message, prefix, ".");
790
+ for (const prefix of equivalentPathPrefixes(this.runtimeDir)) message = replacePathPrefix(message, prefix, "<runtime>");
791
+ const home = process.env.HOME || process.env.USERPROFILE;
792
+ if (home) message = replacePathPrefix(message, resolve(home), "<home>");
793
+ }
794
+ return message;
795
+ }
796
+
797
+ throwIfCancelled(context = {}) {
798
+ if (context.callId && this.cancelledCalls.has(context.callId)) throw new Error("tool call cancelled");
799
+ }
800
+
801
+ async withMutationLock(callback) {
802
+ const previous = this.mutationQueue;
803
+ let release = () => {};
804
+ this.mutationQueue = new Promise((resolvePromise) => { release = resolvePromise; });
805
+ await previous;
806
+ try { return await callback(); } finally { release(); }
807
+ }
528
808
  }
529
809
 
530
810
  function normalizeWorkerUrl(value) {
@@ -536,42 +816,136 @@ function normalizeWorkerUrl(value) {
536
816
  return url.origin;
537
817
  }
538
818
 
819
+ export function isSupersededClose(code, reason) {
820
+ return Number(code) === 1012 && String(reason || "") === "replaced by authenticated daemon";
821
+ }
822
+
539
823
  function reconnectDelay(attempt) {
540
824
  const base = Math.min(3000 * (2 ** Math.min(attempt, 4)), 60_000);
541
825
  return base + Math.floor(Math.random() * 1000);
542
826
  }
543
827
 
544
- function terminateProcessTree(child, signal) {
545
- if (!child?.pid) return;
546
- if (process.platform === "win32") {
547
- try {
548
- const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
549
- killer.unref();
550
- return;
551
- } catch {}
552
- }
553
- try {
554
- process.kill(-child.pid, signal);
555
- } catch {
556
- try { child.kill(signal); } catch {}
557
- }
558
- }
559
828
 
560
829
  function assertContainedPath(root, target) {
561
- const relative = path.relative(root, target);
562
- if (relative === "" || (!relative.startsWith(`..${sep}`) && relative !== ".." && !isAbsolute(relative))) return;
830
+ const rel = relative(root, target);
831
+ if (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))) return;
563
832
  throw new Error("path is outside the configured workspace; restart with --unrestricted-paths to allow it");
564
833
  }
565
834
 
566
835
  async function readUtf8File(filePath) {
567
836
  const buffer = await readFile(filePath);
568
- try {
569
- return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
570
- } catch {
837
+ try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
571
838
  throw new Error("file is not valid UTF-8 text");
572
839
  }
573
840
  }
574
841
 
842
+ async function atomicWriteText(full, content, existing = null, options = {}) {
843
+ await mkdir(dirname(full), { recursive: true });
844
+ const temp = join(dirname(full), `.${basename(full)}.mbm-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
845
+ try {
846
+ await writeFile(temp, content, { encoding: "utf8", flag: "wx", mode: existing ? existing.mode & 0o777 : 0o600 });
847
+ if (existing) await chmod(temp, existing.mode & 0o777).catch(() => {});
848
+ if (options.expectedHash) {
849
+ const current = await readUtf8File(full).catch(() => null);
850
+ if (current === null || sha256(current) !== options.expectedHash) throw new Error("file changed before atomic commit");
851
+ }
852
+ if (options.createOnly) {
853
+ await link(temp, full);
854
+ await rm(temp, { force: true });
855
+ } else {
856
+ await rename(temp, full);
857
+ }
858
+ } catch (error) {
859
+ await rm(temp, { force: true }).catch(() => {});
860
+ throw error;
861
+ }
862
+ }
863
+
864
+ function assertNoResolvedPatchCollisions(operations) {
865
+ const owners = new Map();
866
+ for (const operation of operations) {
867
+ const paths = operation.source === operation.target
868
+ ? [operation.source]
869
+ : [operation.source, operation.target].filter(Boolean);
870
+ for (const full of paths) {
871
+ const key = process.platform === "win32" ? String(full).toLowerCase() : String(full);
872
+ const previous = owners.get(key);
873
+ if (previous && previous !== operation) throw new Error(`patch operations resolve to the same path: ${full}`);
874
+ owners.set(key, operation);
875
+ }
876
+ }
877
+ }
878
+
879
+ async function commitPatchTransaction(operations) {
880
+ const staged = [];
881
+ const committed = [];
882
+ try {
883
+ for (const operation of operations) {
884
+ if (operation.content === undefined) continue;
885
+ await mkdir(dirname(operation.target), { recursive: true });
886
+ const temp = join(dirname(operation.target), `.${basename(operation.target)}.mbm-patch-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
887
+ await writeFile(temp, operation.content, { encoding: "utf8", flag: "wx", mode: operation.mode });
888
+ await chmod(temp, operation.mode).catch(() => {});
889
+ staged.push({ operation, temp });
890
+ }
891
+
892
+ for (const operation of operations) {
893
+ if (operation.source) {
894
+ const current = await readUtf8File(operation.source);
895
+ if (sha256(current) !== operation.originalHash) throw new Error(`patch source changed during apply: ${operation.source}`);
896
+ }
897
+ if (operation.kind === "add" || operation.kind === "move") {
898
+ if (await lstat(operation.target).catch(() => null)) throw new Error(`patch target appeared during apply: ${operation.target}`);
899
+ }
900
+ }
901
+
902
+ for (const operation of operations) {
903
+ let backup = null;
904
+ if (operation.source) {
905
+ backup = join(dirname(operation.source), `.${basename(operation.source)}.mbm-backup-${process.pid}-${randomBytes(6).toString("hex")}`);
906
+ await rename(operation.source, backup);
907
+ }
908
+ const record = { operation, backup, targetCreated: false };
909
+ committed.push(record);
910
+ const stage = staged.find((item) => item.operation === operation);
911
+ if (stage) {
912
+ await rename(stage.temp, operation.target);
913
+ record.targetCreated = true;
914
+ }
915
+ }
916
+ } catch (error) {
917
+ for (const item of committed.reverse()) {
918
+ if (item.targetCreated) await rm(item.operation.target, { force: true }).catch(() => {});
919
+ if (item.backup) await rename(item.backup, item.operation.source).catch(() => {});
920
+ }
921
+ throw error;
922
+ } finally {
923
+ for (const item of staged) await rm(item.temp, { force: true }).catch(() => {});
924
+ }
925
+ for (const item of committed) if (item.backup) await rm(item.backup, { force: true }).catch(() => {});
926
+ }
927
+
928
+ function assertTextSize(content, label) {
929
+ const bytes = Buffer.byteLength(content);
930
+ if (bytes > MAX_WRITE_BYTES) throw new Error(`patched file exceeds maximum size for ${label} (${bytes} > ${MAX_WRITE_BYTES})`);
931
+ }
932
+
933
+ function countOccurrences(content, needle) {
934
+ let count = 0;
935
+ let offset = 0;
936
+ while ((offset = content.indexOf(needle, offset)) !== -1) {
937
+ count += 1;
938
+ offset += needle.length;
939
+ }
940
+ return count;
941
+ }
942
+
943
+ function validateRevision(value) {
944
+ const revision = String(value || "HEAD");
945
+ if (!revision || revision.length > 256 || revision.startsWith("-") || revision.includes("\0") || /[\r\n]/.test(revision)) throw new Error("invalid Git revision");
946
+ return revision;
947
+ }
948
+
575
949
  function boundedErrorMessage(error) {
576
950
  const message = error instanceof Error ? error.message : String(error);
577
951
  return message.replace(/[\r\n]+/g, " ").slice(0, 4096) || "tool call failed";
@@ -600,134 +974,56 @@ function clampInt(value, fallback, min, max) {
600
974
  return Math.min(Math.max(number, min), max);
601
975
  }
602
976
 
603
- export async function daemonSelfTest() {
604
- const workspace = await mkdtemp(join(tmpdir(), "mbm-daemon-workspace-"));
605
- const outside = await mkdtemp(join(tmpdir(), "mbm-daemon-outside-"));
606
- const logger = { info() {}, warn() {}, error() {} };
607
- const restricted = new LocalDaemon({
608
- workerUrl: "https://example.invalid",
609
- secret: "test-secret-value-123456",
610
- workspace,
611
- policy: { allowWrite: true, allowExec: true },
612
- logger,
613
- });
614
- const unrestricted = new LocalDaemon({
615
- workerUrl: "https://example.invalid",
616
- secret: "test-secret-value-123456",
617
- workspace,
618
- policy: { allowWrite: true, allowExec: true, unrestrictedPaths: true },
619
- logger,
620
- });
621
- const previousSecret = process.env.MBM_DAEMON_SELFTEST_SECRET;
622
- process.env.MBM_DAEMON_SELFTEST_SECRET = "should-not-leak";
623
- try {
624
- await writeFile(join(workspace, ".env"), "SECRET=visible", "utf8");
625
- await writeFile(join(workspace, "visible.txt"), "needle", "utf8");
626
- await writeFile(join(outside, "outside.txt"), "outside-needle", "utf8");
627
-
628
- const envFile = await restricted.readFile(".env", 1024);
629
- if (!envFile.content.includes("SECRET=visible")) throw new Error("workspace .env should remain readable");
630
-
631
- await expectReject(() => restricted.readFile(join(outside, "outside.txt"), 1024), "outside the configured workspace");
632
- await expectReject(() => restricted.readFile(path.relative(workspace, join(outside, "outside.txt")), 1024), "outside the configured workspace");
977
+ function createRuntimeDir() {
978
+ const root = mkdtempSync(join(tmpdir(), "machine-bridge-mcp-"));
979
+ for (const name of ["home", "tmp", "cache"]) mkdirSync(join(root, name), { recursive: true, mode: 0o700 });
980
+ return root;
981
+ }
633
982
 
634
- const outsideFile = await unrestricted.readFile(join(outside, "outside.txt"), 1024);
635
- if (!outsideFile.content.includes("outside-needle")) throw new Error("unrestricted absolute read failed");
983
+ function detectImageMime(buffer) {
984
+ if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
985
+ if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "image/jpeg";
986
+ if (buffer.length >= 6 && ["GIF87a", "GIF89a"].includes(buffer.subarray(0, 6).toString("ascii"))) return "image/gif";
987
+ if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
988
+ return "";
989
+ }
636
990
 
637
- const linkPath = join(workspace, "outside-link");
638
- try {
639
- await symlink(outside, linkPath, "dir");
640
- await expectReject(() => restricted.readFile(join(linkPath, "outside.txt"), 1024), "outside the configured workspace");
641
- await expectReject(() => restricted.writeFile({ path: linkPath, content: "replace" }), "outside the configured workspace");
642
- } catch (error) {
643
- if (error?.code !== "EPERM" && error?.code !== "EACCES") throw error;
644
- }
991
+ function isPlainRecord(value) {
992
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
993
+ }
645
994
 
646
- const written = await restricted.writeFile({ path: "nested/written.txt", content: "written", create_only: true });
647
- if (written.bytes !== 7) throw new Error("write_file byte count is incorrect");
648
- await expectReject(() => restricted.writeFile({ path: "nested/written.txt", content: "again", create_only: true }), "file exists");
649
- await expectReject(() => restricted.writeFile({ path: "nested/written.txt", content: "again", expected_sha256: "bad" }), "expected_sha256 mismatch");
650
- await restricted.writeFile({ path: "nested/written.txt", content: "updated", expected_sha256: sha256("written") });
651
- if (await readFile(join(workspace, "nested/written.txt"), "utf8") !== "updated") throw new Error("atomic update failed");
652
- await expectReject(() => restricted.writeFile({ path: "too-large.txt", content: "x".repeat(MAX_WRITE_BYTES + 1) }), "maximum write size");
653
-
654
- await writeFile(join(workspace, "invalid.bin"), Buffer.from([0xff, 0xfe]));
655
- await expectReject(() => restricted.readFile("invalid.bin", 1024), "not valid UTF-8");
656
- const binarySearch = await restricted.searchText({ path: workspace, query: "needle", max_files: 100, max_matches: 10 });
657
- if (!binarySearch.matches.some(match => match.path.endsWith("visible.txt"))) throw new Error("search_text missed UTF-8 file");
658
-
659
- const cappedSearch = await restricted.searchText({ path: workspace, query: "definitely-not-present", max_files: 1, max_matches: 10 });
660
- if (cappedSearch.visited_files !== 1 || cappedSearch.truncated !== true) throw new Error("search_text max_files cap did not apply");
661
-
662
- const repo = join(workspace, "nested-repo");
663
- await mkdir(repo);
664
- await restricted.runProcess("git", ["init", "-q", repo], 10_000);
665
- await writeFile(join(repo, "tracked.txt"), "one\n", "utf8");
666
- await restricted.runProcess("git", ["-C", repo, "add", "tracked.txt"], 10_000);
667
- await restricted.runProcess("git", ["-C", repo, "config", "diff.external", "definitely-not-a-real-diff-command"], 10_000);
668
- await restricted.runProcess("git", ["-C", repo, "config", "core.fsmonitor", "definitely-not-a-real-fsmonitor-command"], 10_000);
669
- await writeFile(join(repo, "tracked.txt"), "two\n", "utf8");
670
- const diff = await restricted.gitDiff({ path: "nested-repo" });
671
- if (diff.code !== 0 || !diff.stdout.includes("tracked.txt") || diff.gitRoot !== await realpath(repo)) throw new Error("nested git diff detection failed");
672
- const status = await restricted.gitStatus({ path: "nested-repo" });
673
- if (status.code !== 0 || !status.stdout.includes("tracked.txt")) throw new Error("nested git status detection failed");
674
-
675
- const command = await restricted.execCommand("printf ${MBM_DAEMON_SELFTEST_SECRET-unset}", 5);
676
- if (command.stdout !== "unset") throw new Error("exec_command inherited unallowlisted environment variables");
677
- await expectReject(() => restricted.execCommand(`printf '${"x".repeat(MAX_COMMAND_BYTES)}'`, 5), "maximum size");
678
- await expectReject(() => restricted.execCommand("printf 'x\0y'", 5), "NUL byte");
679
- if (process.platform !== "win32") {
680
- await expectReject(() => restricted.execCommand("sleep 5", 1), "command timed out");
681
- const interrupted = restricted.runProcess("sleep", ["30"], 60_000);
682
- await new Promise(resolvePromise => setTimeout(resolvePromise, 50));
683
- restricted.terminateActiveProcesses("SIGTERM");
684
- await expectReject(() => interrupted, "exited");
685
- if (restricted.activeProcesses.size !== 0) throw new Error("terminated process remained tracked");
686
-
687
- const descendantPidFile = join(workspace, "timeout-descendant.pid");
688
- const descendantCommand = `(trap '' TERM; sleep 30) & echo $! > ${shellQuote(descendantPidFile)}; wait`;
689
- await expectReject(() => restricted.execCommand(descendantCommand, 1), "command timed out");
690
- await new Promise(resolvePromise => setTimeout(resolvePromise, 2500));
691
- const descendantPid = Number((await readFile(descendantPidFile, "utf8")).trim());
692
- if (isProcessAlive(descendantPid)) {
693
- try { process.kill(descendantPid, "SIGKILL"); } catch {}
694
- throw new Error("timeout escalation left a SIGTERM-ignoring descendant running");
695
- }
696
- }
995
+ function classifyError(error) {
996
+ const message = error instanceof Error ? error.message : String(error);
997
+ if (/cancel/i.test(message)) return "cancelled";
998
+ if (/timed out/i.test(message)) return "timeout";
999
+ if (/outside the configured workspace/i.test(message)) return "path_boundary";
1000
+ if (/disabled by daemon policy|requires .* mode/i.test(message)) return "policy_denied";
1001
+ if (/not found|ENOENT/i.test(message)) return "not_found";
1002
+ if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
1003
+ if (/maximum|exceeds|max_bytes|too many/i.test(message)) return "limit_exceeded";
1004
+ if (/invalid|must|requires|ambiguous|mismatch/i.test(message)) return "invalid_request";
1005
+ return "execution_failed";
1006
+ }
697
1007
 
698
- const restrictedRoots = restricted.listRoots();
699
- if (restrictedRoots.roots.length !== 1 || restrictedRoots.roots[0].path !== await realpath(workspace)) throw new Error("restricted roots exposed paths outside workspace");
700
- const unrestrictedRoots = unrestricted.listRoots();
701
- if (!unrestrictedRoots.roots.some(root => root.path === path.parse(workspace).root)) throw new Error("unrestricted filesystem root missing");
702
- } finally {
703
- if (previousSecret === undefined) delete process.env.MBM_DAEMON_SELFTEST_SECRET;
704
- else process.env.MBM_DAEMON_SELFTEST_SECRET = previousSecret;
705
- await rm(workspace, { recursive: true, force: true }).catch(() => {});
706
- await rm(outside, { recursive: true, force: true }).catch(() => {});
1008
+ function equivalentPathPrefixes(...values) {
1009
+ const prefixes = new Set(values.filter(Boolean).map((value) => String(value)));
1010
+ for (const value of [...prefixes]) {
1011
+ if (value.startsWith("/private/")) prefixes.add(value.slice("/private".length));
1012
+ else if (value.startsWith("/") && ["/var/", "/tmp/", "/etc/"].some((prefix) => value.startsWith(prefix))) prefixes.add(`/private${value}`);
707
1013
  }
708
- return true;
1014
+ return [...prefixes].sort((left, right) => right.length - left.length);
709
1015
  }
710
1016
 
711
- function shellQuote(value) {
712
- return `'${String(value).replaceAll("'", "'\\''")}'`;
1017
+ function replacePathPrefix(message, pathValue, replacement) {
1018
+ if (!pathValue) return message;
1019
+ const normalized = String(pathValue);
1020
+ return message.split(normalized).join(replacement);
713
1021
  }
714
1022
 
715
- function isProcessAlive(pid) {
716
- if (!Number.isInteger(pid) || pid <= 0) return false;
717
- try {
718
- process.kill(pid, 0);
719
- return true;
720
- } catch (error) {
721
- return error?.code === "EPERM";
722
- }
1023
+ function shortCallId(value) {
1024
+ return String(value || "").slice(0, 20);
723
1025
  }
724
1026
 
725
- async function expectReject(callback, pattern) {
726
- try {
727
- await callback();
728
- } catch (error) {
729
- if (String(error?.message || error).includes(pattern)) return;
730
- throw error;
731
- }
732
- throw new Error(`expected rejection containing: ${pattern}`);
1027
+ function redactUrl(value) {
1028
+ try { return new URL(value).origin; } catch { return "<invalid-url>"; }
733
1029
  }