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.
- package/CHANGELOG.md +60 -0
- package/README.md +148 -176
- package/SECURITY.md +84 -0
- package/docs/ARCHITECTURE.md +102 -0
- package/package.json +20 -9
- package/src/local/cli.mjs +289 -298
- package/src/local/daemon.mjs +376 -84
- package/src/local/log.mjs +33 -11
- package/src/local/self-test.mjs +173 -186
- package/src/local/service.mjs +43 -5
- package/src/local/shell.mjs +68 -14
- package/src/local/state.mjs +240 -51
- package/src/worker/index.ts +664 -424
- package/wrangler.jsonc +2 -2
- package/src/local/api-server.mjs +0 -368
package/src/local/daemon.mjs
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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.
|
|
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
|
|
198
|
-
|
|
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.
|
|
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
|
-
|
|
216
|
-
|
|
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.
|
|
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
|
|
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
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
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, {
|
|
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
|
|
311
|
-
killTimer = setTimeout(() => child
|
|
438
|
+
terminateProcessTree(child, "SIGTERM");
|
|
439
|
+
killTimer = setTimeout(() => terminateProcessTree(child, "SIGKILL"), 2000);
|
|
312
440
|
killTimer.unref?.();
|
|
313
441
|
}, timeoutMs);
|
|
314
442
|
timer.unref?.();
|
|
315
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
411
|
-
if (!envFile.content.includes("SECRET=visible")) throw new Error(".env should
|
|
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
|
-
|
|
414
|
-
|
|
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
|
|
417
|
-
if (!
|
|
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
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
|
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
|
|
433
|
-
if (
|
|
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
|
+
}
|