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