machine-bridge-mcp 0.6.2 → 0.8.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,8 +1,9 @@
1
- import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { run } from "./shell.mjs";
5
- import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
5
+ import { ensureOwnerOnlyDir, expandHome, ownerOnlyFile } from "./state.mjs";
6
+ import { replaceFileSync } from "./atomic-fs.mjs";
6
7
 
7
8
  const LABEL = "dev.machine-bridge-mcp.daemon";
8
9
  const WINDOWS_TASK = "MachineBridgeMCP";
@@ -55,23 +56,29 @@ export function trimAutostartLogs(stateRoot, options = {}) {
55
56
  const logs = path.join(root, "logs");
56
57
  for (const name of ["daemon.out.log", "daemon.err.log"]) {
57
58
  const file = path.join(logs, name);
59
+ let fd;
58
60
  try {
59
- const info = statSync(file);
61
+ if (!existsSync(file)) continue;
62
+ const before = lstatSync(file);
63
+ if (before.isSymbolicLink() || !before.isFile()) continue;
64
+ const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
65
+ fd = openSync(file, Number(fsConstants.O_RDWR) | noFollow);
66
+ const info = fstatSync(fd);
67
+ if (!info.isFile()) continue;
60
68
  if (info.size > maxBytes) {
61
- const fd = openSync(file, "r");
62
- let buffer;
63
- try {
64
- const current = fstatSync(fd);
65
- const length = Math.min(keepBytes, current.size);
66
- buffer = Buffer.alloc(length);
67
- readSync(fd, buffer, 0, length, Math.max(0, current.size - length));
68
- } finally {
69
- closeSync(fd);
70
- }
71
- writeFileSync(file, lineSafeTail(buffer), { mode: 0o600 });
69
+ const length = Math.min(keepBytes, info.size);
70
+ const buffer = Buffer.alloc(length);
71
+ readSync(fd, buffer, 0, length, Math.max(0, info.size - length));
72
+ const tail = lineSafeTail(buffer);
73
+ ftruncateSync(fd, 0);
74
+ if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
72
75
  }
73
- chmodSync(file, 0o600);
74
- } catch {}
76
+ try { chmodSync(file, 0o600); } catch {}
77
+ } catch {
78
+ // Operational log maintenance is best effort and must not stop startup.
79
+ } finally {
80
+ if (fd !== undefined) try { closeSync(fd); } catch {}
81
+ }
75
82
  }
76
83
  }
77
84
 
