machine-bridge-mcp 0.4.0 → 0.5.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 +48 -39
- package/SECURITY.md +14 -10
- package/docs/ARCHITECTURE.md +14 -12
- package/docs/CLIENTS.md +82 -23
- package/docs/LOGGING.md +80 -0
- package/docs/OPERATIONS.md +25 -17
- package/docs/TESTING.md +7 -4
- package/package.json +4 -4
- package/src/local/cli.mjs +157 -79
- package/src/local/daemon.mjs +83 -48
- package/src/local/log.mjs +56 -15
- package/src/local/service.mjs +40 -21
- package/src/local/state.mjs +45 -20
- package/src/local/stdio.mjs +13 -37
- package/src/local/tools.mjs +28 -13
- package/src/shared/server-metadata.json +17 -0
- package/src/shared/tool-catalog.json +2 -2
- package/src/worker/index.ts +13 -11
- package/docs/examples/github-actions-ci.yml +0 -53
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,8 @@ 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";
|
|
13
14
|
|
|
14
15
|
export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
|
|
15
16
|
const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
@@ -18,6 +19,7 @@ const MAX_DIRECTORY_ENTRIES = 10_000;
|
|
|
18
19
|
const MAX_PATH_RESULT_BYTES = 4 * 1024 * 1024;
|
|
19
20
|
const MAX_WALK_ENTRIES = 200_000;
|
|
20
21
|
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
22
|
+
const SLOW_TOOL_CALL_MS = 30_000;
|
|
21
23
|
|
|
22
24
|
export class LocalDaemon {
|
|
23
25
|
constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null }) {
|
|
@@ -25,7 +27,8 @@ export class LocalDaemon {
|
|
|
25
27
|
if (this.workerUrl && (typeof secret !== "string" || secret.length < 16)) throw new Error("daemon secret is missing or too short");
|
|
26
28
|
this.secret = secret || "";
|
|
27
29
|
this.workspaceInput = resolve(workspace || process.cwd());
|
|
28
|
-
this.workspace = realpathSync(this.workspaceInput);
|
|
30
|
+
this.workspace = realpathSync.native ? realpathSync.native(this.workspaceInput) : realpathSync(this.workspaceInput);
|
|
31
|
+
this.workspaceCanonicalPromise = null;
|
|
29
32
|
this.policy = normalizePolicy(policy);
|
|
30
33
|
this.logger = logger;
|
|
31
34
|
this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
|
|
@@ -65,12 +68,18 @@ export class LocalDaemon {
|
|
|
65
68
|
|
|
66
69
|
runtimeInfo() {
|
|
67
70
|
return {
|
|
68
|
-
name:
|
|
71
|
+
name: SERVER_NAME,
|
|
69
72
|
protocol_version: MCP_PROTOCOL_VERSION,
|
|
70
73
|
supported_protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
71
74
|
workspace: this.displayPath(this.workspace),
|
|
72
75
|
workspace_name: basename(this.workspace),
|
|
73
76
|
policy: this.policy,
|
|
77
|
+
enforcement: {
|
|
78
|
+
filesystem_scope: this.policy.unrestrictedPaths ? "local-user-accessible" : "workspace",
|
|
79
|
+
sensitive_filename_filter: false,
|
|
80
|
+
operating_system_permissions_apply: true,
|
|
81
|
+
host_policy_is_independent: true,
|
|
82
|
+
},
|
|
74
83
|
tools: ["server_info", ...this.tools()],
|
|
75
84
|
runtime: {
|
|
76
85
|
environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
|
|
@@ -108,7 +117,7 @@ export class LocalDaemon {
|
|
|
108
117
|
connect() {
|
|
109
118
|
if (this.closed) return;
|
|
110
119
|
const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
|
|
111
|
-
this.logger.
|
|
120
|
+
this.logger.debug?.("connecting to remote relay", { endpoint: redactUrl(wsUrl) });
|
|
112
121
|
const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret } });
|
|
113
122
|
this.ws = socket;
|
|
114
123
|
|
|
@@ -118,7 +127,7 @@ export class LocalDaemon {
|
|
|
118
127
|
return;
|
|
119
128
|
}
|
|
120
129
|
this.reconnectAttempt = 0;
|
|
121
|
-
this.logger.info?.("
|
|
130
|
+
this.logger.info?.("remote relay connected");
|
|
122
131
|
this.send({ type: "hello", tools: this.tools(), policy: this.policy, protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS });
|
|
123
132
|
if (this.connectedOnceResolve) {
|
|
124
133
|
this.connectedOnceResolve(true);
|
|
@@ -137,7 +146,7 @@ export class LocalDaemon {
|
|
|
137
146
|
return;
|
|
138
147
|
}
|
|
139
148
|
void this.handleMessage(raw).catch(error => {
|
|
140
|
-
this.logger.error?.("daemon message handler failed", {
|
|
149
|
+
this.logger.error?.("daemon message handler failed", { error_class: classifyOperationalError(error) });
|
|
141
150
|
});
|
|
142
151
|
});
|
|
143
152
|
|
|
@@ -156,13 +165,13 @@ export class LocalDaemon {
|
|
|
156
165
|
this.logger.warn?.("daemon connection permanently superseded", fields);
|
|
157
166
|
queueMicrotask(() => {
|
|
158
167
|
try { this.onSuperseded?.(); } catch (error) {
|
|
159
|
-
this.logger.error?.("daemon superseded callback failed", { error_class:
|
|
168
|
+
this.logger.error?.("daemon superseded callback failed", { error_class: classifyOperationalError(error) });
|
|
160
169
|
}
|
|
161
170
|
});
|
|
162
171
|
return;
|
|
163
172
|
}
|
|
164
|
-
if (this.closed) this.logger.
|
|
165
|
-
else this.logger.warn?.("
|
|
173
|
+
if (this.closed) this.logger.debug?.("remote relay closed", fields);
|
|
174
|
+
else this.logger.warn?.("remote relay disconnected", fields);
|
|
166
175
|
if (!this.closed) {
|
|
167
176
|
const delay = reconnectDelay(this.reconnectAttempt++);
|
|
168
177
|
this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay });
|
|
@@ -173,8 +182,8 @@ export class LocalDaemon {
|
|
|
173
182
|
|
|
174
183
|
socket.on("error", error => {
|
|
175
184
|
if (this.ws !== socket) return;
|
|
176
|
-
if (this.closed) this.logger.
|
|
177
|
-
else this.logger.error?.("
|
|
185
|
+
if (this.closed) this.logger.debug?.("remote relay closed during shutdown", { error_class: classifyOperationalError(error) });
|
|
186
|
+
else this.logger.error?.("remote relay error", { error_class: classifyOperationalError(error) });
|
|
178
187
|
});
|
|
179
188
|
}
|
|
180
189
|
|
|
@@ -184,7 +193,7 @@ export class LocalDaemon {
|
|
|
184
193
|
this.ws.send(JSON.stringify(value));
|
|
185
194
|
return true;
|
|
186
195
|
} catch (error) {
|
|
187
|
-
this.logger.warn?.("
|
|
196
|
+
this.logger.warn?.("remote relay send failed", { error_class: classifyOperationalError(error) });
|
|
188
197
|
return false;
|
|
189
198
|
}
|
|
190
199
|
}
|
|
@@ -228,11 +237,15 @@ export class LocalDaemon {
|
|
|
228
237
|
const result = await this.executeTool(tool, argumentsValue, { callId: id });
|
|
229
238
|
if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
|
|
230
239
|
this.send({ type: "tool_result", id, ok: true, result });
|
|
231
|
-
|
|
240
|
+
const durationMs = Date.now() - started;
|
|
241
|
+
if (durationMs >= SLOW_TOOL_CALL_MS) this.logger.info?.("slow tool call completed", { tool, duration_ms: durationMs });
|
|
242
|
+
else this.logger.debug?.("tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
|
|
232
243
|
} catch (error) {
|
|
233
244
|
const safeError = this.safeErrorMessage(error);
|
|
234
245
|
this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
|
|
235
|
-
|
|
246
|
+
const durationMs = Date.now() - started;
|
|
247
|
+
this.logger.warn?.("tool call failed", { tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
248
|
+
this.logger.debug?.("tool call failure correlation", { call_id: shortCallId(id) });
|
|
236
249
|
} finally {
|
|
237
250
|
clearTimeout(deadline);
|
|
238
251
|
this.activeToolCalls -= 1;
|
|
@@ -257,7 +270,7 @@ export class LocalDaemon {
|
|
|
257
270
|
}, 2000);
|
|
258
271
|
timer.unref?.();
|
|
259
272
|
}
|
|
260
|
-
this.logger.
|
|
273
|
+
this.logger.debug?.("tool call cancellation requested", { call_id: shortCallId(callId), reason });
|
|
261
274
|
}
|
|
262
275
|
|
|
263
276
|
async executeTool(tool, args, context = {}) {
|
|
@@ -365,11 +378,9 @@ export class LocalDaemon {
|
|
|
365
378
|
}
|
|
366
379
|
if (!args.path) throw new Error("path is required");
|
|
367
380
|
const full = await this.resolveExistingPath(args.path);
|
|
368
|
-
const info = await stat(full);
|
|
369
|
-
if (!info.isFile()) throw new Error("path is not a file");
|
|
370
|
-
if (info.size > MAX_WRITE_BYTES) throw new Error(`file exceeds maximum readable text size (${info.size} > ${MAX_WRITE_BYTES})`);
|
|
371
381
|
this.throwIfCancelled(context);
|
|
372
|
-
const
|
|
382
|
+
const { buffer, info } = await readBoundedFile(full, MAX_WRITE_BYTES, "readable text file");
|
|
383
|
+
const content = decodeUtf8(buffer);
|
|
373
384
|
this.throwIfCancelled(context);
|
|
374
385
|
const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
|
|
375
386
|
const startLine = args.start_line === undefined ? 1 : clampInt(args.start_line, 1, 1, Number.MAX_SAFE_INTEGER);
|
|
@@ -398,11 +409,8 @@ export class LocalDaemon {
|
|
|
398
409
|
async viewImage(args, context = {}) {
|
|
399
410
|
if (!args.path) throw new Error("path is required");
|
|
400
411
|
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
412
|
this.throwIfCancelled(context);
|
|
405
|
-
const buffer = await
|
|
413
|
+
const { buffer, info } = await readBoundedFile(full, MAX_IMAGE_BYTES, "image");
|
|
406
414
|
this.throwIfCancelled(context);
|
|
407
415
|
const mimeType = detectImageMime(buffer);
|
|
408
416
|
if (!mimeType) throw new Error("unsupported image format; expected PNG, JPEG, GIF, or WebP");
|
|
@@ -544,10 +552,9 @@ export class LocalDaemon {
|
|
|
544
552
|
|
|
545
553
|
async searchOneFile(full, query, matches, max, context = {}) {
|
|
546
554
|
this.throwIfCancelled(context);
|
|
547
|
-
const
|
|
548
|
-
if (!
|
|
549
|
-
const buffer =
|
|
550
|
-
if (!buffer || buffer.includes(0)) return;
|
|
555
|
+
const bounded = await readBoundedFile(full, 1024 * 1024, "search file").catch(() => null);
|
|
556
|
+
if (!bounded || bounded.buffer.includes(0)) return;
|
|
557
|
+
const buffer = bounded.buffer;
|
|
551
558
|
let text;
|
|
552
559
|
try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch { return; }
|
|
553
560
|
if (!text) return;
|
|
@@ -751,28 +758,45 @@ export class LocalDaemon {
|
|
|
751
758
|
resolvePath(inputPath = ".") {
|
|
752
759
|
const raw = String(inputPath || ".");
|
|
753
760
|
if (raw.includes("\0")) throw new Error("path contains a NUL byte");
|
|
754
|
-
return isAbsolute(raw) ? resolve(raw) : resolve(this.
|
|
761
|
+
return isAbsolute(raw) ? resolve(raw) : resolve(this.workspaceInput, raw);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
async canonicalWorkspace() {
|
|
765
|
+
if (!this.workspaceCanonicalPromise) {
|
|
766
|
+
this.workspaceCanonicalPromise = realpath(this.workspaceInput).then((canonical) => {
|
|
767
|
+
this.workspace = canonical;
|
|
768
|
+
this.processSessionManager.workspace = canonical;
|
|
769
|
+
return canonical;
|
|
770
|
+
}).catch((error) => {
|
|
771
|
+
this.workspaceCanonicalPromise = null;
|
|
772
|
+
throw error;
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
return this.workspaceCanonicalPromise;
|
|
755
776
|
}
|
|
756
777
|
|
|
757
778
|
async resolveExistingPath(inputPath = ".") {
|
|
758
779
|
const candidate = this.resolvePath(inputPath);
|
|
759
|
-
const canonical = await realpath(candidate);
|
|
760
|
-
if (!this.policy.unrestrictedPaths) assertContainedPath(
|
|
780
|
+
const [workspace, canonical] = await Promise.all([this.canonicalWorkspace(), realpath(candidate)]);
|
|
781
|
+
if (!this.policy.unrestrictedPaths) assertContainedPath(workspace, canonical);
|
|
761
782
|
return canonical;
|
|
762
783
|
}
|
|
763
784
|
|
|
764
785
|
async resolveWritePath(inputPath = ".") {
|
|
765
786
|
const candidate = this.resolvePath(inputPath);
|
|
766
787
|
if (this.policy.unrestrictedPaths) return candidate;
|
|
788
|
+
const candidateInfo = await lstat(candidate).catch(() => null);
|
|
767
789
|
let ancestor = candidate;
|
|
768
790
|
while (!(await lstat(ancestor).catch(() => null))) {
|
|
769
791
|
const parent = dirname(ancestor);
|
|
770
792
|
if (parent === ancestor) break;
|
|
771
793
|
ancestor = parent;
|
|
772
794
|
}
|
|
773
|
-
const canonicalAncestor = await realpath(ancestor);
|
|
774
|
-
assertContainedPath(
|
|
775
|
-
|
|
795
|
+
const [workspace, canonicalAncestor] = await Promise.all([this.canonicalWorkspace(), realpath(ancestor)]);
|
|
796
|
+
assertContainedPath(workspace, canonicalAncestor);
|
|
797
|
+
if (candidateInfo?.isSymbolicLink()) throw new Error("refusing to overwrite a symbolic link");
|
|
798
|
+
const suffix = relative(ancestor, candidate);
|
|
799
|
+
return suffix ? resolve(canonicalAncestor, suffix) : canonicalAncestor;
|
|
776
800
|
}
|
|
777
801
|
|
|
778
802
|
displayPath(fullPath) {
|
|
@@ -832,13 +856,36 @@ function assertContainedPath(root, target) {
|
|
|
832
856
|
throw new Error("path is outside the configured workspace; restart with --unrestricted-paths to allow it");
|
|
833
857
|
}
|
|
834
858
|
|
|
835
|
-
async function
|
|
836
|
-
const
|
|
859
|
+
async function readBoundedFile(filePath, maxBytes, label) {
|
|
860
|
+
const handle = await open(filePath, "r");
|
|
861
|
+
try {
|
|
862
|
+
const info = await handle.stat();
|
|
863
|
+
if (!info.isFile()) throw new Error(`${label} is not a regular file`);
|
|
864
|
+
if (info.size > maxBytes) throw new Error(`${label} exceeds maximum size (${info.size} > ${maxBytes})`);
|
|
865
|
+
const buffer = Buffer.alloc(info.size);
|
|
866
|
+
let offset = 0;
|
|
867
|
+
while (offset < buffer.length) {
|
|
868
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
869
|
+
if (bytesRead === 0) break;
|
|
870
|
+
offset += bytesRead;
|
|
871
|
+
}
|
|
872
|
+
return { buffer: buffer.subarray(0, offset), info };
|
|
873
|
+
} finally {
|
|
874
|
+
await handle.close();
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function decodeUtf8(buffer) {
|
|
837
879
|
try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
|
|
838
880
|
throw new Error("file is not valid UTF-8 text");
|
|
839
881
|
}
|
|
840
882
|
}
|
|
841
883
|
|
|
884
|
+
async function readUtf8File(filePath) {
|
|
885
|
+
const { buffer } = await readBoundedFile(filePath, MAX_WRITE_BYTES, "text file");
|
|
886
|
+
return decodeUtf8(buffer);
|
|
887
|
+
}
|
|
888
|
+
|
|
842
889
|
async function atomicWriteText(full, content, existing = null, options = {}) {
|
|
843
890
|
await mkdir(dirname(full), { recursive: true });
|
|
844
891
|
const temp = join(dirname(full), `.${basename(full)}.mbm-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
@@ -992,18 +1039,6 @@ function isPlainRecord(value) {
|
|
|
992
1039
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
993
1040
|
}
|
|
994
1041
|
|
|
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
|
-
}
|
|
1007
1042
|
|
|
1008
1043
|
function equivalentPathPrefixes(...values) {
|
|
1009
1044
|
const prefixes = new Set(values.filter(Boolean).map((value) => String(value)));
|
package/src/local/log.mjs
CHANGED
|
@@ -9,6 +9,11 @@ const COLORS = {
|
|
|
9
9
|
gray: "\x1b[90m",
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
const MAX_LOG_MESSAGE_CHARS = 2048;
|
|
13
|
+
const MAX_LOG_FIELD_CHARS = 4096;
|
|
14
|
+
const MAX_LOG_ARRAY_ITEMS = 32;
|
|
15
|
+
const MAX_LOG_OBJECT_KEYS = 48;
|
|
16
|
+
const LEVEL_RANK = Object.freeze({ debug: 10, info: 20, success: 20, warn: 30, error: 40 });
|
|
12
17
|
const SENSITIVE_KEY = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)/i;
|
|
13
18
|
const SECRET_VALUE = /\b(?:mcp_password|daemon_secret|token_version|mcp_at|mcp_code)_[A-Za-z0-9_-]+\b/g;
|
|
14
19
|
const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
|
|
@@ -16,23 +21,26 @@ const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi;
|
|
|
16
21
|
export function createLogger(options = {}) {
|
|
17
22
|
const quiet = Boolean(options.quiet);
|
|
18
23
|
const verbose = Boolean(options.verbose);
|
|
19
|
-
const
|
|
24
|
+
const minimumLevel = normalizeLogLevel(options.level || (quiet ? "error" : verbose ? "debug" : "info"));
|
|
25
|
+
const component = sanitizeLogText(options.component ? String(options.component) : "cli", 128);
|
|
20
26
|
const useColor = shouldUseColor(options);
|
|
27
|
+
const stdout = options.stderrOnly ? process.stderr : process.stdout;
|
|
21
28
|
|
|
22
29
|
const write = (stream, level, label, color, message, fields) => {
|
|
23
|
-
if (
|
|
24
|
-
if (level === "debug" && !verbose) return;
|
|
30
|
+
if (LEVEL_RANK[level] < LEVEL_RANK[minimumLevel]) return;
|
|
25
31
|
const prefix = useColor ? `${color}${label}${COLORS.reset}` : label;
|
|
26
32
|
const suffix = formatFields(fields);
|
|
27
|
-
stream.write(`${prefix} ${component}: ${sanitizeLogText(message)}${suffix}\n`);
|
|
33
|
+
stream.write(`${prefix} ${component}: ${sanitizeLogText(message, MAX_LOG_MESSAGE_CHARS)}${suffix}\n`);
|
|
28
34
|
};
|
|
29
35
|
|
|
30
36
|
return {
|
|
37
|
+
verbose: minimumLevel === "debug",
|
|
38
|
+
level: minimumLevel,
|
|
31
39
|
child(childComponent) {
|
|
32
40
|
return createLogger({ ...options, component: childComponent });
|
|
33
41
|
},
|
|
34
|
-
info(message, fields) { write(
|
|
35
|
-
success(message, fields) { write(
|
|
42
|
+
info(message, fields) { write(stdout, "info", "[info]", COLORS.blue, message, fields); },
|
|
43
|
+
success(message, fields) { write(stdout, "success", "[ok]", COLORS.green, message, fields); },
|
|
36
44
|
warn(message, fields) { write(process.stderr, "warn", "[warn]", COLORS.yellow, message, fields); },
|
|
37
45
|
error(message, fields) { write(process.stderr, "error", "[error]", COLORS.red, message, fields); },
|
|
38
46
|
debug(message, fields) { write(process.stderr, "debug", "[debug]", COLORS.gray, message, fields); },
|
|
@@ -41,13 +49,41 @@ export function createLogger(options = {}) {
|
|
|
41
49
|
};
|
|
42
50
|
}
|
|
43
51
|
|
|
44
|
-
export function
|
|
45
|
-
|
|
52
|
+
export function normalizeLogLevel(value = "info") {
|
|
53
|
+
const level = String(value || "info").trim().toLowerCase();
|
|
54
|
+
if (!Object.prototype.hasOwnProperty.call(LEVEL_RANK, level) || level === "success") {
|
|
55
|
+
throw new Error("log level must be one of: error, warn, info, debug");
|
|
56
|
+
}
|
|
57
|
+
return level;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
export function classifyOperationalError(error) {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
63
|
+
if (/cancel/i.test(message)) return "cancelled";
|
|
64
|
+
if (/timed out/i.test(message)) return "timeout";
|
|
65
|
+
if (/outside the configured workspace/i.test(message)) return "path_boundary";
|
|
66
|
+
if (/disabled|requires .* mode/i.test(message)) return "policy_denied";
|
|
67
|
+
if (/not found|ENOENT/i.test(message)) return "not_found";
|
|
68
|
+
if (/permission|EACCES|EPERM/i.test(message)) return "permission_denied";
|
|
69
|
+
if (/maximum|exceeds|max_bytes|too many/i.test(message)) return "limit_exceeded";
|
|
70
|
+
if (/invalid|must|requires|ambiguous|mismatch/i.test(message)) return "invalid_request";
|
|
71
|
+
return "execution_failed";
|
|
46
72
|
}
|
|
47
73
|
|
|
48
74
|
export function formatFields(fields) {
|
|
49
|
-
|
|
50
|
-
|
|
75
|
+
try {
|
|
76
|
+
if (!fields || typeof fields !== "object" || !Object.keys(fields).length) return "";
|
|
77
|
+
const sanitized = sanitizeLogValue(fields);
|
|
78
|
+
const json = JSON.stringify(sanitized);
|
|
79
|
+
if (json.length <= MAX_LOG_FIELD_CHARS) return ` ${json}`;
|
|
80
|
+
return ` ${JSON.stringify({
|
|
81
|
+
fields_truncated: true,
|
|
82
|
+
field_names: Object.keys(fields).slice(0, MAX_LOG_OBJECT_KEYS),
|
|
83
|
+
})}`;
|
|
84
|
+
} catch {
|
|
85
|
+
return ' {"fields_unavailable":true}';
|
|
86
|
+
}
|
|
51
87
|
}
|
|
52
88
|
|
|
53
89
|
export function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth = 0) {
|
|
@@ -56,23 +92,28 @@ export function sanitizeLogValue(value, key = "", seen = new WeakSet(), depth =
|
|
|
56
92
|
if (typeof value === "string") return sanitizeLogText(value);
|
|
57
93
|
if (typeof value === "bigint") return value.toString();
|
|
58
94
|
if (value === null || typeof value !== "object") return value;
|
|
59
|
-
if (depth >=
|
|
95
|
+
if (depth >= 6) return "<max-depth>";
|
|
60
96
|
if (seen.has(value)) return "<circular>";
|
|
61
97
|
seen.add(value);
|
|
62
|
-
if (Array.isArray(value)) return value.slice(0,
|
|
98
|
+
if (Array.isArray(value)) return value.slice(0, MAX_LOG_ARRAY_ITEMS).map(item => sanitizeLogValue(item, "", seen, depth + 1));
|
|
63
99
|
const out = {};
|
|
64
|
-
for (const [childKey, childValue] of Object.entries(value).slice(0,
|
|
100
|
+
for (const [childKey, childValue] of Object.entries(value).slice(0, MAX_LOG_OBJECT_KEYS)) {
|
|
65
101
|
out[childKey] = sanitizeLogValue(childValue, childKey, seen, depth + 1);
|
|
66
102
|
}
|
|
67
103
|
return out;
|
|
68
104
|
}
|
|
69
105
|
|
|
70
|
-
export function sanitizeLogText(value) {
|
|
71
|
-
|
|
106
|
+
export function sanitizeLogText(value, maxChars = MAX_LOG_MESSAGE_CHARS) {
|
|
107
|
+
let raw;
|
|
108
|
+
try { raw = String(value ?? ""); } catch { raw = "<unprintable>"; }
|
|
109
|
+
const sanitized = raw
|
|
72
110
|
.replace(SECRET_VALUE, "<redacted-secret>")
|
|
73
111
|
.replace(BEARER_VALUE, "Bearer <redacted>")
|
|
74
112
|
.replace(/[\r\n\t]/g, match => match === "\t" ? "\\t" : "\\n")
|
|
75
113
|
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "?");
|
|
114
|
+
if (!Number.isFinite(Number(maxChars)) || Number(maxChars) <= 0) return "";
|
|
115
|
+
const limit = Math.max(16, Number(maxChars));
|
|
116
|
+
return sanitized.length > limit ? `${sanitized.slice(0, limit - 1)}…` : sanitized;
|
|
76
117
|
}
|
|
77
118
|
|
|
78
119
|
function shouldUseColor(options) {
|
package/src/local/service.mjs
CHANGED
|
@@ -6,6 +6,15 @@ import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
|
|
|
6
6
|
|
|
7
7
|
const LABEL = "dev.machine-bridge-mcp.daemon";
|
|
8
8
|
const WINDOWS_TASK = "MachineBridgeMCP";
|
|
9
|
+
const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
|
10
|
+
|
|
11
|
+
function serviceRun(command, args) {
|
|
12
|
+
return run(command, args, {
|
|
13
|
+
capture: true,
|
|
14
|
+
allowFailure: true,
|
|
15
|
+
maxOutputBytes: SERVICE_COMMAND_OUTPUT_BYTES,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
9
18
|
|
|
10
19
|
export async function installAutostart({ workspace, stateRoot, entryScript, logger = console }) {
|
|
11
20
|
const spec = serviceSpec({ workspace, stateRoot, entryScript });
|
|
@@ -28,14 +37,14 @@ export async function autostartStatus({ logger = console } = {}) {
|
|
|
28
37
|
|
|
29
38
|
export async function startAutostart({ logger = console } = {}) {
|
|
30
39
|
if (process.platform === "darwin") return startLaunchd(logger);
|
|
31
|
-
if (process.platform === "win32") return
|
|
32
|
-
return
|
|
40
|
+
if (process.platform === "win32") return serviceRun("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
|
|
41
|
+
return serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]);
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export async function stopAutostart({ logger = console } = {}) {
|
|
36
45
|
if (process.platform === "darwin") return stopLaunchd(logger);
|
|
37
|
-
if (process.platform === "win32") return
|
|
38
|
-
return
|
|
46
|
+
if (process.platform === "win32") return serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]);
|
|
47
|
+
return serviceRun("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]);
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
|
|
@@ -50,21 +59,31 @@ export function trimAutostartLogs(stateRoot, options = {}) {
|
|
|
50
59
|
const info = statSync(file);
|
|
51
60
|
if (info.size > maxBytes) {
|
|
52
61
|
const fd = openSync(file, "r");
|
|
62
|
+
let buffer;
|
|
53
63
|
try {
|
|
54
64
|
const current = fstatSync(fd);
|
|
55
65
|
const length = Math.min(keepBytes, current.size);
|
|
56
|
-
|
|
66
|
+
buffer = Buffer.alloc(length);
|
|
57
67
|
readSync(fd, buffer, 0, length, Math.max(0, current.size - length));
|
|
58
|
-
writeFileSync(file, buffer, { mode: 0o600 });
|
|
59
68
|
} finally {
|
|
60
69
|
closeSync(fd);
|
|
61
70
|
}
|
|
71
|
+
writeFileSync(file, lineSafeTail(buffer), { mode: 0o600 });
|
|
62
72
|
}
|
|
63
73
|
chmodSync(file, 0o600);
|
|
64
74
|
} catch {}
|
|
65
75
|
}
|
|
66
76
|
}
|
|
67
77
|
|
|
78
|
+
function lineSafeTail(buffer) {
|
|
79
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return Buffer.alloc(0);
|
|
80
|
+
let text = buffer.toString("utf8");
|
|
81
|
+
const firstNewline = text.indexOf("\n");
|
|
82
|
+
if (firstNewline >= 0 && firstNewline < text.length - 1) text = text.slice(firstNewline + 1);
|
|
83
|
+
else text = text.replace(/^\uFFFD+/, "");
|
|
84
|
+
return Buffer.from(text, "utf8");
|
|
85
|
+
}
|
|
86
|
+
|
|
68
87
|
function serviceSpec({ workspace, stateRoot, entryScript }) {
|
|
69
88
|
const root = expandHome(stateRoot);
|
|
70
89
|
const logs = path.join(root, "logs");
|
|
@@ -92,7 +111,7 @@ export function daemonArgs(spec) {
|
|
|
92
111
|
"--workspace", spec.workspace,
|
|
93
112
|
"--state-dir", spec.stateRoot,
|
|
94
113
|
"--no-print-credentials",
|
|
95
|
-
"--
|
|
114
|
+
"--log-level", "warn",
|
|
96
115
|
];
|
|
97
116
|
}
|
|
98
117
|
|
|
@@ -113,9 +132,9 @@ async function startLaunchd(logger) {
|
|
|
113
132
|
const plistPath = launchdPlistPath();
|
|
114
133
|
const target = `gui/${process.getuid?.() ?? ""}`;
|
|
115
134
|
if (!existsSync(plistPath)) return { ok: false, error: "launchd plist not installed" };
|
|
116
|
-
await
|
|
117
|
-
const boot = await
|
|
118
|
-
const kick = await
|
|
135
|
+
await serviceRun("launchctl", ["bootout", target, plistPath]);
|
|
136
|
+
const boot = await serviceRun("launchctl", ["bootstrap", target, plistPath]);
|
|
137
|
+
const kick = await serviceRun("launchctl", ["kickstart", "-k", `${target}/${LABEL}`]);
|
|
119
138
|
logger.info?.("launchd service started");
|
|
120
139
|
return { ok: boot.code === 0 || kick.code === 0, bootstrap: boot, kickstart: kick };
|
|
121
140
|
}
|
|
@@ -123,7 +142,7 @@ async function startLaunchd(logger) {
|
|
|
123
142
|
async function stopLaunchd(logger) {
|
|
124
143
|
const plistPath = launchdPlistPath();
|
|
125
144
|
const target = `gui/${process.getuid?.() ?? ""}`;
|
|
126
|
-
const result = await
|
|
145
|
+
const result = await serviceRun("launchctl", ["bootout", target, plistPath]);
|
|
127
146
|
logger.info?.("launchd service stopped");
|
|
128
147
|
return result;
|
|
129
148
|
}
|
|
@@ -139,7 +158,7 @@ async function uninstallLaunchd(logger) {
|
|
|
139
158
|
async function statusLaunchd() {
|
|
140
159
|
const plistPath = launchdPlistPath();
|
|
141
160
|
const target = `gui/${process.getuid?.() ?? ""}/${LABEL}`;
|
|
142
|
-
const result = await
|
|
161
|
+
const result = await serviceRun("launchctl", ["print", target]);
|
|
143
162
|
return { ok: existsSync(plistPath), provider: "launchd", installed: existsSync(plistPath), path: plistPath, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
144
163
|
}
|
|
145
164
|
|
|
@@ -173,25 +192,25 @@ async function installSystemd(spec, logger) {
|
|
|
173
192
|
const servicePath = systemdPath();
|
|
174
193
|
mkdirSync(path.dirname(servicePath), { recursive: true });
|
|
175
194
|
writeFileSync(servicePath, systemdUnit(spec), { mode: 0o644 });
|
|
176
|
-
const reload = await
|
|
177
|
-
const enable = await
|
|
178
|
-
const linger = await
|
|
195
|
+
const reload = await serviceRun("systemctl", ["--user", "daemon-reload"]);
|
|
196
|
+
const enable = await serviceRun("systemctl", ["--user", "enable", "machine-bridge-mcp.service"]);
|
|
197
|
+
const linger = await serviceRun("loginctl", ["enable-linger", os.userInfo().username]);
|
|
179
198
|
logger.info?.(`Autostart installed: ${servicePath}`);
|
|
180
199
|
return { ok: reload.code === 0 && enable.code === 0, provider: "systemd", path: servicePath, reload, enable, linger };
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
async function uninstallSystemd(logger) {
|
|
184
|
-
await
|
|
203
|
+
await serviceRun("systemctl", ["--user", "disable", "--now", "machine-bridge-mcp.service"]);
|
|
185
204
|
const servicePath = systemdPath();
|
|
186
205
|
if (existsSync(servicePath)) rmSync(servicePath, { force: true });
|
|
187
|
-
await
|
|
206
|
+
await serviceRun("systemctl", ["--user", "daemon-reload"]);
|
|
188
207
|
logger.info?.(`Autostart removed: ${servicePath}`);
|
|
189
208
|
return { ok: true, provider: "systemd", path: servicePath };
|
|
190
209
|
}
|
|
191
210
|
|
|
192
211
|
async function statusSystemd() {
|
|
193
212
|
const servicePath = systemdPath();
|
|
194
|
-
const result = await
|
|
213
|
+
const result = await serviceRun("systemctl", ["--user", "status", "machine-bridge-mcp.service", "--no-pager"]);
|
|
195
214
|
return { ok: existsSync(servicePath), provider: "systemd", installed: existsSync(servicePath), path: servicePath, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
196
215
|
}
|
|
197
216
|
|
|
@@ -216,19 +235,19 @@ WantedBy=default.target
|
|
|
216
235
|
|
|
217
236
|
async function installWindowsTask(spec, logger) {
|
|
218
237
|
const command = windowsCommand(spec);
|
|
219
|
-
const result = await
|
|
238
|
+
const result = await serviceRun("schtasks", ["/Create", "/TN", WINDOWS_TASK, "/SC", "ONLOGON", "/TR", command, "/F"]);
|
|
220
239
|
logger.info?.("Windows Scheduled Task installed for logon");
|
|
221
240
|
return { ok: result.code === 0, provider: "schtasks", task: WINDOWS_TASK, result };
|
|
222
241
|
}
|
|
223
242
|
|
|
224
243
|
async function uninstallWindowsTask(logger) {
|
|
225
|
-
const result = await
|
|
244
|
+
const result = await serviceRun("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
|
|
226
245
|
logger.info?.("Windows Scheduled Task removed");
|
|
227
246
|
return { ok: result.code === 0 || /cannot find/i.test(result.stderr), provider: "schtasks", task: WINDOWS_TASK, result };
|
|
228
247
|
}
|
|
229
248
|
|
|
230
249
|
async function statusWindowsTask() {
|
|
231
|
-
const result = await
|
|
250
|
+
const result = await serviceRun("schtasks", ["/Query", "/TN", WINDOWS_TASK, "/FO", "LIST"]);
|
|
232
251
|
return { ok: result.code === 0, provider: "schtasks", installed: result.code === 0, task: WINDOWS_TASK, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
233
252
|
}
|
|
234
253
|
|