machine-bridge-mcp 0.2.5 → 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,37 +1,34 @@
1
1
  import { spawn } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { existsSync } from "node:fs";
4
- import { mkdir, mkdtemp, opendir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { createHash, randomBytes } from "node:crypto";
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 ALL_TOOL_NAMES = [
11
- "project_overview",
12
- "list_roots",
13
- "list_dir",
14
- "list_files",
15
- "read_file",
16
- "write_file",
17
- "search_text",
18
- "git_status",
19
- "git_diff",
20
- "exec_command",
21
- ];
14
+ export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
15
+ const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
16
+ const MAX_CONCURRENT_TOOL_CALLS = 16;
17
+ const MAX_DIRECTORY_ENTRIES = 10_000;
18
+ const MAX_PATH_RESULT_BYTES = 4 * 1024 * 1024;
19
+ const MAX_WALK_ENTRIES = 200_000;
20
+ const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
22
21
 
23
22
  export class LocalDaemon {
24
- constructor({ workerUrl, secret, workspace, policy, logger = console }) {
25
- this.workerUrl = String(workerUrl || "").replace(/\/+$/, "");
26
- this.secret = secret;
27
- this.workspace = resolve(workspace || process.cwd());
28
- this.policy = {
29
- allowWrite: policy?.allowWrite !== false,
30
- allowExec: policy?.allowExec !== false,
31
- unrestrictedPaths: policy?.unrestrictedPaths !== false,
32
- minimalEnv: policy?.minimalEnv !== false,
33
- };
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);
34
30
  this.logger = logger;
31
+ this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
35
32
  this.closed = false;
36
33
  this.ws = null;
37
34
  this.heartbeat = null;
@@ -39,17 +36,52 @@ export class LocalDaemon {
39
36
  this.connectedOnce = null;
40
37
  this.connectedOnceResolve = null;
41
38
  this.connectedOnceReject = null;
39
+ this.activeToolCalls = 0;
40
+ this.activeProcesses = new Set();
41
+ this.callProcesses = new Map();
42
+ this.cancelledCalls = new Set();
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
+ });
42
60
  }
43
61
 
44
62
  tools() {
45
- return ALL_TOOL_NAMES.filter(name => {
46
- if (name === "write_file") return this.policy.allowWrite;
47
- if (name === "exec_command") return this.policy.allowExec;
48
- return true;
49
- });
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
+ };
50
81
  }
51
82
 
