machine-bridge-mcp 0.3.3 → 0.4.2

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