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