52
83
  start() {
84
+ if (!this.workerUrl || !this.secret) throw new Error("remote daemon start requires a Worker URL and daemon secret");
53
85
  this.closed = false;
54
86
  this.connectedOnce = new Promise((resolvePromise, rejectPromise) => {
55
87
  this.connectedOnceResolve = resolvePromise;
@@ -67,28 +99,27 @@ export class LocalDaemon {
67
99
  this.reconnectTimer = null;
68
100
  this.ws?.close();
69
101
  this.ws = null;
102
+ this.terminateActiveProcesses("SIGKILL");
103
+ this.processSessionManager.clear();
104
+ this.reconnectAttempt = 0;
105
+ rmSync(this.runtimeDir, { recursive: true, force: true });
70
106
  }
71
107
 
72
108
  connect() {
73
109
  if (this.closed) return;
74
110
  const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
75
- this.logger.info?.(`connecting daemon websocket: ${wsUrl}`);
76
- this.ws = new WebSocket(wsUrl, {
77
- headers: {
78
- "X-Bridge-Token": this.secret,
79
- "X-Daemon-Id": `local-${process.pid}`,
80
- },
81
- });
111
+ this.logger.info?.("connecting daemon websocket", { endpoint: redactUrl(wsUrl) });
112
+ const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret } });
113
+ this.ws = socket;
82
114
 
83
- this.ws.on("open", () => {
115
+ socket.on("open", () => {
116
+ if (this.ws !== socket || this.closed) {
117
+ socket.close();
118
+ return;
119
+ }
120
+ this.reconnectAttempt = 0;
84
121
  this.logger.info?.("daemon websocket connected");
85
- this.send({
86
- type: "hello",
87
- workspace_hash: sha256(this.workspace),
88
- workspace_name: basename(this.workspace),
89
- tools: this.tools(),
90
- policy: this.policy,
91
- });
122
+ this.send({ type: "hello", tools: this.tools(), policy: this.policy, protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS });
92
123
  if (this.connectedOnceResolve) {
93
124
  this.connectedOnceResolve(true);
94
125
  this.connectedOnceResolve = null;
@@ -99,222 +130,570 @@ export class LocalDaemon {
99
130
  this.heartbeat.unref?.();
100
131
  });
101
132
 
102
- this.ws.on("message", data => {
103
- void this.handleMessage(String(data)).catch(error => {
104
- this.logger.error?.(`daemon message handler failed: ${error.message}`);
133
+ socket.on("message", data => {
134
+ const raw = String(data);
135
+ if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
136
+ this.logger.warn?.("oversized websocket message rejected");
137
+ return;
138
+ }
139
+ void this.handleMessage(raw).catch(error => {
140
+ this.logger.error?.("daemon message handler failed", { error: this.safeErrorMessage(error) });
105
141
  });
106
142
  });
107
143
 
108
- this.ws.on("close", (code, reason) => {
144
+ socket.on("close", (code, reason) => {
145
+ if (this.ws !== socket) return;
146
+ this.ws = null;
147
+ this.terminateActiveProcesses("SIGTERM", true);
109
148
  if (this.heartbeat) clearInterval(this.heartbeat);
110
149
  this.heartbeat = null;
111
- const text = `daemon websocket closed: ${code} ${String(reason || "")}`;
112
- if (this.closed) this.logger.info?.(text);
113
- else this.logger.warn?.(text);
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);
114
166
  if (!this.closed) {
115
- this.reconnectTimer = setTimeout(() => this.connect(), 3000);
167
+ const delay = reconnectDelay(this.reconnectAttempt++);
168
+ this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay });
169
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
116
170
  this.reconnectTimer.unref?.();
117
171
  }
118
172
  });
119
173
 
120
- this.ws.on("error", error => {
121
- // Do not reject the first-connection promise: the close handler schedules
122
- // reconnects, and first deploy propagation can briefly race WebSocket setup.
123
- if (this.closed) this.logger.info?.(`daemon websocket closed during shutdown: ${error.message}`);
124
- else this.logger.error?.(`daemon websocket error: ${error.message}`);
174
+ socket.on("error", error => {
175
+ if (this.ws !== socket) return;
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) });
125
178
  });
126
179
  }
127
180
 
128
181
  send(value) {
129
- if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(value));
182
+ if (this.ws?.readyState !== WebSocket.OPEN) return false;
183
+ try {
184
+ this.ws.send(JSON.stringify(value));
185
+ return true;
186
+ } catch (error) {
187
+ this.logger.warn?.("daemon websocket send failed", { error: boundedErrorMessage(error) });
188
+ return false;
189
+ }
130
190
  }
131
191
 
132
192
  async handleMessage(raw) {
133
193
  let message;
134
- try {
135
- message = JSON.parse(raw);
136
- } catch {
194
+ try { message = JSON.parse(raw); } catch {
137
195
  this.logger.warn?.("invalid websocket JSON");
138
196
  return;
139
197
  }
140
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
+ }
141
203
  if (message.type !== "tool_call") {
142
- this.logger.warn?.(`unknown websocket message: ${message.type}`);
204
+ this.logger.warn?.("unknown websocket message", { type: String(message.type || "") });
143
205
  return;
144
206
  }
145
207
 
146
- const id = message.id;
208
+ const id = typeof message.id === "string" ? message.id : "";
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)) {
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" } });
214
+ return;
215
+ }
216
+ if (this.activeToolCalls >= MAX_CONCURRENT_TOOL_CALLS) {
217
+ this.send({ type: "tool_result", id, ok: false, error: { message: "too many concurrent tool calls" } });
218
+ return;
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?.();
224
+ this.activeToolCalls += 1;
225
+ const started = Date.now();
226
+ this.logger.debug?.("tool call started", { call_id: shortCallId(id), tool });
147
227
  try {
148
- 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");
149
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 });
150
232
  } catch (error) {
151
- this.send({ type: "tool_result", id, ok: false, error: { message: error.message } });
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) });
236
+ } finally {
237
+ clearTimeout(deadline);
238
+ this.activeToolCalls -= 1;
239
+ this.finishCall(id);
152
240
  }
153
241
  }
154
242
 
