machine-bridge-mcp 0.4.2 → 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 +24 -0
- package/README.md +17 -9
- package/SECURITY.md +6 -4
- package/docs/ARCHITECTURE.md +12 -10
- package/docs/CLIENTS.md +6 -0
- package/docs/LOGGING.md +80 -0
- package/docs/OPERATIONS.md +25 -19
- package/docs/TESTING.md +7 -4
- package/package.json +4 -4
- package/src/local/cli.mjs +155 -77
- package/src/local/daemon.mjs +58 -41
- 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/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 }) {
|
|
@@ -66,12 +68,18 @@ export class LocalDaemon {
|
|
|
66
68
|
|
|
67
69
|
runtimeInfo() {
|
|
68
70
|
return {
|
|
69
|
-
name:
|
|
71
|
+
name: SERVER_NAME,
|
|
70
72
|
protocol_version: MCP_PROTOCOL_VERSION,
|
|
71
73
|
supported_protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
72
74
|
workspace: this.displayPath(this.workspace),
|
|
73
75
|
workspace_name: basename(this.workspace),
|
|
74
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
|
+
},
|
|
75
83
|
tools: ["server_info", ...this.tools()],
|
|
76
84
|
runtime: {
|
|
77
85
|
environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
|
|
@@ -109,7 +117,7 @@ export class LocalDaemon {
|
|
|
109
117
|
connect() {
|
|
110
118
|
if (this.closed) return;
|
|
111
119
|
const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
|
|
112
|
-
this.logger.
|
|
120
|
+
this.logger.debug?.("connecting to remote relay", { endpoint: redactUrl(wsUrl) });
|
|
113
121
|
const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret } });
|
|
114
122
|
this.ws = socket;
|
|
115
123
|
|
|
@@ -119,7 +127,7 @@ export class LocalDaemon {
|
|
|
119
127
|
return;
|
|
120
128
|
}
|
|
121
129
|
this.reconnectAttempt = 0;
|
|
122
|
-
this.logger.info?.("
|
|
130
|
+
this.logger.info?.("remote relay connected");
|
|
123
131
|
this.send({ type: "hello", tools: this.tools(), policy: this.policy, protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS });
|
|
124
132
|
if (this.connectedOnceResolve) {
|
|
125
133
|
this.connectedOnceResolve(true);
|
|
@@ -138,7 +146,7 @@ export class LocalDaemon {
|
|
|
138
146
|
return;
|
|
139
147
|
}
|
|
140
148
|
void this.handleMessage(raw).catch(error => {
|
|
141
|
-
this.logger.error?.("daemon message handler failed", {
|
|
149
|
+
this.logger.error?.("daemon message handler failed", { error_class: classifyOperationalError(error) });
|
|
142
150
|
});
|
|
143
151
|
});
|
|
144
152
|
|
|
@@ -157,13 +165,13 @@ export class LocalDaemon {
|
|
|
157
165
|
this.logger.warn?.("daemon connection permanently superseded", fields);
|
|
158
166
|
queueMicrotask(() => {
|
|
159
167
|
try { this.onSuperseded?.(); } catch (error) {
|
|
160
|
-
this.logger.error?.("daemon superseded callback failed", { error_class:
|
|
168
|
+
this.logger.error?.("daemon superseded callback failed", { error_class: classifyOperationalError(error) });
|
|
161
169
|
}
|
|
162
170
|
});
|
|
163
171
|
return;
|
|
164
172
|
}
|
|
165
|
-
if (this.closed) this.logger.
|
|
166
|
-
else this.logger.warn?.("
|
|
173
|
+
if (this.closed) this.logger.debug?.("remote relay closed", fields);
|
|
174
|
+
else this.logger.warn?.("remote relay disconnected", fields);
|
|
167
175
|
if (!this.closed) {
|
|
168
176
|
const delay = reconnectDelay(this.reconnectAttempt++);
|
|
169
177
|
this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay });
|
|
@@ -174,8 +182,8 @@ export class LocalDaemon {
|
|
|
174
182
|
|
|
175
183
|
socket.on("error", error => {
|
|
176
184
|
if (this.ws !== socket) return;
|
|
177
|
-
if (this.closed) this.logger.
|
|
178
|
-
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) });
|
|
179
187
|
});
|
|
180
188
|
}
|
|
181
189
|
|
|
@@ -185,7 +193,7 @@ export class LocalDaemon {
|
|
|
185
193
|
this.ws.send(JSON.stringify(value));
|
|
186
194
|
return true;
|
|
187
195
|
} catch (error) {
|
|
188
|
-
this.logger.warn?.("
|
|
196
|
+
this.logger.warn?.("remote relay send failed", { error_class: classifyOperationalError(error) });
|
|
189
197
|
return false;
|
|
190
198
|
}
|
|
191
199
|
}
|
|
@@ -229,11 +237,15 @@ export class LocalDaemon {
|
|
|
229
237
|
const result = await this.executeTool(tool, argumentsValue, { callId: id });
|
|
230
238
|
if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
|
|
231
239
|
this.send({ type: "tool_result", id, ok: true, result });
|
|
232
|
-
|
|
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 });
|
|
233
243
|
} catch (error) {
|
|
234
244
|
const safeError = this.safeErrorMessage(error);
|
|
235
245
|
this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
|
|
236
|
-
|
|
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) });
|
|
237
249
|
} finally {
|
|
238
250
|
clearTimeout(deadline);
|
|
239
251
|
this.activeToolCalls -= 1;
|
|
@@ -258,7 +270,7 @@ export class LocalDaemon {
|
|
|
258
270
|
}, 2000);
|
|
259
271
|
timer.unref?.();
|
|
260
272
|
}
|
|
261
|
-
this.logger.
|
|
273
|
+
this.logger.debug?.("tool call cancellation requested", { call_id: shortCallId(callId), reason });
|
|
262
274
|
}
|
|
263
275
|
|
|
264
276
|
async executeTool(tool, args, context = {}) {
|
|
@@ -366,11 +378,9 @@ export class LocalDaemon {
|
|
|
366
378
|
}
|
|
367
379
|
if (!args.path) throw new Error("path is required");
|
|
368
380
|
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
381
|
this.throwIfCancelled(context);
|
|
373
|
-
const
|
|
382
|
+
const { buffer, info } = await readBoundedFile(full, MAX_WRITE_BYTES, "readable text file");
|
|
383
|
+
const content = decodeUtf8(buffer);
|
|
374
384
|
this.throwIfCancelled(context);
|
|
375
385
|
const maxBytes = clampInt(args.max_bytes, 1024 * 1024, 1, MAX_WRITE_BYTES);
|
|
376
386
|
const startLine = args.start_line === undefined ? 1 : clampInt(args.start_line, 1, 1, Number.MAX_SAFE_INTEGER);
|
|
@@ -399,11 +409,8 @@ export class LocalDaemon {
|
|
|
399
409
|
async viewImage(args, context = {}) {
|
|
400
410
|
if (!args.path) throw new Error("path is required");
|
|
401
411
|
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
412
|
this.throwIfCancelled(context);
|
|
406
|
-
const buffer = await
|
|
413
|
+
const { buffer, info } = await readBoundedFile(full, MAX_IMAGE_BYTES, "image");
|
|
407
414
|
this.throwIfCancelled(context);
|
|
408
415
|
const mimeType = detectImageMime(buffer);
|
|
409
416
|
if (!mimeType) throw new Error("unsupported image format; expected PNG, JPEG, GIF, or WebP");
|
|
@@ -545,10 +552,9 @@ export class LocalDaemon {
|
|
|
545
552
|
|
|
546
553
|
async searchOneFile(full, query, matches, max, context = {}) {
|
|
547
554
|
this.throwIfCancelled(context);
|
|
548
|
-
const
|
|
549
|
-
if (!
|
|
550
|
-
const buffer =
|
|
551
|
-
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;
|
|
552
558
|
let text;
|
|
553
559
|
try { text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch { return; }
|
|
554
560
|
if (!text) return;
|
|
@@ -850,13 +856,36 @@ function assertContainedPath(root, target) {
|
|
|
850
856
|
throw new Error("path is outside the configured workspace; restart with --unrestricted-paths to allow it");
|
|
851
857
|
}
|
|
852
858
|
|
|
853
|
-
async function
|
|
854
|
-
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) {
|
|
855
879
|
try { return new TextDecoder("utf-8", { fatal: true }).decode(buffer); } catch {
|
|
856
880
|
throw new Error("file is not valid UTF-8 text");
|
|
857
881
|
}
|
|
858
882
|
}
|
|
859
883
|
|
|
884
|
+
async function readUtf8File(filePath) {
|
|
885
|
+
const { buffer } = await readBoundedFile(filePath, MAX_WRITE_BYTES, "text file");
|
|
886
|
+
return decodeUtf8(buffer);
|
|
887
|
+
}
|
|
888
|
+
|
|
860
889
|
async function atomicWriteText(full, content, existing = null, options = {}) {
|
|
861
890
|
await mkdir(dirname(full), { recursive: true });
|
|
862
891
|
const temp = join(dirname(full), `.${basename(full)}.mbm-${process.pid}-${randomBytes(6).toString("hex")}.tmp`);
|
|
@@ -1010,18 +1039,6 @@ function isPlainRecord(value) {
|
|
|
1010
1039
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1011
1040
|
}
|
|
1012
1041
|
|
|
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
1042
|
|
|
1026
1043
|
function equivalentPathPrefixes(...values) {
|
|
1027
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
|
|