@@ -87,22 +94,76 @@ function lineSafeTail(buffer) {
87
94
  function serviceSpec({ workspace, stateRoot, entryScript }) {
88
95
  const root = expandHome(stateRoot);
89
96
  const logs = path.join(root, "logs");
97
+ const resolvedEntryScript = path.resolve(entryScript);
98
+ const node = stableNodeExecutable();
90
99
  ensureOwnerOnlyDir(root);
91
100
  ensureOwnerOnlyDir(logs);
92
- for (const file of [path.join(logs, "daemon.out.log"), path.join(logs, "daemon.err.log")]) {
93
- writeFileSync(file, "", { flag: "a", mode: 0o600 });
94
- try { chmodSync(file, 0o600); } catch {}
95
- }
101
+ for (const file of [path.join(logs, "daemon.out.log"), path.join(logs, "daemon.err.log")]) ensurePrivateLogFile(file);
96
102
  return {
97
103
  workspace,
98
104
  stateRoot: root,
99
- entryScript: path.resolve(entryScript),
100
- node: process.execPath,
105
+ entryScript: resolvedEntryScript,
106
+ node,
107
+ pathEnv: serviceEnvironmentPath({ node, entryScript: resolvedEntryScript }),
101
108
  stdout: path.join(logs, "daemon.out.log"),
102
109
  stderr: path.join(logs, "daemon.err.log"),
103
110
  };
104
111
  }
105
112
 
113
+ export function stableNodeExecutable(options = {}) {
114
+ const platform = String(options.platform || process.platform);
115
+ const execPath = path.resolve(String(options.execPath || process.execPath));
116
+ const pathEnv = String(options.pathEnv ?? process.env.PATH ?? "");
117
+ let canonicalExec;
118
+ try { canonicalExec = realpathSync(execPath); } catch { return execPath; }
119
+ const executableName = platform === "win32" ? "node.exe" : "node";
120
+ for (const directory of pathEnv.split(path.delimiter).filter(Boolean)) {
121
+ const candidate = path.resolve(directory, executableName);
122
+ try {
123
+ const info = lstatSync(candidate);
124
+ if (!info.isFile() && !info.isSymbolicLink()) continue;
125
+ const candidateCanonical = realpathSync(candidate);
126
+ const sameExecutable = platform === "win32"
127
+ ? candidateCanonical.toLowerCase() === canonicalExec.toLowerCase()
128
+ : candidateCanonical === canonicalExec;
129
+ if (!sameExecutable) continue;
130
+ if (platform !== "win32" && (statSync(candidate).mode & 0o111) === 0) continue;
131
+ return candidate;
132
+ } catch {}
133
+ }
134
+ return execPath;
135
+ }
136
+
137
+ export function serviceEnvironmentPath(options = {}) {
138
+ const platform = String(options.platform || process.platform);
139
+ const delimiter = String(options.delimiter || (platform === "win32" ? ";" : ":"));
140
+ const pathEnv = String(options.pathEnv ?? process.env.PATH ?? "");
141
+ const node = String(options.node || process.execPath || "");
142
+ const entryScript = String(options.entryScript || "");
143
+ const defaults = platform === "darwin"
144
+ ? ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
145
+ : platform === "win32"
146
+ ? []
147
+ : ["/usr/local/bin", "/usr/bin", "/bin", "/usr/local/sbin", "/usr/sbin", "/sbin"];
148
+ const candidates = [
149
+ node ? path.dirname(path.resolve(node)) : "",
150
+ entryScript ? path.dirname(path.resolve(entryScript)) : "",
151
+ ...pathEnv.split(delimiter),
152
+ ...defaults,
153
+ ];
154
+ const seen = new Set();
155
+ const entries = [];
156
+ for (const raw of candidates) {
157
+ if (!raw || !path.isAbsolute(raw)) continue;
158
+ const normalized = path.resolve(raw);
159
+ const key = platform === "win32" ? normalized.toLowerCase() : normalized;
160
+ if (seen.has(key)) continue;
161
+ seen.add(key);
162
+ entries.push(normalized);
163
+ }
164
+ return entries.join(delimiter);
165
+ }
166
+
106
167
  export function daemonArgs(spec) {
107
168
  return [
108
169
  spec.entryScript,
@@ -115,6 +176,38 @@ export function daemonArgs(spec) {
115
176
  ];
116
177
  }
117
178
 
179
+ function ensurePrivateLogFile(file) {
180
+ if (existsSync(file)) {
181
+ const info = lstatSync(file);
182
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart log path must be a regular non-symbolic-link file");
183
+ }
184
+ const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
185
+ const fd = openSync(file, Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND) | noFollow, 0o600);
186
+ try {
187
+ if (!fstatSync(fd).isFile()) throw new Error("autostart log path is not a regular file");
188
+ } finally {
189
+ closeSync(fd);
190
+ }
191
+ ownerOnlyFile(file);
192
+ }
193
+
194
+ function writePrivateServiceFile(file, content) {
195
+ mkdirSync(path.dirname(file), { recursive: true });
196
+ if (existsSync(file)) {
197
+ const info = lstatSync(file);
198
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart configuration path must be a regular non-symbolic-link file");
199
+ }
200
+ const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
201
+ try {
202
+ writeFileSync(temporary, content, { mode: 0o600, flag: "wx" });
203
+ replaceFileSync(temporary, file);
204
+ ownerOnlyFile(file);
205
+ } catch (error) {
206
+ try { rmSync(temporary, { force: true }); } catch {}
207
+ throw error;
208
+ }
209
+ }
210
+
118
211
  function launchdPlistPath() {
119
212
  return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
120
213
  }
@@ -123,8 +216,8 @@ async function installLaunchd(spec, logger) {
123
216
  const plistPath = launchdPlistPath();
124
217
  mkdirSync(path.dirname(plistPath), { recursive: true });
125
218
  const args = [spec.node, ...daemonArgs(spec)];
126
- writeFileSync(plistPath, launchdPlist({ args, stdout: spec.stdout, stderr: spec.stderr }), { mode: 0o644 });
127
- logger.info?.(`Autostart installed for next login: ${plistPath}`);
219
+ writePrivateServiceFile(plistPath, launchdPlist({ args, pathEnv: spec.pathEnv, stdout: spec.stdout, stderr: spec.stderr }));
220
+ logger.info?.("Autostart installed for next login.");
128
221
  return { ok: true, provider: "launchd", path: plistPath };
129
222
  }
130
223
 
@@ -151,7 +244,7 @@ async function uninstallLaunchd(logger) {
151
244
  await stopLaunchd(logger).catch(() => {});
152
245
  const plistPath = launchdPlistPath();
153
246
  if (existsSync(plistPath)) rmSync(plistPath, { force: true });
154
- logger.info?.(`Autostart removed: ${plistPath}`);
247
+ logger.info?.("Autostart removed.");
155
248
  return { ok: true, provider: "launchd", path: plistPath };
156
249
  }
157
250
 
@@ -162,7 +255,7 @@ async function statusLaunchd() {
162
255
  return { ok: existsSync(plistPath), provider: "launchd", installed: existsSync(plistPath), path: plistPath, active: result.code === 0, detail: result.stdout || result.stderr };
163
256
  }
164
257
 
165
- function launchdPlist({ args, stdout, stderr }) {
258
+ export function launchdPlist({ args, pathEnv, stdout, stderr }) {
166
259
  return `<?xml version="1.0" encoding="UTF-8"?>
167
260
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
168
261
  <plist version="1.0">
@@ -172,6 +265,10 @@ function launchdPlist({ args, stdout, stderr }) {
172
265
  <array>
173
266
  ${args.map(arg => ` <string>${escapeXml(arg)}</string>`).join("\n")}
174
267
  </array>
268
+ <key>EnvironmentVariables</key>
269
+ <dict>
270
+ <key>PATH</key><string>${escapeXml(pathEnv)}</string>
271
+ </dict>
175
272
  <key>RunAtLoad</key><true/>
176
273
  <key>KeepAlive</key>
177
274
  <dict>
@@ -191,11 +288,11 @@ function systemdPath() {
191
288
  async function installSystemd(spec, logger) {
192
289
  const servicePath = systemdPath();
193
290
  mkdirSync(path.dirname(servicePath), { recursive: true });
194
- writeFileSync(servicePath, systemdUnit(spec), { mode: 0o644 });
291
+ writePrivateServiceFile(servicePath, systemdUnit(spec));
195
292
  const reload = await serviceRun("systemctl", ["--user", "daemon-reload"]);
196
293
  const enable = await serviceRun("systemctl", ["--user", "enable", "machine-bridge-mcp.service"]);
197
294
  const linger = await serviceRun("loginctl", ["enable-linger", os.userInfo().username]);
198
- logger.info?.(`Autostart installed: ${servicePath}`);
295
+ logger.info?.("Autostart installed.");
199
296
  return { ok: reload.code === 0 && enable.code === 0, provider: "systemd", path: servicePath, reload, enable, linger };
200
297
  }
201
298
 
@@ -204,7 +301,7 @@ async function uninstallSystemd(logger) {
204
301
  const servicePath = systemdPath();
205
302
  if (existsSync(servicePath)) rmSync(servicePath, { force: true });
206
303
  await serviceRun("systemctl", ["--user", "daemon-reload"]);
207
- logger.info?.(`Autostart removed: ${servicePath}`);
304
+ logger.info?.("Autostart removed.");
208
305
  return { ok: true, provider: "systemd", path: servicePath };
209
306
  }
210
307
 
@@ -214,7 +311,7 @@ async function statusSystemd() {
214
311
  return { ok: existsSync(servicePath), provider: "systemd", installed: existsSync(servicePath), path: servicePath, active: result.code === 0, detail: result.stdout || result.stderr };
215
312
  }
216
313
 
217
- function systemdUnit(spec) {
314
+ export function systemdUnit(spec) {
218
315
  const execArgs = [spec.node, ...daemonArgs(spec)].map(systemdQuote).join(" ");
219
316
  return `[Unit]
220
317
  Description=Machine Bridge MCP daemon
@@ -223,6 +320,7 @@ After=network-online.target
223
320
  [Service]
224
321
  Type=simple
225
322
  ExecStart=${execArgs}
323
+ Environment=${systemdQuote(`PATH=${spec.pathEnv}`)}
226
324
  Restart=on-failure
227
325
  RestartSec=5
228
326
  StandardOutput=append:${spec.stdout}
@@ -85,7 +85,7 @@ function finalizeOutput(value, truncated) {
85
85
  return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
86
86
  }
87
87
 
88
- export function findWranglerCommand() {
88
+ function findWranglerCommand() {
89
89
  const suffix = process.platform === "win32" ? ".cmd" : "";
90
90
  const local = path.join(packageRoot, "node_modules", ".bin", `wrangler${suffix}`);
91
91
  if (existsSync(local)) return { cmd: local, argsPrefix: [] };
@@ -7,44 +7,62 @@ import { run } from "./shell.mjs";
7
7
  const KEY_TYPES = new Set(["ed25519", "rsa"]);
8
8
 
9
9
  export async function generateSshKeyPair(options = {}) {
10
+ const request = normalizeKeyRequest(options);
11
+ await mkdir(request.parent, { recursive: true, mode: 0o700 });
12
+ if (process.platform !== "win32") await chmod(request.parent, 0o700);
13
+
14
+ const existing = await inspectExistingKeyFiles(request.privateKeyPath, request.publicKeyPath);
15
+ if (existing) {
16
+ await secureKeyModes(request.privateKeyPath, request.publicKeyPath);
17
+ return inspectSshKeyPair(request.privateKeyPath, request.publicKeyPath, false);
18
+ }
19
+ return createSshKeyPair(request);
20
+ }
21
+
22
+ function normalizeKeyRequest(options) {
10
23
  const privateKeyPath = resolve(String(options.privateKeyPath || ""));
11
24
  if (!privateKeyPath || privateKeyPath === resolve(".")) throw new Error("private key path is required");
12
- const publicKeyPath = `${privateKeyPath}.pub`;
13
25
  const type = String(options.type || "ed25519").toLowerCase();
14
26
  if (!KEY_TYPES.has(type)) throw new Error("SSH key type must be ed25519 or rsa");
15
- const comment = boundedComment(options.comment || `machine-mcp:${basename(privateKeyPath)}`);
16
- const parent = dirname(privateKeyPath);
17
- await mkdir(parent, { recursive: true, mode: 0o700 });
18
- if (process.platform !== "win32") await chmod(parent, 0o700);
27
+ return {
28
+ privateKeyPath,
29
+ publicKeyPath: `${privateKeyPath}.pub`,
30
+ parent: dirname(privateKeyPath),
31
+ type,
32
+ bits: options.bits,
33
+ comment: boundedComment(options.comment || `machine-mcp:${basename(privateKeyPath)}`),
34
+ };
35
+ }
19
36
 
37
+ async function inspectExistingKeyFiles(privateKeyPath, publicKeyPath) {
20
38
  const privateInfo = await safeLstat(privateKeyPath);
21
39
  const publicInfo = await safeLstat(publicKeyPath);
22
40
  if (privateInfo?.isSymbolicLink() || publicInfo?.isSymbolicLink()) throw new Error("SSH key path must not be a symbolic link");
23
- if (privateInfo || publicInfo) {
24
- if (!privateInfo?.isFile() || !publicInfo?.isFile()) throw new Error("SSH key pair is incomplete or not a pair of regular files");
25
- await secureKeyModes(privateKeyPath, publicKeyPath);
26
- return inspectSshKeyPair(privateKeyPath, publicKeyPath, false);
27
- }
41
+ if (!privateInfo && !publicInfo) return false;
42
+ if (!privateInfo?.isFile() || !publicInfo?.isFile()) throw new Error("SSH key pair is incomplete or not a pair of regular files");
43
+ return true;
44
+ }
28
45
 
46
+ async function createSshKeyPair(request) {
29
47
  const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
30
- const tempPrivate = resolve(parent, `.${basename(privateKeyPath)}.mbm-${suffix}`);
48
+ const tempPrivate = resolve(request.parent, `.${basename(request.privateKeyPath)}.mbm-${suffix}`);
31
49
  const tempPublic = `${tempPrivate}.pub`;
32
50
  try {
33
- const args = ["-q", "-t", type];
34
- if (type === "rsa") args.push("-b", String(normalizeRsaBits(options.bits)));
35
- args.push("-N", "", "-f", tempPrivate, "-C", comment);
51
+ const args = ["-q", "-t", request.type];
52
+ if (request.type === "rsa") args.push("-b", String(normalizeRsaBits(request.bits)));
53
+ args.push("-N", "", "-f", tempPrivate, "-C", request.comment);
36
54
  const generated = await run("ssh-keygen", args, { capture: true, timeoutMs: 30_000, maxOutputBytes: 64 * 1024 });
37
55
  if (generated.code !== 0) throw new Error("ssh-keygen failed");
38
56
  await secureKeyModes(tempPrivate, tempPublic);
39
- await installNoReplace(tempPrivate, privateKeyPath);
57
+ await installNoReplace(tempPrivate, request.privateKeyPath);
40
58
  try {
41
- await installNoReplace(tempPublic, publicKeyPath);
59
+ await installNoReplace(tempPublic, request.publicKeyPath);
42
60
  } catch (error) {
43
- await rm(privateKeyPath, { force: true });
61
+ await rm(request.privateKeyPath, { force: true });
44
62
  throw error;
45
63
  }
46
- await secureKeyModes(privateKeyPath, publicKeyPath);
47
- return inspectSshKeyPair(privateKeyPath, publicKeyPath, true);
64
+ await secureKeyModes(request.privateKeyPath, request.publicKeyPath);
65
+ return inspectSshKeyPair(request.privateKeyPath, request.publicKeyPath, true);
48
66
  } finally {
49
67
  await rm(tempPrivate, { force: true });
50
68
  await rm(tempPublic, { force: true });
@@ -72,13 +90,15 @@ export async function inspectSshKeyPair(privateKeyPath, publicKeyPath = `${priva
72
90
  timeoutMs: 15_000,
73
91
  maxOutputBytes: 64 * 1024,
74
92
  });
93
+ const fingerprintValue = fingerprint.stdout.trim().split(/\s+/)[1] || "";
94
+ if (!/^SHA256:[A-Za-z0-9+/=]+$/.test(fingerprintValue)) throw new Error("SSH key fingerprint output is invalid");
75
95
  return {
76
96
  created,
77
97
  privateKeyPath: resolve(privateKeyPath),
78
98
  publicKeyPath: resolve(publicKeyPath),
79
99
  privateMode: process.platform === "win32" ? null : `0${(privateInfo.mode & 0o777).toString(8)}`,
80
100
  publicMode: process.platform === "win32" ? null : `0${(publicInfo.mode & 0o777).toString(8)}`,
81
- fingerprint: fingerprint.stdout.trim(),
101
+ fingerprint: fingerprintValue,
82
102
  publicKeyType: publicLine.split(/\s+/, 1)[0],
83
103
  };
84
104
  }
@@ -112,7 +132,7 @@ async function safeLstat(path) {
112
132
  }
113
133
 
114
134
  function boundedComment(value) {
115
- const comment = String(value || "").replace(/[\r\n\0]/g, " ").trim();
135
+ const comment = String(value || "").replace(/[\r\n\0\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, " ").replace(/\s+/g, " ").trim();
116
136
  if (!comment) return "machine-mcp";
117
137
  if (Buffer.byteLength(comment) > 256) throw new Error("SSH key comment exceeds 256 bytes");
118
138
  return comment;
@@ -80,7 +80,7 @@ export function removeStateRoot(stateRoot = defaultStateRoot()) {
80
80
  return true;
81
81
  }
82
82
 
83
- export function workspaceHash(workspace) {
83
+ function workspaceHash(workspace) {
84
84
  const canonical = resolveWorkspace(workspace);
85
85
  const identity = process.platform === "win32" ? canonical.toLowerCase() : canonical;
86
86
  return createHash("sha256").update(identity).digest("hex").slice(0, 24);
@@ -160,7 +160,7 @@ export function daemonLockPathForState(state) {
160
160
  return lockPathForState(state, "daemon.lock");
161
161
  }
162
162
 
163
- export function startupLockPathForState(state) {
163
+ function startupLockPathForState(state) {
164
164
  return lockPathForState(state, "startup.lock");
165
165
  }
166
166
 
@@ -362,7 +362,7 @@ function assertValidStateMarker(marker, options = {}) {
362
362
 
363
363
  function hasOnlyStateEntries(entries) {
364
364
  const allowed = new Set([STATE_MARKER, "config.json", "profiles", "logs"]);
365
- return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d+$/.test(entry));
365
+ return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d+(?:-[a-f0-9]{8})?$/.test(entry));
366
366
  }
367
367
 
368
368
  function looksLikeSourceTree(root) {
@@ -484,7 +484,7 @@ function randomToken(prefix) {
484
484
  return `${prefix}_${randomBytes(32).toString("base64url")}`;
485
485
  }
486
486
 
487
- export function sha256(value) {
487
+ function sha256(value) {
488
488
  return createHash("sha256").update(String(value)).digest("hex");
489
489
  }
490
490
 
@@ -508,7 +508,9 @@ export function redactState(state) {
508
508
  if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
509
509
  if (clone.resources && typeof clone.resources === "object") {
510
510
  for (const value of Object.values(clone.resources)) {
511
- if (value && typeof value === "object" && value.path) value.path = "<local-resource-path>";
511
+ if (!value || typeof value !== "object") continue;
512
+ if (value.path) value.path = "<local-resource-path>";
513
+ delete value.pathAliases;
512
514
  }
513
515
  }
514
516
  return clone;