155
- 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}`);
156
265
  switch (tool) {
157
- case "project_overview": return this.projectOverview();
266
+ case "server_info": return this.runtimeInfo();
267
+ case "project_overview": return this.projectOverview(context);
158
268
  case "list_roots": return this.listRoots();
159
- case "list_dir": return this.listDir(args.path || ".");
160
- case "list_files": return this.listFiles(args.path || ".", clampInt(args.max_files, 1000, 1, 10000));
161
- case "read_file": return this.readFile(args.path, clampInt(args.max_bytes, 1024 * 1024, 1, 5 * 1024 * 1024));
162
- case "write_file": return this.writeFile(args);
163
- case "search_text": return this.searchText(args);
164
- case "git_status": return this.runProcess("git", ["-C", this.workspace, "status", "--short"], 30_000, true);
165
- case "git_diff": return this.gitDiff(args);
166
- case "exec_command": return this.execCommand(args.command, clampInt(args.timeout_seconds, 120, 1, 600));
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);
167
287
  default: throw new Error(`unknown daemon tool: ${tool}`);
168
288
  }
169
289
  }
170
290
 
171
- async projectOverview() {
172
- const top = await this.listDir(".").catch(error => ({ error: error.message, entries: [] }));
173
- const git = await this.runProcess("git", ["-C", this.workspace, "rev-parse", "--show-toplevel"], 10_000, true);
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);
174
295
  return {
175
- workspace: this.workspace,
296
+ workspace: this.displayPath(this.workspace),
176
297
  workspaceName: basename(this.workspace),
177
- gitRoot: git.code === 0 ? git.stdout.trim() : "",
298
+ gitRoot: git.code === 0 ? this.displayPath(git.stdout.trim()) : "",
178
299
  policy: this.policy,
179
- tools: this.tools(),
300
+ tools: ["server_info", ...this.tools()],
180
301
  topLevel: top.entries || [],
181
302
  };
182
303
  }
183
304
 
184
305
  listRoots() {
185
- const roots = [{ name: basename(this.workspace), path: this.workspace, default: true }];
186
- const home = process.env.HOME;
187
- if (home && home !== this.workspace) roots.push({ name: "home", path: home, default: false });
188
- roots.push({ name: "filesystem-root", path: path.parse(this.workspace).root, default: false });
306
+ const roots = [{ name: basename(this.workspace), path: this.displayPath(this.workspace), default: true }];
307
+ if (this.policy.unrestrictedPaths) {
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 });
311
+ }
189
312
  return { roots };
190
313
  }
191
314
 
192
- async listDir(inputPath) {
193
- const full = this.resolvePath(inputPath);
315
+ async listDir(inputPath, context = {}) {
316
+ const full = await this.resolveExistingPath(inputPath);
194
317
  const entries = [];
318
+ let resultBytes = 0;
319
+ let truncated = false;
195
320
  for await (const entry of await opendir(full)) {
321
+ this.throwIfCancelled(context);
196
322
  const entryPath = resolve(full, entry.name);
197
- const info = await stat(entryPath).catch(() => null);
198
- entries.push({
323
+ const info = await lstat(entryPath).catch(() => null);
324
+ const item = {
199
325
  name: entry.name,
200
- path: entryPath,
326
+ path: this.displayPath(entryPath),
201
327
  type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other",
202
328
  size: info?.size ?? 0,
203
- });
329
+ };
330
+ const itemBytes = Buffer.byteLength(item.name) + Buffer.byteLength(item.path) + 64;
331
+ if (entries.length >= MAX_DIRECTORY_ENTRIES || resultBytes + itemBytes > MAX_PATH_RESULT_BYTES) {
332
+ truncated = true;
333
+ break;
334
+ }
335
+ entries.push(item);
336
+ resultBytes += itemBytes;
204
337
  }
205
338
  entries.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
206
- return { path: full, entries };
339
+ return { path: this.displayPath(full), entries, truncated };
207
340
  }
208
341
 
209
- async listFiles(inputPath, maxFiles) {
210
- const root = this.resolvePath(inputPath);
342
+ async listFiles(inputPath, maxFiles, context = {}) {
343
+ const root = await this.resolveExistingPath(inputPath);
211
344
  const info = await stat(root);
212
- 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 };
213
346
  if (!info.isDirectory()) throw new Error("path is not a file or directory");
214
347
  const files = [];
215
- await this.walk(root, async full => {
216
- if (files.length >= maxFiles) return false;
217
- files.push(full);
348
+ let resultBytes = 0;
349
+ const walkResult = await this.walk(root, async full => {
350
+ this.throwIfCancelled(context);
351
+ const shown = this.displayPath(full);
352
+ const pathBytes = Buffer.byteLength(shown) + 8;
353
+ if (files.length >= maxFiles || resultBytes + pathBytes > MAX_PATH_RESULT_BYTES) return false;
354
+ files.push(shown);
355
+ resultBytes += pathBytes;
218
356
  return true;
219
- });
220
- return { path: root, files, truncated: files.length >= maxFiles };
357
+ }, context);
358
+ return { path: this.displayPath(root), files, truncated: files.length >= maxFiles || resultBytes >= MAX_PATH_RESULT_BYTES || walkResult.truncated };
221
359
  }
222
360
 
223
- async readFile(inputPath, maxBytes) {
224
- if (!inputPath) throw new Error("path is required");
225
- const full = this.resolvePath(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);
226
368
  const info = await stat(full);
227
369
  if (!info.isFile()) throw new Error("path is not a file");
228
- if (info.size > maxBytes) throw new Error(`file exceeds max_bytes (${info.size} > ${maxBytes})`);
229
- const content = await readFile(full, "utf8");
230
- return { path: full, size: info.size, sha256: sha256(content), content };
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);
372
+ const content = await readUtf8File(full);
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
+ };
231
396
  }
232
397
 
233
- async writeFile(args) {
234
- if (!this.policy.allowWrite) throw new Error("write_file is disabled by daemon policy");
398
+ async viewImage(args, context = {}) {
235
399
  if (!args.path) throw new Error("path is required");
236
- const full = this.resolvePath(args.path);
237
- if (args.create_only && existsSync(full)) throw new Error("file exists and create_only=true");
238
- if (args.expected_sha256 && existsSync(full)) {
239
- const current = await readFile(full, "utf8");
240
- if (sha256(current) !== args.expected_sha256) throw new Error("expected_sha256 mismatch");
241
- }
242
- await mkdir(dirname(full), { recursive: true });
243
- await writeFile(full, String(args.content ?? ""), "utf8");
244
- const content = await readFile(full, "utf8");
245
- return { ok: true, path: full, sha256: sha256(content), bytes: Buffer.byteLength(content) };
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");
460
+ const current = await readUtf8File(full);
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 });
505
+ }
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
+ });
246
519
  }
247
520
 
248
- async searchText(args) {
521
+ async searchText(args, context = {}) {
249
522
  const query = String(args.query || "");
250
523
  if (!query) throw new Error("query is required");
251
- const root = this.resolvePath(args.path || ".");
524
+ const root = await this.resolveExistingPath(args.path || ".");
252
525
  const max = clampInt(args.max_matches, 100, 1, 1000);
253
526
  const maxFiles = clampInt(args.max_files, 10000, 1, 100000);
254
527
  let visitedFiles = 0;
255
528
  const matches = [];
256
529
  const rootInfo = await stat(root);
257
530
  if (rootInfo.isFile()) {
258
- await this.searchOneFile(root, query, matches, max);
259
- 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 };
260
533
  }
261
534
  if (!rootInfo.isDirectory()) throw new Error("path is not a file or directory");
262
- await this.walk(root, async full => {
535
+ const walkResult = await this.walk(root, async full => {
536
+ this.throwIfCancelled(context);
263
537
  if (matches.length >= max || visitedFiles >= maxFiles) return false;
264
538
  visitedFiles += 1;
265
- await this.searchOneFile(full, query, matches, max);
539
+ await this.searchOneFile(full, query, matches, max, context);
266
540
  return matches.length < max && visitedFiles < maxFiles;
267
- });
268
- return { query, root, matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles };
541
+ }, context);
542
+ return { query, root: this.displayPath(root), matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles || walkResult.truncated };
269
543
  }
270
544
 
271
- async searchOneFile(full, query, matches, max) {
545
+ async searchOneFile(full, query, matches, max, context = {}) {
546
+ this.throwIfCancelled(context);
272
547
  const info = await stat(full).catch(() => null);
273
548
  if (!info?.isFile() || info.size > 1024 * 1024) return;
274
- const text = await readFile(full, "utf8").catch(() => "");
549
+ const buffer = await readFile(full).catch(() => null);
550
+ if (!buffer || buffer.includes(0)) return;
551
+ let text;
552
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch { return; }
275
553
  if (!text) return;
276
554
  const lines = text.split(/\r?\n/);
277
555
  for (let index = 0; index < lines.length; index += 1) {
278
556
  if (lines[index].includes(query)) {
279
- 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) });
280
558
  if (matches.length >= max) break;
281
559
  }
282
560
  }
283
561
  }
284
562
 
285
- async gitDiff(args) {
286
- const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, 5 * 1024 * 1024);
287
- const target = args.path ? this.resolvePath(args.path) : this.workspace;
288
- const result = await this.runProcess("git", ["-C", this.workspace, "diff", "--", target], 60_000, true, maxBytes);
289
- return { ...result, path: target };
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 };
290
581
  }
291
582
 
292
- async execCommand(command, timeoutSeconds) {
293
- if (!this.policy.allowExec) throw new Error("exec_command is disabled by daemon policy");
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) };
598
+ }
599
+
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) };
609
+ }
610
+
611
+ async gitContext(inputPath, context = {}) {
612
+ const target = await this.resolveExistingPath(inputPath);
613
+ const info = await stat(target);
614
+ const cwd = info.isDirectory() ? target : dirname(target);
615
+ const result = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", cwd, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
616
+ if (result.code !== 0) return { ok: false, result, target };
617
+ const root = result.stdout.trim();
618
+ const repoRelative = relative(root, target);
619
+ if (repoRelative.startsWith(`..${sep}`) || repoRelative === ".." || isAbsolute(repoRelative)) {
620
+ return { ok: false, target, result: { code: 128, stdout: "", stderr: "target is outside the detected git repository" } };
621
+ }
622
+ return { ok: true, target, root, pathspec: repoRelative || "" };
623
+ }
624
+
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");
294
635
  if (!command || typeof command !== "string") throw new Error("command is required");
636
+ if (command.includes("\0")) throw new Error("command contains a NUL byte");
637
+ if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new Error(`command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
295
638
  const shell = workspaceShellCommand(command);
