machine-bridge-mcp 0.4.2 → 0.6.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 +51 -0
- package/README.md +110 -14
- package/SECURITY.md +38 -6
- package/docs/ARCHITECTURE.md +50 -15
- package/docs/CLIENTS.md +18 -1
- package/docs/LOGGING.md +82 -0
- package/docs/MANAGED_JOBS.md +274 -0
- package/docs/OPERATIONS.md +80 -23
- package/docs/TESTING.md +13 -5
- package/package.json +8 -7
- package/src/local/cli.mjs +345 -80
- package/src/local/daemon.mjs +159 -42
- package/src/local/job-runner.mjs +470 -0
- package/src/local/log.mjs +56 -15
- package/src/local/managed-jobs.mjs +854 -0
- package/src/local/service.mjs +40 -21
- package/src/local/state.mjs +94 -26
- package/src/local/stdio.mjs +14 -38
- package/src/local/tools.mjs +28 -13
- package/src/shared/server-metadata.json +24 -0
- package/src/shared/tool-catalog.json +500 -2
- package/src/worker/index.ts +13 -11
package/src/local/daemon.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
3
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
4
|
-
import { chmod, link, lstat, mkdir,
|
|
4
|
+
import { chmod, link, lstat, mkdir, open, opendir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import path, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
7
|
import WebSocket from "ws";
|
|
@@ -9,7 +9,9 @@ import { applyUpdateHunks, parsePatchEnvelope } from "./patch.mjs";
|
|
|
9
9
|
import { executionEnv, workspaceShellCommand } from "./shell.mjs";
|
|
10
10
|
import { MAX_COMMAND_BYTES, ProcessSessionManager, terminateProcessTree, validateArgv } from "./process-sessions.mjs";
|
|
11
11
|
export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
|
|
12
|
-
import { MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, toolNamesForPolicy } from "./tools.mjs";
|
|
12
|
+
import { MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, SERVER_NAME, toolNamesForPolicy } from "./tools.mjs";
|
|
13
|
+
import { classifyOperationalError } from "./log.mjs";
|
|
14
|
+
import { ManagedJobManager } from "./managed-jobs.mjs";
|
|
13
15
|
|
|
14
16
|
export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
|
|
15
17
|
const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
@@ -18,9 +20,10 @@ const MAX_DIRECTORY_ENTRIES = 10_000;
|
|
|
18
20
|
const MAX_PATH_RESULT_BYTES = 4 * 1024 * 1024;
|
|
19
21
|
const MAX_WALK_ENTRIES = 200_000;
|
|
20
22
|
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
23
|
+
const SLOW_TOOL_CALL_MS = 30_000;
|
|
21
24
|
|
|
22
25
|
export class LocalDaemon {
|
|
23
|
-
constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null }) {
|
|
26
|
+
constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null, jobRoot = "", resources = {}, resourceStatePath = "", recoverJobs = true }) {
|
|
24
27
|
this.workerUrl = workerUrl ? normalizeWorkerUrl(workerUrl) : "";
|
|
25
28
|
if (this.workerUrl && (typeof secret !== "string" || secret.length < 16)) throw new Error("daemon secret is missing or too short");
|
|
26
29
|
this.secret = secret || "";
|
|
@@ -44,6 +47,16 @@ export class LocalDaemon {
|
|
|
44
47
|
this.reconnectAttempt = 0;
|
|
45
48
|
this.mutationQueue = Promise.resolve();
|
|
46
49
|
this.runtimeDir = createRuntimeDir();
|
|
50
|
+
if (typeof jobRoot !== "string" || !jobRoot.trim()) throw new Error("persistent managed-job root is required");
|
|
51
|
+
this.managedJobManager = new ManagedJobManager({
|
|
52
|
+
jobRoot,
|
|
53
|
+
workspace: this.workspace,
|
|
54
|
+
policy: this.policy,
|
|
55
|
+
resources,
|
|
56
|
+
resourceStatePath,
|
|
57
|
+
logger: this.logger,
|
|
58
|
+
recover: recoverJobs,
|
|
59
|
+
});
|
|
47
60
|
this.processSessionManager = new ProcessSessionManager({
|
|
48
61
|
workspace: this.workspace,
|
|
49
62
|
policy: this.policy,
|
|
@@ -66,17 +79,25 @@ export class LocalDaemon {
|
|
|
66
79
|
|
|
67
80
|
runtimeInfo() {
|
|
68
81
|
return {
|
|
69
|
-
name:
|
|
82
|
+
name: SERVER_NAME,
|
|
70
83
|
protocol_version: MCP_PROTOCOL_VERSION,
|
|
71
84
|
supported_protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
72
85
|
workspace: this.displayPath(this.workspace),
|
|
73
86
|
workspace_name: basename(this.workspace),
|
|
74
87
|
policy: this.policy,
|
|
88
|
+
enforcement: {
|
|
89
|
+
filesystem_scope: this.policy.unrestrictedPaths ? "local-user-accessible" : "workspace",
|
|
90
|
+
sensitive_filename_filter: false,
|
|
91
|
+
operating_system_permissions_apply: true,
|
|
92
|
+
host_policy_is_independent: true,
|
|
93
|
+
},
|
|
75
94
|
tools: ["server_info", ...this.tools()],
|
|
76
95
|
runtime: {
|
|
77
96
|
environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
|
|
78
97
|
runtime_dir: this.policy.exposeAbsolutePaths ? this.runtimeDir : "<private-runtime-dir>",
|
|
79
98
|
process_sessions: this.processSessionManager.status(),
|
|
99
|
+
managed_jobs: this.managedJobManager.status(),
|
|
100
|
+
local_resources: this.managedJobManager.resourceInfo(),
|
|
80
101
|
},
|
|
81
102
|
};
|
|
82
103
|
}
|
|
@@ -109,7 +130,7 @@ export class LocalDaemon {
|
|
|
109
130
|
connect() {
|
|
110
131
|
if (this.closed) return;
|
|
111
132
|
const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
|
|
112
|
-
this.logger.
|
|
133
|
+
this.logger.debug?.("connecting to remote relay", { endpoint: redactUrl(wsUrl) });
|
|
113
134
|
const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret } });
|
|
114
135
|
this.ws = socket;
|
|
115
136
|
|
|
@@ -119,7 +140,7 @@ export class LocalDaemon {
|
|
|
119
140
|
return;
|
|
120
141
|
}
|
|
121
142
|
this.reconnectAttempt = 0;
|
|
122
|
-
this.logger.info?.("
|
|
143
|
+
this.logger.info?.("remote relay connected");
|
|
123
144
|
this.send({ type: "hello", tools: this.tools(), policy: this.policy, protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS });
|
|
124
145
|
if (this.connectedOnceResolve) {
|
|
125
146
|
this.connectedOnceResolve(true);
|
|
@@ -138,7 +159,7 @@ export class LocalDaemon {
|
|
|
138
159
|
return;
|
|
139
160
|
}
|
|
140
161
|
void this.handleMessage(raw).catch(error => {
|
|
141
|
-
this.logger.error?.("daemon message handler failed", {
|
|
162
|
+
this.logger.error?.("daemon message handler failed", { error_class: classifyOperationalError(error) });
|
|
142
163
|
});
|
|
143
164
|
});
|
|
144
165
|
|
|
@@ -157,13 +178,13 @@ export class LocalDaemon {
|
|
|
157
178
|
this.logger.warn?.("daemon connection permanently superseded", fields);
|
|
158
179
|
queueMicrotask(() => {
|
|
159
180
|
try { this.onSuperseded?.(); } catch (error) {
|
|
160
|
-
this.logger.error?.("daemon superseded callback failed", { error_class:
|
|
181
|
+
this.logger.error?.("daemon superseded callback failed", { error_class: classifyOperationalError(error) });
|
|
161
182
|
}
|
|
162
183
|
});
|
|
163
184
|
return;
|
|
164
185
|
}
|
|
165
|
-
if (this.closed) this.logger.
|
|
166
|
-
else this.logger.warn?.("
|
|
186
|
+
if (this.closed) this.logger.debug?.("remote relay closed", fields);
|
|
187
|
+
else this.logger.warn?.("remote relay disconnected", fields);
|
|
167
188
|
if (!this.closed) {
|
|
168
189
|
const delay = reconnectDelay(this.reconnectAttempt++);
|
|
169
190
|
this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay });
|
|
@@ -174,8 +195,8 @@ export class LocalDaemon {
|
|
|
174
195
|
|
|
175
196
|
socket.on("error", error => {
|
|
176
197
|
if (this.ws !== socket) return;
|
|
177
|
-
if (this.closed) this.logger.
|
|
178
|
-
else this.logger.error?.("
|
|
198
|
+
if (this.closed) this.logger.debug?.("remote relay closed during shutdown", { error_class: classifyOperationalError(error) });
|
|
199
|
+
else this.logger.error?.("remote relay error", { error_class: classifyOperationalError(error) });
|
|
179
200
|
});
|
|
180
201
|
}
|
|
181
202
|
|
|
@@ -185,7 +206,7 @@ export class LocalDaemon {
|
|
|
185
206
|
this.ws.send(JSON.stringify(value));
|
|
186
207
|
return true;
|
|
187
208
|
} catch (error) {
|
|
188
|
-
this.logger.warn?.("
|
|
209
|
+
this.logger.warn?.("remote relay send failed", { error_class: classifyOperationalError(error) });
|
|
189
210
|
return false;
|
|
190
211
|
}
|
|
191
212
|
}
|
|
@@ -229,11 +250,15 @@ export class LocalDaemon {
|
|
|
229
250
|
const result = await this.executeTool(tool, argumentsValue, { callId: id });
|
|
230
251
|
if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
|
|
231
252
|
this.send({ type: "tool_result", id, ok: true, result });
|
|
232
|
-
|
|
253
|
+
const durationMs = Date.now() - started;
|
|
254
|
+
if (durationMs >= SLOW_TOOL_CALL_MS) this.logger.info?.("slow tool call completed", { tool, duration_ms: durationMs });
|
|
255
|
+
else this.logger.debug?.("tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
|
|
233
256
|
} catch (error) {
|
|
234
257
|
const safeError = this.safeErrorMessage(error);
|
|
235
258
|
this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
|
|
236
|
-
|
|
259
|
+
const durationMs = Date.now() - started;
|
|
260
|
+
this.logger.warn?.("tool call failed", { tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
261
|
+
this.logger.debug?.("tool call failure correlation", { call_id: shortCallId(id) });
|
|
237
262
|
} finally {
|
|
238
263
|
clearTimeout(deadline);
|
|
239
264
|
this.activeToolCalls -= 1;
|
|
@@ -258,7 +283,7 @@ export class LocalDaemon {
|
|
|
258
283
|
}, 2000);
|
|
259
284
|
timer.unref?.();
|
|
260
285
|
}
|
|
261
|
-
this.logger.
|
|
286
|
+
this.logger.debug?.("tool call cancellation requested", { call_id: shortCallId(callId), reason });
|
|
262
287
|
}
|
|
263
288
|
|
|
264
289
|
async executeTool(tool, args, context = {}) {
|
|
@@ -279,6 +304,13 @@ export class LocalDaemon {
|
|
|
279
304
|
case "git_diff": return this.gitDiff(args, context);
|
|
280
305
|
case "git_log": return this.gitLog(args, context);
|
|
281
306
|
case "git_show": return this.gitShow(args, context);
|
|
307
|
+
case "diagnose_runtime": return this.diagnoseRuntime(context);
|
|
308
|
+
case "list_local_resources": return this.managedJobManager.listResources();
|
|
309
|
+
case "stage_job": return this.managedJobManager.stage(args);
|
|
310
|
+
case "start_job": return this.managedJobManager.start(args);
|
|
311
|
+
case "list_jobs": return this.managedJobManager.list(args);
|
|
312
|
+
case "read_job": return this.managedJobManager.read(args);
|
|
313
|
+
case "cancel_job": return this.managedJobManager.cancel(args);
|
|
282
314
|
case "run_process": return this.runDirectProcess(args, context);
|
|
283
315
|
case "start_process": return this.processSessionManager.start(args, context);
|
|
284
316
|
case "read_process": return this.processSessionManager.read(args, context);
|
|
@@ -366,11 +398,9 @@ export class LocalDaemon {
|
|
|
366
398
|
}
|
|
367
399
|
if (!args.path) throw new Error("path is required");
|
|
368
400
|
const full = await this.resolveExistingPath(args.path);
|
|
369
|
-
const info = await stat(full);
|
|
370
|
-
if (!info.isFile()) throw new Error("path is not a file");
|
|
371
|
-
if (info.size > MAX_WRITE_BYTES) throw new Error(`file exceeds maximum readable text size (${info.size} > ${MAX_WRITE_BYTES})`);
|
|
372
401
|
this.throwIfCancelled(context);
|
|
373
|
-
const
|
|
402
|
+
const { buffer, info } = await readBoundedFile(full, MAX_WRITE_BYTES, "readable text file");
|
|
403
|
+
const content = decodeUtf8(buffer);
|
|
374
404
|
this.throwIfCancelled(context);
|
|
375
405
|
const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
|
|
376
406
|
const startLine = args.start_line === undefined ? 1 : clampInt(args.start_line, 1, 1, Number.MAX_SAFE_INTEGER);
|
|
@@ -399,11 +429,8 @@ export class LocalDaemon {
|
|
|
399
429
|
async viewImage(args, context = {}) {
|
|
400
430
|
if (!args.path) throw new Error("path is required");
|
|
401
431
|
const full = await this.resolveExistingPath(args.path);
|
|
402
|
-
const info = await stat(full);
|
|
403
|
-
if (!info.isFile()) throw new Error("path is not a file");
|
|
404
|
-
if (info.size > MAX_IMAGE_BYTES) throw new Error(`image exceeds maximum size (${info.size} > ${MAX_IMAGE_BYTES})`);
|
|
405
432
|
this.throwIfCancelled(context);
|
|
406
|
-
const buffer = await
|
|
433
|
+
const { buffer, info } = await readBoundedFile(full, MAX_IMAGE_BYTES, "image");
|
|
407
434
|
this.throwIfCancelled(context);
|
|
408
435
|
const mimeType = detectImageMime(buffer);
|
|
409
436
|
if (!mimeType) throw new Error("unsupported image format; expected PNG, JPEG, GIF, or WebP");
|
|
@@ -545,10 +572,9 @@ export class LocalDaemon {
|
|
|
545
572
|
|
|
546
573
|
async searchOneFile(full, query, matches, max, context = {}) {
|
|
547
574
|
this.throwIfCancelled(context);
|
|
548
|
-
const
|
|
549
|
-
if (!
|
|
550
|
-
const buffer =
|
|
551
|
-
if (!buffer || buffer.includes(0)) return;
|
|
575
|
+
const bounded = await readBoundedFile(full, 1024 * 1024, "search file").catch(() => null);
|
|
576
|
+
if (!bounded || bounded.buffer.includes(0)) return;
|
|
577
|
+
const buffer = bounded.buffer;
|
|
552
578
|
let text;
|
|
553
579
|
try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch { return; }
|
|
554
580
|
if (!text) return;
|
|
@@ -623,6 +649,86 @@ export class LocalDaemon {
|
|
|
623
649
|
return { ok: true, target, root, pathspec: repoRelative || "" };
|
|
624
650
|
}
|
|
625
651
|
|
|
652
|
+
async diagnoseRuntime(context = {}) {
|
|
653
|
+
this.throwIfCancelled(context);
|
|
654
|
+
const checks = [];
|
|
655
|
+
checks.push({
|
|
656
|
+
layer: "mcp-host-to-daemon",
|
|
657
|
+
ok: true,
|
|
658
|
+
detail: "This diagnostic request reached the local Machine Bridge runtime.",
|
|
659
|
+
});
|
|
660
|
+
checks.push({
|
|
661
|
+
layer: "machine-bridge-policy",
|
|
662
|
+
ok: this.policy.execMode === "direct" || this.policy.execMode === "shell",
|
|
663
|
+
detail: `profile=${this.policy.profile}; exec_mode=${this.policy.execMode}; unrestricted_paths=${this.policy.unrestrictedPaths}`,
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
const probe = join(this.runtimeDir, `.diagnostic-${process.pid}-${randomBytes(6).toString("hex")}`);
|
|
667
|
+
try {
|
|
668
|
+
await writeFile(probe, "ok\n", { mode: 0o600, flag: "wx" });
|
|
669
|
+
const { buffer } = await readBoundedFile(probe, 64, "diagnostic file");
|
|
670
|
+
checks.push({ layer: "local-filesystem", ok: buffer.toString("utf8") === "ok\n", error_class: null });
|
|
671
|
+
} catch (error) {
|
|
672
|
+
checks.push({ layer: "local-filesystem", ok: false, error_class: classifyOperationalError(error) });
|
|
673
|
+
} finally {
|
|
674
|
+
await rm(probe, { force: true }).catch(() => {});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (this.policy.execMode === "direct" || this.policy.execMode === "shell") {
|
|
678
|
+
const direct = await this.runProcess(
|
|
679
|
+
process.execPath,
|
|
680
|
+
["-e", "process.stdout.write('ok')"],
|
|
681
|
+
5000,
|
|
682
|
+
true,
|
|
683
|
+
1024,
|
|
684
|
+
context,
|
|
685
|
+
this.workspace,
|
|
686
|
+
).catch((error) => ({ code: 127, stdout: "", stderr: "", error_class: classifyOperationalError(error) }));
|
|
687
|
+
checks.push({
|
|
688
|
+
layer: "local-process-spawn",
|
|
689
|
+
ok: direct.code === 0 && direct.stdout === "ok",
|
|
690
|
+
error_class: direct.error_class || (direct.code === 0 ? null : classifyOperationalError(direct.stderr || direct.stdout || "execution failed")),
|
|
691
|
+
});
|
|
692
|
+
} else {
|
|
693
|
+
checks.push({ layer: "local-process-spawn", ok: false, skipped: true, error_class: "policy_denied" });
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (this.policy.execMode === "shell") {
|
|
697
|
+
const shell = workspaceShellCommand(process.platform === "win32" ? "cd" : "pwd");
|
|
698
|
+
const result = await this.runProcess(shell.cmd, shell.args, 5000, true, 4096, context, this.workspace)
|
|
699
|
+
.catch((error) => ({ code: 127, error_class: classifyOperationalError(error) }));
|
|
700
|
+
checks.push({
|
|
701
|
+
layer: "local-shell",
|
|
702
|
+
ok: result.code === 0,
|
|
703
|
+
error_class: result.error_class || (result.code === 0 ? null : classifyOperationalError(result.stderr || result.stdout || "execution failed")),
|
|
704
|
+
});
|
|
705
|
+
} else {
|
|
706
|
+
checks.push({ layer: "local-shell", ok: false, skipped: true, error_class: "policy_denied" });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const storage = this.managedJobManager.diagnoseStorage();
|
|
710
|
+
checks.push({ layer: "managed-job-storage", ...storage });
|
|
711
|
+
const resources = this.managedJobManager.listResources();
|
|
712
|
+
checks.push({
|
|
713
|
+
layer: "local-resource-registry",
|
|
714
|
+
ok: resources.resources.every((resource) => resource.available),
|
|
715
|
+
registered: resources.count,
|
|
716
|
+
unavailable: resources.resources.filter((resource) => !resource.available).map((resource) => ({ name: resource.name, error_class: resource.error_class })),
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
request_reached_local_runtime: true,
|
|
721
|
+
interpretation: {
|
|
722
|
+
tool_call_blocked_before_response: "host/platform or connector gateway",
|
|
723
|
+
diagnostic_reached_daemon_but_spawn_failed: "local OS, endpoint security, shell configuration, or Machine Bridge policy",
|
|
724
|
+
managed_job_accepted_then_later_tools_blocked: "job continues independently; inspect with local CLI or a later read_job call",
|
|
725
|
+
},
|
|
726
|
+
policy: this.policy,
|
|
727
|
+
checks,
|
|
728
|
+
ok: checks.filter((check) => !check.skipped).every((check) => check.ok),
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
626
732
|
async runDirectProcess(args, context = {}) {
|
|
627
733
|
if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error("run_process is disabled by daemon policy");
|
|
628
734
|
const argv = validateArgv(args.argv);
|
|
@@ -850,13 +956,36 @@ function assertContainedPath(root, target) {
|
|
|
850
956
|
throw new Error("path is outside the configured workspace; restart with --unrestricted-paths to allow it");
|
|
851
957
|
}
|
|
852
958
|
|
|
853
|
-
async function
|
|
854
|
-
const
|
|
959
|
+
async function readBoundedFile(filePath, maxBytes, label) {
|
|
960
|
+
const handle = await open(filePath, "r");
|
|
961
|
+
try {
|
|
962
|
+
const info = await handle.stat();
|
|
963
|
+
if (!info.isFile()) throw new Error(`${label} is not a regular file`);
|
|
964
|
+
if (info.size > maxBytes) throw new Error(`${label} exceeds maximum size (${info.size} > ${maxBytes})`);
|
|
965
|
+
const buffer = Buffer.alloc(info.size);
|
|
966
|
+
let offset = 0;
|
|
967
|
+
while (offset < buffer.length) {
|
|
968
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
969
|
+
if (bytesRead === 0) break;
|
|
970
|
+
offset += bytesRead;
|
|
971
|
+
}
|
|
972
|
+
return { buffer: buffer.subarray(0, offset), info };
|
|
973
|
+
} finally {
|
|
974
|
+
await handle.close();
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function decodeUtf8(buffer) {
|
|
855
979
|
try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
|
|
856
980
|
throw new Error("file is not valid UTF-8 text");
|
|
857
981
|
}
|
|
858
982
|
}
|
|
859
983
|
|
|
984
|
+
async function readUtf8File(filePath) {
|
|
985
|
+
const { buffer } = await readBoundedFile(filePath, MAX_WRITE_BYTES, "text file");
|
|
986
|
+
return decodeUtf8(buffer);
|
|
987
|
+
}
|
|
988
|
+
|
|
860
989
|
async function atomicWriteText(full, content, existing = null, options = {}) {
|
|
861
990
|
await mkdir(dirname(full), { recursive: true });
|
|
862
991
|
const temp = join(dirname(full), `.${basename(full)}.mbm-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
@@ -1010,18 +1139,6 @@ function isPlainRecord(value) {
|
|
|
1010
1139
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1011
1140
|
}
|
|
1012
1141
|
|
|
1013
|
-
function classifyError(error) {
|
|
1014
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1015
|
-
if (/cancel/i.test(message)) return "cancelled";
|
|
1016
|
-
if (/timed out/i.test(message)) return "timeout";
|
|
1017
|
-
if (/outside the configured workspace/i.test(message)) return "path_boundary";
|
|
1018
|
-
if (/disabled by daemon policy|requires .* mode/i.test(message)) return "policy_denied";
|
|
1019
|
-
if (/not found|ENOENT/i.test(message)) return "not_found";
|
|
1020
|
-
if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
|
|
1021
|
-
if (/maximum|exceeds|max_bytes|too many/i.test(message)) return "limit_exceeded";
|
|
1022
|
-
if (/invalid|must|requires|ambiguous|mismatch/i.test(message)) return "invalid_request";
|
|
1023
|
-
return "execution_failed";
|
|
1024
|
-
}
|
|
1025
1142
|
|
|
1026
1143
|
function equivalentPathPrefixes(...values) {
|
|
1027
1144
|
const prefixes = new Set(values.filter(Boolean).map((value) => String(value)));
|