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