296
- 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);
297
640
  }
298
641
 
299
- async runProcess(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = 512 * 1024) {
642
+ terminateActiveProcesses(signal = "SIGTERM", escalate = false) {
643
+ const children = [...this.activeProcesses];
644
+ for (const child of children) terminateProcessTree(child, signal);
645
+ if (escalate && signal !== "SIGKILL" && children.length) {
646
+ const timer = setTimeout(() => {
647
+ for (const child of children) if (this.activeProcesses.has(child)) terminateProcessTree(child, "SIGKILL");
648
+ }, 2000);
649
+ timer.unref?.();
650
+ }
651
+ }
652
+
653
+ async runProcess(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = 512 * 1024, context = {}, cwd = this.workspace) {
654
+ this.throwIfCancelled(context);
300
655
  return new Promise((resolvePromise, reject) => {
301
- const child = spawn(cmd, args, { cwd: this.workspace, env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false }) });
656
+ const child = spawn(cmd, args, {
657
+ cwd,
658
+ env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false, runtimeDir: this.runtimeDir }),
659
+ detached: process.platform !== "win32",
660
+ windowsHide: true,
661
+ });
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
+ }
302
668
  let stdout = "";
303
669
  let stderr = "";
304
670
  let stdoutTruncated = 0;
305
671
  let stderrTruncated = 0;
