machine-bridge-mcp 0.2.4 → 0.3.3

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,12 +1,20 @@
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 { existsSync, realpathSync } from "node:fs";
4
+ import { chmod, lstat, mkdir, mkdtemp, opendir, readFile, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import path, { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
7
7
  import WebSocket from "ws";
8
8
  import { executionEnv, workspaceShellCommand } from "./shell.mjs";
9
9
 
10
+ const MAX_WRITE_BYTES = 5 * 1024 * 1024;
11
+ const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
12
+ const MAX_CONCURRENT_TOOL_CALLS = 16;
13
+ const MAX_COMMAND_BYTES = 64 * 1024;
14
+ const MAX_DIRECTORY_ENTRIES = 10_000;
15
+ const MAX_PATH_RESULT_BYTES = 4 * 1024 * 1024;
16
+ const MAX_WALK_ENTRIES = 200_000;
17
+
10
18
  const ALL_TOOL_NAMES = [
11
19
  "project_overview",
12
20
  "list_roots",
@@ -22,13 +30,14 @@ const ALL_TOOL_NAMES = [
22
30
 
23
31
  export class LocalDaemon {
24
32
  constructor({ workerUrl, secret, workspace, policy, logger = console }) {
25
- this.workerUrl = String(workerUrl || "").replace(/\/+$/, "");
33
+ this.workerUrl = normalizeWorkerUrl(workerUrl);
34
+ if (typeof secret !== "string" || secret.length < 16) throw new Error("daemon secret is missing or too short");
26
35
  this.secret = secret;
27
- this.workspace = resolve(workspace || process.cwd());
36
+ this.workspace = realpathSync(resolve(workspace || process.cwd()));
28
37
  this.policy = {
29
38
  allowWrite: policy?.allowWrite !== false,
30
39
  allowExec: policy?.allowExec !== false,
31
- unrestrictedPaths: policy?.unrestrictedPaths !== false,
40
+ unrestrictedPaths: policy?.unrestrictedPaths === true,
32
41
  minimalEnv: policy?.minimalEnv !== false,
33
42
  };
34
43
  this.logger = logger;
@@ -39,6 +48,9 @@ export class LocalDaemon {
39
48
  this.connectedOnce = null;
40
49
  this.connectedOnceResolve = null;
41
50
  this.connectedOnceReject = null;
51
+ this.activeToolCalls = 0;
52
+ this.activeProcesses = new Set();
53
+ this.reconnectAttempt = 0;
42
54
  }
43
55
 
44
56
  tools() {
@@ -67,25 +79,30 @@ export class LocalDaemon {
67
79
  this.reconnectTimer = null;
68
80
  this.ws?.close();
69
81
  this.ws = null;
82
+ this.terminateActiveProcesses("SIGKILL");
83
+ this.reconnectAttempt = 0;
70
84
  }
71
85
 
72
86
  connect() {
73
87
  if (this.closed) return;
74
88
  const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
75
89
  this.logger.info?.(`connecting daemon websocket: ${wsUrl}`);
76
- this.ws = new WebSocket(wsUrl, {
90
+ const socket = new WebSocket(wsUrl, {
77
91
  headers: {
78
92
  "X-Bridge-Token": this.secret,
79
- "X-Daemon-Id": `local-${process.pid}`,
80
93
  },
81
94
  });
95
+ this.ws = socket;
82
96
 
83
- this.ws.on("open", () => {
97
+ socket.on("open", () => {
98
+ if (this.ws !== socket || this.closed) {
99
+ socket.close();
100
+ return;
101
+ }
102
+ this.reconnectAttempt = 0;
84
103
  this.logger.info?.("daemon websocket connected");
85
104
  this.send({
86
105
  type: "hello",
87
- workspace_hash: sha256(this.workspace),
88
- workspace_name: basename(this.workspace),
89
106
  tools: this.tools(),
90
107
  policy: this.policy,
91
108
  });
@@ -99,25 +116,36 @@ export class LocalDaemon {
99
116
  this.heartbeat.unref?.();
100
117
  });
101
118
 
102
- this.ws.on("message", data => {
103
- void this.handleMessage(String(data)).catch(error => {
119
+ socket.on("message", data => {
120
+ const raw = String(data);
121
+ if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
122
+ this.logger.warn?.("oversized websocket message rejected");
123
+ return;
124
+ }
125
+ void this.handleMessage(raw).catch(error => {
104
126
  this.logger.error?.(`daemon message handler failed: ${error.message}`);
105
127
  });
106
128
  });
107
129
 
108
- this.ws.on("close", (code, reason) => {
130
+ socket.on("close", (code, reason) => {
131
+ if (this.ws !== socket) return;
132
+ this.ws = null;
133
+ this.terminateActiveProcesses("SIGTERM", true);
109
134
  if (this.heartbeat) clearInterval(this.heartbeat);
110
135
  this.heartbeat = null;
111
136
  const text = `daemon websocket closed: ${code} ${String(reason || "")}`;
112
137
  if (this.closed) this.logger.info?.(text);
113
138
  else this.logger.warn?.(text);
114
139
  if (!this.closed) {
115
- this.reconnectTimer = setTimeout(() => this.connect(), 3000);
140
+ const delay = reconnectDelay(this.reconnectAttempt++);
141
+ this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay });
142
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
116
143
  this.reconnectTimer.unref?.();
117
144
  }
118
145
  });
119
146
 
120
- this.ws.on("error", error => {
147
+ socket.on("error", error => {
148
+ if (this.ws !== socket) return;
121
149
  // Do not reject the first-connection promise: the close handler schedules
122
150
  // reconnects, and first deploy propagation can briefly race WebSocket setup.
123
151
  if (this.closed) this.logger.info?.(`daemon websocket closed during shutdown: ${error.message}`);
@@ -126,7 +154,14 @@ export class LocalDaemon {
126
154
  }
127
155
 
128
156
  send(value) {
129
- if (this.ws?.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(value));
157
+ if (this.ws?.readyState !== WebSocket.OPEN) return false;
158
+ try {
159
+ this.ws.send(JSON.stringify(value));
160
+ return true;
161
+ } catch (error) {
162
+ this.logger.warn?.(`daemon websocket send failed: ${boundedErrorMessage(error)}`);
163
+ return false;
164
+ }
130
165
  }
131
166
 
132
167
  async handleMessage(raw) {
@@ -143,12 +178,23 @@ export class LocalDaemon {
143
178
  return;
144
179
  }
145
180
 
146
- const id = message.id;
181
+ const id = typeof message.id === "string" ? message.id : "";
182
+ if (!id || typeof message.tool !== "string") {
183
+ this.logger.warn?.("invalid tool_call envelope");
184
+ return;
185
+ }
186
+ if (this.activeToolCalls >= MAX_CONCURRENT_TOOL_CALLS) {
187
+ this.send({ type: "tool_result", id, ok: false, error: { message: "too many concurrent tool calls" } });
188
+ return;
189
+ }
190
+ this.activeToolCalls += 1;
147
191
  try {
148
192
  const result = await this.executeTool(message.tool, message.arguments || {});
149
193
  this.send({ type: "tool_result", id, ok: true, result });
150
194
  } catch (error) {
151
- this.send({ type: "tool_result", id, ok: false, error: { message: error.message } });
195
+ this.send({ type: "tool_result", id, ok: false, error: { message: boundedErrorMessage(error) } });
196
+ } finally {
197
+ this.activeToolCalls -= 1;
152
198
  }
153
199
  }
154
200
 
@@ -161,7 +207,7 @@ export class LocalDaemon {
161
207
  case "read_file": return this.readFile(args.path, clampInt(args.max_bytes, 1024 * 1024, 1, 5 * 1024 * 1024));
162
208
  case "write_file": return this.writeFile(args);
163
209
  case "search_text": return this.searchText(args);
164
- case "git_status": return this.runProcess("git", ["-C", this.workspace, "status", "--short"], 30_000, true);
210
+ case "git_status": return this.gitStatus(args);
165
211
  case "git_diff": return this.gitDiff(args);
166
212
  case "exec_command": return this.execCommand(args.command, clampInt(args.timeout_seconds, 120, 1, 600));
167
213
  default: throw new Error(`unknown daemon tool: ${tool}`);
@@ -183,72 +229,104 @@ export class LocalDaemon {
183
229
 
184
230
  listRoots() {
185
231
  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 });
232
+ 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 });
236
+ }
189
237
  return { roots };
190
238
  }
191
239
 
192
240
  async listDir(inputPath) {
193
- const full = this.resolvePath(inputPath);
241
+ const full = await this.resolveExistingPath(inputPath);
194
242
  const entries = [];
243
+ let resultBytes = 0;
244
+ let truncated = false;
195
245
  for await (const entry of await opendir(full)) {
196
246
  const entryPath = resolve(full, entry.name);
197
- const info = await stat(entryPath).catch(() => null);
198
- entries.push({
247
+ const info = await lstat(entryPath).catch(() => null);
248
+ const item = {
199
249
  name: entry.name,
200
250
  path: entryPath,
201
251
  type: entry.isDirectory() ? "directory" : entry.isFile() ? "file" : entry.isSymbolicLink() ? "symlink" : "other",
202
252
  size: info?.size ?? 0,
203
- });
253
+ };
254
+ const itemBytes = Buffer.byteLength(item.name) + Buffer.byteLength(item.path) + 64;
255
+ if (entries.length >= MAX_DIRECTORY_ENTRIES || resultBytes + itemBytes > MAX_PATH_RESULT_BYTES) {
256
+ truncated = true;
257
+ break;
258
+ }
259
+ entries.push(item);
260
+ resultBytes += itemBytes;
204
261
  }
205
262
  entries.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
206
- return { path: full, entries };
263
+ return { path: full, entries, truncated };
207
264
  }
208
265
 
209
266
  async listFiles(inputPath, maxFiles) {
210
- const root = this.resolvePath(inputPath);
267
+ const root = await this.resolveExistingPath(inputPath);
211
268
  const info = await stat(root);
212
269
  if (info.isFile()) return { path: root, files: [root], truncated: false };
213
270
  if (!info.isDirectory()) throw new Error("path is not a file or directory");
214
271
  const files = [];
215
- await this.walk(root, async full => {
216
- if (files.length >= maxFiles) return false;
272
+ let resultBytes = 0;
273
+ const walkResult = await this.walk(root, async full => {
274
+ const pathBytes = Buffer.byteLength(full) + 8;
275
+ if (files.length >= maxFiles || resultBytes + pathBytes > MAX_PATH_RESULT_BYTES) return false;
217
276
  files.push(full);
277
+ resultBytes += pathBytes;
218
278
  return true;
219
279
  });
220
- return { path: root, files, truncated: files.length >= maxFiles };
280
+ return { path: root, files, truncated: files.length >= maxFiles || resultBytes >= MAX_PATH_RESULT_BYTES || walkResult.truncated };
221
281
  }
222
282
 
223
283
  async readFile(inputPath, maxBytes) {
224
284
  if (!inputPath) throw new Error("path is required");
225
- const full = this.resolvePath(inputPath);
285
+ const full = await this.resolveExistingPath(inputPath);
226
286
  const info = await stat(full);
227
287
  if (!info.isFile()) throw new Error("path is not a file");
228
288
  if (info.size > maxBytes) throw new Error(`file exceeds max_bytes (${info.size} > ${maxBytes})`);
229
- const content = await readFile(full, "utf8");
289
+ const content = await readUtf8File(full);
230
290
  return { path: full, size: info.size, sha256: sha256(content), content };
231
291
  }
232
292
 
233
293
  async writeFile(args) {
234
294
  if (!this.policy.allowWrite) throw new Error("write_file is disabled by daemon policy");
235
295
  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");
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");
306
+ const current = await readUtf8File(full);
307
+ if (sha256(current) !== String(args.expected_sha256)) throw new Error("expected_sha256 mismatch");
241
308
  }
242
309
  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) };
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;
321
+ }
322
+ }
323
+ return { ok: true, path: full, sha256: sha256(content), bytes };
246
324
  }
247
325
 
248
326
  async searchText(args) {
249
327
  const query = String(args.query || "");
250
328
  if (!query) throw new Error("query is required");
251
- const root = this.resolvePath(args.path || ".");
329
+ const root = await this.resolveExistingPath(args.path || ".");
252
330
  const max = clampInt(args.max_matches, 100, 1, 1000);
253
331
  const maxFiles = clampInt(args.max_files, 10000, 1, 100000);
254
332
  let visitedFiles = 0;
@@ -259,19 +337,22 @@ export class LocalDaemon {
259
337
  return { query, root, matches, visited_files: 1, truncated: matches.length >= max };
260
338
  }
261
339
  if (!rootInfo.isDirectory()) throw new Error("path is not a file or directory");
262
- await this.walk(root, async full => {
340
+ const walkResult = await this.walk(root, async full => {
263
341
  if (matches.length >= max || visitedFiles >= maxFiles) return false;
264
342
  visitedFiles += 1;
265
343
  await this.searchOneFile(full, query, matches, max);
266
344
  return matches.length < max && visitedFiles < maxFiles;
267
345
  });
268
- return { query, root, matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles };
346
+ return { query, root, matches, visited_files: visitedFiles, truncated: matches.length >= max || visitedFiles >= maxFiles || walkResult.truncated };
269
347
  }
270
348
 
271
349
  async searchOneFile(full, query, matches, max) {
272
350
  const info = await stat(full).catch(() => null);
273
351
  if (!info?.isFile() || info.size > 1024 * 1024) return;
274
- const text = await readFile(full, "utf8").catch(() => "");
352
+ const buffer = await readFile(full).catch(() => null);
353
+ if (!buffer || buffer.includes(0)) return;
354
+ let text;
355
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch { return; }
275
356
  if (!text) return;
276
357
  const lines = text.split(/\r?\n/);
277
358
  for (let index = 0; index < lines.length; index += 1) {
@@ -282,39 +363,93 @@ export class LocalDaemon {
282
363
  }
283
364
  }
284
365
 
285
- async gitDiff(args) {
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);
372
+ }
373
+
374
+ async gitDiff(args = {}) {
286
375
  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 };
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 };
382
+ }
383
+
384
+ async gitContext(inputPath) {
385
+ const target = await this.resolveExistingPath(inputPath);
386
+ const info = await stat(target);
387
+ 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);
389
+ if (result.code !== 0) return { ok: false, result, target };
390
+ const root = result.stdout.trim();
391
+ const relative = path.relative(root, target);
392
+ if (relative.startsWith(`..${sep}`) || relative === ".." || isAbsolute(relative)) {
393
+ return { ok: false, target, result: { code: 128, stdout: "", stderr: "target is outside the detected git repository" } };
394
+ }
395
+ return { ok: true, target, root, pathspec: relative || "" };
290
396
  }
291
397
 
292
398
  async execCommand(command, timeoutSeconds) {
293
399
  if (!this.policy.allowExec) throw new Error("exec_command is disabled by daemon policy");
294
400
  if (!command || typeof command !== "string") throw new Error("command is required");
401
+ if (command.includes("\0")) throw new Error("command contains a NUL byte");
402
+ if (Buffer.byteLength(command) > MAX_COMMAND_BYTES) throw new Error(`command exceeds maximum size (${MAX_COMMAND_BYTES} bytes)`);
295
403
  const shell = workspaceShellCommand(command);
296
404
  return this.runProcess(shell.cmd, shell.args, clampInt(timeoutSeconds, 120, 1, 600) * 1000);
297
405
  }
298
406
 
407
+ terminateActiveProcesses(signal = "SIGTERM", escalate = false) {
408
+ const children = [...this.activeProcesses];
409
+ for (const child of children) terminateProcessTree(child, signal);
410
+ if (escalate && signal !== "SIGKILL" && children.length) {
411
+ const timer = setTimeout(() => {
412
+ for (const child of children) {
413
+ if (this.activeProcesses.has(child)) terminateProcessTree(child, "SIGKILL");
414
+ }
415
+ }, 2000);
416
+ timer.unref?.();
417
+ }
418
+ }
419
+
299
420
  async runProcess(cmd, args, timeoutMs, allowFailure = false, maxOutputBytes = 512 * 1024) {
300
421
  return new Promise((resolvePromise, reject) => {
301
- const child = spawn(cmd, args, { cwd: this.workspace, env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false }) });
422
+ const child = spawn(cmd, args, {
423
+ cwd: this.workspace,
424
+ env: executionEnv(this.workspace, { fullEnv: this.policy.minimalEnv === false }),
425
+ detached: process.platform !== "win32",
426
+ windowsHide: true,
427
+ });
428
+ this.activeProcesses.add(child);
302
429
  let stdout = "";
303
430
  let stderr = "";
304
431
  let stdoutTruncated = 0;
305
432
  let stderrTruncated = 0;
306
433
  let timedOut = false;
434
+ let settled = false;
307
435
  let killTimer = null;
308
436
  const timer = setTimeout(() => {
309
437
  timedOut = true;
310
- child.kill("SIGTERM");
311
- killTimer = setTimeout(() => child.kill("SIGKILL"), 2000);
438
+ terminateProcessTree(child, "SIGTERM");
439
+ killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
312
440
  killTimer.unref?.();
313
441
  }, timeoutMs);
314
442
  timer.unref?.();
315
- const clearTimers = () => {
443
+ const cleanup = () => {
316
444
  clearTimeout(timer);
317
- if (killTimer) clearTimeout(killTimer);
445
+ if (killTimer && !timedOut) clearTimeout(killTimer);
446
+ this.activeProcesses.delete(child);
447
+ };
448
+ const finish = callback => {
449
+ if (settled) return;
450
+ settled = true;
451
+ cleanup();
452
+ callback();
318
453
  };
319
454
  child.stdout.on("data", chunk => {
320
455
  const next = appendLimited(stdout, chunk, maxOutputBytes);
@@ -326,13 +461,11 @@ export class LocalDaemon {
326
461
  stderr = next.value;
327
462
  stderrTruncated += next.truncated;
328
463
  });
329
- child.on("error", error => {
330
- clearTimers();
331
- if (allowFailure) resolvePromise({ code: 127, stdout, stderr: error.message });
464
+ child.on("error", error => finish(() => {
465
+ if (allowFailure) resolvePromise({ code: 127, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: boundedErrorMessage(error) });
332
466
  else reject(error);
333
- });
334
- child.on("close", code => {
335
- clearTimers();
467
+ }));
468
+ child.on("close", code => finish(() => {
336
469
  const result = { code, stdout: finalizeOutput(stdout, stdoutTruncated), stderr: finalizeOutput(stderr, stderrTruncated) };
337
470
  if (timedOut) {
338
471
  reject(new Error(`command timed out after ${timeoutMs}ms`));
@@ -340,33 +473,110 @@ export class LocalDaemon {
340
473
  }
341
474
  if (code === 0 || allowFailure) resolvePromise(result);
342
475
  else reject(new Error(stderr.trim() || stdout.trim() || `${cmd} exited ${code}`));
343
- });
476
+ }));
344
477
  });
345
478
  }
346
479
 
347
480
  async walk(root, onFile) {
348
481
  const stack = [root];
482
+ let visitedEntries = 0;
349
483
  while (stack.length) {
350
484
  const current = stack.pop();
351
485
  const entries = await opendir(current).catch(() => null);
352
486
  if (!entries) continue;
353
487
  for await (const entry of entries) {
488
+ visitedEntries += 1;
489
+ if (visitedEntries > MAX_WALK_ENTRIES) return { truncated: true, visitedEntries };
354
490
  const full = resolve(current, entry.name);
355
491
  if (entry.isDirectory()) stack.push(full);
356
492
  else if (entry.isFile()) {
357
493
  const keepGoing = await onFile(full);
358
- if (keepGoing === false) return;
494
+ if (keepGoing === false) return { truncated: true, visitedEntries };
359
495
  }
360
496
  }
361
497
  }
498
+ return { truncated: false, visitedEntries };
362
499
  }
363
500
 
364
501
  resolvePath(inputPath = ".") {
365
502
  const raw = String(inputPath || ".");
366
- return isAbsolute(raw) ? resolve(raw) : resolve(this.workspace, raw);
503
+ 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;
506
+ }
507
+
508
+ async resolveExistingPath(inputPath = ".") {
509
+ const candidate = this.resolvePath(inputPath);
510
+ const canonical = await realpath(candidate);
511
+ if (!this.policy.unrestrictedPaths) assertContainedPath(this.workspace, canonical);
512
+ return canonical;
513
+ }
514
+
515
+ async resolveWritePath(inputPath = ".") {
516
+ const candidate = this.resolvePath(inputPath);
517
+ if (this.policy.unrestrictedPaths) return candidate;
518
+ let ancestor = candidate;
519
+ while (!(await lstat(ancestor).catch(() => null))) {
520
+ const parent = dirname(ancestor);
521
+ if (parent === ancestor) break;
522
+ ancestor = parent;
523
+ }
524
+ const canonicalAncestor = await realpath(ancestor);
525
+ assertContainedPath(this.workspace, canonicalAncestor);
526
+ return candidate;
367
527
  }
368
528
  }
369
529
 
530
+ function normalizeWorkerUrl(value) {
531
+ let url;
532
+ try { url = new URL(String(value || "")); } catch { throw new Error("invalid Worker URL"); }
533
+ if (url.protocol !== "https:") throw new Error("Worker URL must use HTTPS");
534
+ if (url.username || url.password) throw new Error("Worker URL must not contain credentials");
535
+ if (url.pathname !== "/" || url.search || url.hash) throw new Error("Worker URL must be an origin without a path, query, or fragment");
536
+ return url.origin;
537
+ }
538
+
539
+ function reconnectDelay(attempt) {
540
+ const base = Math.min(3000 * (2 ** Math.min(attempt, 4)), 60_000);
541
+ return base + Math.floor(Math.random() * 1000);
542
+ }
543
+
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
+
560
+ function assertContainedPath(root, target) {
561
+ const relative = path.relative(root, target);
562
+ if (relative === "" || (!relative.startsWith(`..${sep}`) && relative !== ".." && !isAbsolute(relative))) return;
563
+ throw new Error("path is outside the configured workspace; restart with --unrestricted-paths to allow it");
564
+ }
565
+
566
+ async function readUtf8File(filePath) {
567
+ const buffer = await readFile(filePath);
568
+ try {
569
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer);
570
+ } catch {
571
+ throw new Error("file is not valid UTF-8 text");
572
+ }
573
+ }
574
+
575
+ function boundedErrorMessage(error) {
576
+ const message = error instanceof Error ? error.message : String(error);
577
+ return message.replace(/[\r\n]+/g, " ").slice(0, 4096) || "tool call failed";
578
+ }
579
+
370
580
  export function sha256(value) {
371
581
  return createHash("sha256").update(String(value)).digest("hex");
372
582
  }
@@ -393,12 +603,20 @@ function clampInt(value, fallback, min, max) {
393
603
  export async function daemonSelfTest() {
394
604
  const workspace = await mkdtemp(join(tmpdir(), "mbm-daemon-workspace-"));
395
605
  const outside = await mkdtemp(join(tmpdir(), "mbm-daemon-outside-"));
396
- const daemon = new LocalDaemon({
606
+ const logger = { info() {}, warn() {}, error() {} };
607
+ const restricted = new LocalDaemon({
397
608
  workerUrl: "https://example.invalid",
398
- secret: "test",
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",
399
617
  workspace,
400
618
  policy: { allowWrite: true, allowExec: true, unrestrictedPaths: true },
401
- logger: { info() {}, warn() {}, error() {} },
619
+ logger,
402
620
  });
403
621
  const previousSecret = process.env.MBM_DAEMON_SELFTEST_SECRET;
404
622
  process.env.MBM_DAEMON_SELFTEST_SECRET = "should-not-leak";
@@ -407,30 +625,80 @@ export async function daemonSelfTest() {
407
625
  await writeFile(join(workspace, "visible.txt"), "needle", "utf8");
408
626
  await writeFile(join(outside, "outside.txt"), "outside-needle", "utf8");
409
627
 
410
- const envFile = await daemon.readFile(".env", 1024);
411
- if (!envFile.content.includes("SECRET=visible")) throw new Error(".env should be readable by default");
628
+ const envFile = await restricted.readFile(".env", 1024);
629
+ if (!envFile.content.includes("SECRET=visible")) throw new Error("workspace .env should remain readable");
412
630
 
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");
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");
415
633
 
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");
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");
418
636
 
419
- const writtenPath = join(outside, "written.txt");
420
- await daemon.writeFile({ path: writtenPath, content: "written" });
421
- const written = await readFile(writtenPath, "utf8");
422
- if (written !== "written") throw new Error("absolute outside path should be writable");
423
-
424
- const cappedSearch = await daemon.searchText({ path: workspace, query: "definitely-not-present", max_files: 1, max_matches: 10 });
425
- if (cappedSearch.visited_files !== 1 || cappedSearch.truncated !== true) {
426
- throw new Error("search_text max_files cap did not apply");
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;
427
644
  }
428
645
 
429
- const command = await daemon.execCommand("printf ${MBM_DAEMON_SELFTEST_SECRET-unset}", 5);
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);
430
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
+ }
431
697
 
432
- const roots = daemon.listRoots();
433
- if (!roots.roots.some(root => root.path === workspace)) throw new Error("workspace root missing");
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");
434
702
  } finally {
435
703
  if (previousSecret === undefined) delete process.env.MBM_DAEMON_SELFTEST_SECRET;
436
704
  else process.env.MBM_DAEMON_SELFTEST_SECRET = previousSecret;
@@ -439,3 +707,27 @@ export async function daemonSelfTest() {
439
707
  }
440
708
  return true;
441
709
  }
710
+
711
+ function shellQuote(value) {
712
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
713
+ }
714
+
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
+ }
723
+ }
724
+
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}`);
733
+ }