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.
@@ -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, opendir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
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: "machine-bridge-mcp",
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.info?.("connecting daemon websocket", { endpoint: redactUrl(wsUrl) });
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?.("daemon websocket connected");
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", { error: this.safeErrorMessage(error) });
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: classifyError(error) });
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.info?.("daemon websocket closed", fields);
165
- else this.logger.warn?.("daemon websocket disconnected", fields);
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.info?.("daemon websocket closed during shutdown", { error: boundedErrorMessage(error) });
177
- else this.logger.error?.("daemon websocket error", { error: boundedErrorMessage(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?.("daemon websocket send failed", { error: boundedErrorMessage(error) });
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
- this.logger.info?.("tool call completed", { call_id: shortCallId(id), tool, duration_ms: Date.now() - started, ok: true });
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
- this.logger.warn?.("tool call failed", { call_id: shortCallId(id), tool, duration_ms: Date.now() - started, ok: false, error_class: classifyError(error) });
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.info?.("tool call cancellation requested", { call_id: shortCallId(callId), reason });
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 content = await readUtf8File(full);
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 readFile(full);
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 info = await stat(full).catch(() => null);
548
- if (!info?.isFile() || info.size > 1024 * 1024) return;
549
- const buffer = await readFile(full).catch(() => null);
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.workspace, raw);
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(this.workspace, canonical);
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(this.workspace, canonicalAncestor);
775
- return candidate;
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 readUtf8File(filePath) {
836
- const buffer = await readFile(filePath);
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 component = sanitizeLogText(options.component ? String(options.component) : "cli");
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 (quiet && level !== "error") return;
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(process.stdout, "info", "[info]", COLORS.blue, message, fields); },
35
- success(message, fields) { write(process.stdout, "success", "[ok]", COLORS.green, message, fields); },
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 redactSecret(value) {
45
- return value ? "<redacted>" : "<empty>";
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
- if (!fields || typeof fields !== "object" || !Object.keys(fields).length) return "";
50
- return ` ${JSON.stringify(sanitizeLogValue(fields))}`;
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 >= 8) return "<max-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, 100).map(item => sanitizeLogValue(item, "", seen, depth + 1));
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)) {
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
- return String(value ?? "")
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) {
@@ -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 run("schtasks", ["/Run", "/TN", WINDOWS_TASK], { capture: true, allowFailure: true });
32
- return run("systemctl", ["--user", "start", "machine-bridge-mcp.service"], { capture: true, allowFailure: true });
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 run("schtasks", ["/End", "/TN", WINDOWS_TASK], { capture: true, allowFailure: true });
38
- return run("systemctl", ["--user", "stop", "machine-bridge-mcp.service"], { capture: true, allowFailure: true });
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
- const buffer = Buffer.alloc(length);
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
- "--quiet",
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 run("launchctl", ["bootout", target, plistPath], { capture: true, allowFailure: true });
117
- const boot = await run("launchctl", ["bootstrap", target, plistPath], { capture: true, allowFailure: true });
118
- const kick = await run("launchctl", ["kickstart", "-k", `${target}/${LABEL}`], { capture: true, allowFailure: true });
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 run("launchctl", ["bootout", target, plistPath], { capture: true, allowFailure: true });
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 run("launchctl", ["print", target], { capture: true, allowFailure: true });
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 run("systemctl", ["--user", "daemon-reload"], { capture: true, allowFailure: true });
177
- const enable = await run("systemctl", ["--user", "enable", "machine-bridge-mcp.service"], { capture: true, allowFailure: true });
178
- const linger = await run("loginctl", ["enable-linger", os.userInfo().username], { capture: true, allowFailure: true });
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 run("systemctl", ["--user", "disable", "--now", "machine-bridge-mcp.service"], { capture: true, allowFailure: true });
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 run("systemctl", ["--user", "daemon-reload"], { capture: true, allowFailure: true });
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 run("systemctl", ["--user", "status", "machine-bridge-mcp.service", "--no-pager"], { capture: true, allowFailure: true });
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 run("schtasks", ["/Create", "/TN", WINDOWS_TASK, "/SC", "ONLOGON", "/TR", command, "/F"], { capture: true, allowFailure: true });
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 run("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"], { capture: true, allowFailure: true });
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 run("schtasks", ["/Query", "/TN", WINDOWS_TASK, "/FO", "LIST"], { capture: true, allowFailure: true });
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