306
672
  let timedOut = false;
673
+ let settled = false;
307
674
  let killTimer = null;
308
675
  const timer = setTimeout(() => {
309
676
  timedOut = true;
310
- child.kill("SIGTERM");
311
- killTimer = setTimeout(() => child.kill("SIGKILL"), 2000);
677
+ terminateProcessTree(child, "SIGTERM");
678
+ killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
312
679
  killTimer.unref?.();
313
680
  }, timeoutMs);
314
681
  timer.unref?.();
315
- const clearTimers = () => {
682
+ const cleanup = () => {
316
683
  clearTimeout(timer);
317
- if (killTimer) clearTimeout(killTimer);
684
+ if (killTimer && !timedOut) clearTimeout(killTimer);
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
+ }
691
+ };
692
+ const finish = callback => {
693
+ if (settled) return;
694
+ settled = true;
695
+ cleanup();
696
+ callback();
318
697
  };
319
698
  child.stdout.on("data", chunk => {
320
699
  const next = appendLimited(stdout, chunk, maxOutputBytes);
@@ -326,45 +705,250 @@ export class LocalDaemon {
326
705
  stderr = next.value;
327
706
  stderrTruncated += next.truncated;
328
707
  });
329
- child.on("error", error => {
330
- clearTimers();
331
- if (allowFailure) resolvePromise({ code: 127, stdout, stderr: error.message });
708
+ child.on("error", error => finish(() => {
709
+ if (allowFailure) resolvePromise({ code: 127, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: boundedErrorMessage(error) });
332
710
  else reject(error);
333
- });
334
- child.on("close", code => {
335
- clearTimers();
711
+ }));
712
+ child.on("close", code => finish(() => {
336
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
+ }
337
718
  if (timedOut) {
338
719
  reject(new Error(`command timed out after ${timeoutMs}ms`));
339
720
  return;
340
721
  }
341
722
  if (code === 0 || allowFailure) resolvePromise(result);
342
723
  else reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
343
- });
724
+ }));
344
725
  });
345
726
  }
346
727
 
347
- async walk(root, onFile) {
728
+ async walk(root, onFile, context = {}) {
348
729
  const stack = [root];
730
+ let visitedEntries = 0;
349
731
  while (stack.length) {
732
+ this.throwIfCancelled(context);
350
733
  const current = stack.pop();
351
734
  const entries = await opendir(current).catch(() => null);
352
735
  if (!entries) continue;
353
736
  for await (const entry of entries) {
737
+ this.throwIfCancelled(context);
738
+ visitedEntries += 1;
739
+ if (visitedEntries > MAX_WALK_ENTRIES) return { truncated: true, visitedEntries };
354
740
  const full = resolve(current, entry.name);
355
741
  if (entry.isDirectory()) stack.push(full);
356
742
  else if (entry.isFile()) {
357
743
  const keepGoing = await onFile(full);
358
- if (keepGoing === false) return;
744
+ if (keepGoing === false) return { truncated: true, visitedEntries };
359
745
  }
360
746
  }
361
747
  }
748
+ return { truncated: false, visitedEntries };
362
749
  }
363
750
 
364
751
  resolvePath(inputPath = ".") {
365
752
  const raw = String(inputPath || ".");
753
+ if (raw.includes("\0")) throw new Error("path contains a NUL byte");
366
754
  return isAbsolute(raw) ? resolve(raw) : resolve(this.workspace, raw);
367
755
  }
756
+
757
+ async resolveExistingPath(inputPath = ".") {
758
+ const candidate = this.resolvePath(inputPath);
759
+ const canonical = await realpath(candidate);
760
+ if (!this.policy.unrestrictedPaths) assertContainedPath(this.workspace, canonical);
761
+ return canonical;
762
+ }
763
+
764
+ async resolveWritePath(inputPath = ".") {
765
+ const candidate = this.resolvePath(inputPath);
766
+ if (this.policy.unrestrictedPaths) return candidate;
767
+ let ancestor = candidate;
768
+ while (!(await lstat(ancestor).catch(() => null))) {
769
+ const parent = dirname(ancestor);
770
+ if (parent === ancestor) break;
771
+ ancestor = parent;
772
+ }
773
+ const canonicalAncestor = await realpath(ancestor);
774
+ assertContainedPath(this.workspace, canonicalAncestor);
775
+ return candidate;
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
+ }
808
+ }
809
+
810
+ function normalizeWorkerUrl(value) {
811
+ let url;
812
+ try { url = new URL(String(value || "")); } catch { throw new Error("invalid Worker URL"); }
813
+ if (url.protocol !== "https:") throw new Error("Worker URL must use HTTPS");
814
+ if (url.username || url.password) throw new Error("Worker URL must not contain credentials");
815
+ if (url.pathname !== "/" || url.search || url.hash) throw new Error("Worker URL must be an origin without a path, query, or fragment");
816
+ return url.origin;
817
+ }
818
+
819
+ export function isSupersededClose(code, reason) {
820
+ return Number(code) === 1012 && String(reason || "") === "replaced by authenticated daemon";
821
+ }
822
+
823
+ function reconnectDelay(attempt) {
824
+ const base = Math.min(3000 * (2 ** Math.min(attempt, 4)), 60_000);
825
+ return base + Math.floor(Math.random() * 1000);
826
+ }
827
+
828
+
829
+ function assertContainedPath(root, target) {
830
+ const rel = relative(root, target);
831
+ if (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))) return;
832
+ throw new Error("path is outside the configured workspace; restart with --unrestricted-paths to allow it");
833
+ }
834
+
835
+ async function readUtf8File(filePath) {
836
+ const buffer = await readFile(filePath);
837
+ try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
838
+ throw new Error("file is not valid UTF-8 text");
839
+ }
840
+ }
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
+
949
+ function boundedErrorMessage(error) {
950
+ const message = error instanceof Error ? error.message : String(error);
951
+ return message.replace(/[\r\n]+/g, " ").slice(0, 4096) || "tool call failed";
368
952
  }
369
953
 
370
954
  export function sha256(value) {
@@ -390,52 +974,56 @@ function clampInt(value, fallback, min, max) {
390
974
  return Math.min(Math.max(number, min), max);
391
975
  }
392
976
 
393
- export async function daemonSelfTest() {
394
- const workspace = await mkdtemp(join(tmpdir(), "mbm-daemon-workspace-"));
395
- const outside = await mkdtemp(join(tmpdir(), "mbm-daemon-outside-"));
396
- const daemon = new LocalDaemon({
397
- workerUrl: "https://example.invalid",
398
- secret: "test",
399
- workspace,
400
- policy: { allowWrite: true, allowExec: true, unrestrictedPaths: true },
401
- logger: { info() {}, warn() {}, error() {} },
402
- });
403
- const previousSecret = process.env.MBM_DAEMON_SELFTEST_SECRET;
404
- process.env.MBM_DAEMON_SELFTEST_SECRET = "should-not-leak";
405
- try {
406
- await writeFile(join(workspace, ".env"), "SECRET=visible", "utf8");
407
- await writeFile(join(workspace, "visible.txt"), "needle", "utf8");
408
- await writeFile(join(outside, "outside.txt"), "outside-needle", "utf8");
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
+ }
409
982
 
410
- const envFile = await daemon.readFile(".env", 1024);
411
- if (!envFile.content.includes("SECRET=visible")) throw new Error(".env should be readable by default");
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
+ }
412
990
 
413
- const outsideFile = await daemon.readFile(join(outside, "outside.txt"), 1024);
414
- if (!outsideFile.content.includes("outside-needle")) throw new Error("absolute outside path should be readable");
991
+ function isPlainRecord(value) {
992
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
993
+ }
415
994
 
416
- const parentFile = await daemon.readFile(path.relative(workspace, join(outside, "outside.txt")), 1024);
417
- if (!parentFile.content.includes("outside-needle")) throw new Error("relative path escaping workspace should be readable");
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
+ }
418
1007
 
419
- const writtenPath = join(outside, "written.txt");
420
- await daemon.writeFile({ path: writtenPath, content: "written" });
421
- const written = await readFile(writtenPath, "utf8");
422
- if (written !== "written") throw new Error("absolute outside path should be writable");
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}`);
1013
+ }
1014
+ return [...prefixes].sort((left, right) => right.length - left.length);
1015
+ }
423
1016
 
424
- const cappedSearch = await daemon.searchText({ path: workspace, query: "definitely-not-present", max_files: 1, max_matches: 10 });
425
- if (cappedSearch.visited_files !== 1 || cappedSearch.truncated !== true) {
426
- throw new Error("search_text max_files cap did not apply");
427
- }
1017
+ function replacePathPrefix(message, pathValue, replacement) {
1018
+ if (!pathValue) return message;
1019
+ const normalized = String(pathValue);
1020
+ return message.split(normalized).join(replacement);
1021
+ }
428
1022
 
429
- const command = await daemon.execCommand("printf ${MBM_DAEMON_SELFTEST_SECRET-unset}", 5);
430
- if (command.stdout !== "unset") throw new Error("exec_command inherited unallowlisted environment variables");
1023
+ function shortCallId(value) {
1024
+ return String(value || "").slice(0, 20);
1025
+ }
431
1026
 
432
- const roots = daemon.listRoots();
433
- if (!roots.roots.some(root => root.path === workspace)) throw new Error("workspace root missing");
434
- } finally {
435
- if (previousSecret === undefined) delete process.env.MBM_DAEMON_SELFTEST_SECRET;
436
- else process.env.MBM_DAEMON_SELFTEST_SECRET = previousSecret;
437
- await rm(workspace, { recursive: true, force: true }).catch(() => {});
438
- await rm(outside, { recursive: true, force: true }).catch(() => {});
439
- }
440
- return true;
1027
+ function redactUrl(value) {
1028
+ try { return new URL(value).origin; } catch { return "<invalid-url>"; }
441
1029
  }