machine-bridge-mcp 0.6.2 → 0.7.1

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,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
@@ -95,36 +95,44 @@ export class ManagedJobManager {
95
95
  approve(args = {}, { localOperator = false } = {}) {
96
96
  if (!localOperator) this.assertEnabled("approve_job");
97
97
  const dir = this.jobDir(args.job_id);
98
- const statusFile = join(dir, "status.json");
99
- const status = readRequiredJson(statusFile, 256 * 1024, "job status");
100
- if (status.status !== "staged") throw new Error(`job is not staged: ${status.status}`);
101
- readRequiredJson(join(dir, "plan.json"), MAX_PLAN_BYTES, "job plan");
102
- status.status = "queued";
103
- status.updated_at = new Date().toISOString();
104
- status.approved_at = status.updated_at;
105
- status.approval = localOperator ? "local-operator" : "mcp";
106
- status.cleanup_guarantee = "best-effort-finally-and-recovery";
107
- atomicWriteJson(statusFile, status, 256 * 1024);
98
+ const transition = acquireJobTransitionLock(dir);
99
+ if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
108
100
  try {
109
- launchRunner(dir);
110
- } catch (error) {
111
- failRunnerLaunch(dir, status, error);
112
- throw error;
101
+ const statusFile = join(dir, "status.json");
102
+ const status = readRequiredJson(statusFile, 256 * 1024, "job status");
103
+ if (status.status !== "staged") throw new Error(`job is not staged: ${status.status}`);
104
+ const plan = readRequiredJson(join(dir, "plan.json"), MAX_PLAN_BYTES, "job plan");
105
+ assertPlanIntegrity(plan, status);
106
+ status.status = "queued";
107
+ status.updated_at = new Date().toISOString();
108
+ status.approved_at = status.updated_at;
109
+ status.approval = localOperator ? "local-operator" : "mcp";
110
+ status.cleanup_guarantee = "best-effort-finally-and-recovery";
111
+ atomicWriteJson(statusFile, status, 256 * 1024);
112
+ try {
113
+ launchRunner(dir);
114
+ } catch (error) {
115
+ failRunnerLaunch(dir, status, error);
116
+ throw error;
117
+ }
118
+ return {
119
+ accepted: true,
120
+ job_id: status.job_id,
121
+ name: status.name,
122
+ status: "queued",
123
+ detached: true,
124
+ continues_without_mcp_connection: true,
125
+ approval: status.approval,
126
+ plan_sha256: status.plan_sha256,
127
+ cleanup: {
128
+ resource_copies: "best-effort",
129
+ finally_steps: "best-effort-if-declared",
130
+ restart_recovery: "best-effort-on-next-runtime-or-cli-start",
131
+ },
132
+ };
133
+ } finally {
134
+ transition.release();
113
135
  }
114
- return {
115
- accepted: true,
116
- job_id: status.job_id,
117
- name: status.name,
118
- status: "queued",
119
- detached: true,
120
- continues_without_mcp_connection: true,
121
- approval: status.approval,
122
- cleanup: {
123
- resource_copies: "best-effort",
124
- finally_steps: "best-effort-if-declared",
125
- restart_recovery: "best-effort-on-next-runtime-or-cli-start",
126
- },
127
- };
128
136
  }
129
137
 
130
138
  createJob(args, { launch }) {
@@ -225,48 +233,55 @@ export class ManagedJobManager {
225
233
  this.reconcileStatus(dir);
226
234
  const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
227
235
  const plan = readJson(join(dir, "plan.json"), MAX_PLAN_BYTES);
236
+ if (plan) assertPlanIntegrity(plan, status);
228
237
  return {
229
238
  ...publicStatus(status),
239
+ plan_integrity_verified: Boolean(plan),
230
240
  ...(plan ? { review_plan: reviewablePlan(plan) } : {}),
231
241
  };
232
242
  }
233
243
 
234
244
  cancel(args = {}) {
235
245
  const dir = this.jobDir(args.job_id);
236
- this.reconcileStatus(dir);
237
- const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
238
- if (status.status === "staged") {
239
- const now = new Date().toISOString();
240
- status.status = "cancelled_before_start";
241
- status.updated_at = now;
242
- status.finished_at = now;
243
- status.error_class = "cancelled";
244
- status.cleanup_guarantee = "not-started";
245
- atomicWriteJson(join(dir, "status.json"), status, 256 * 1024);
246
- atomicWriteJson(join(dir, "result.json"), {
247
- job_id: status.job_id,
248
- name: status.name,
249
- status: status.status,
250
- steps: [],
251
- finally_steps: [],
252
- error_class: "cancelled",
253
- cleanup_error_class: null,
254
- finished_at: now,
255
- }, 4 * 1024 * 1024);
256
- scrubFinishedPlan(dir, status);
257
- return { ...publicStatus(status), cancellation_requested: true, cleanup_will_run: false, execution_started: false };
258
- }
259
- if (!ACTIVE_JOB_STATES.has(status.status)) {
260
- return { ...publicStatus(status), cancellation_requested: false, already_finished: true };
246
+ const transition = acquireJobTransitionLock(dir);
247
+ if (!transition) throw new Error("job state is being modified by another process; retry after inspecting its current status");
248
+ try {
249
+ this.reconcileStatus(dir);
250
+ const status = readRequiredJson(join(dir, "status.json"), 256 * 1024, "job status");
251
+ if (status.status === "staged") {
252
+ const now = new Date().toISOString();
253
+ status.status = "cancelled_before_start";
254
+ status.updated_at = now;
255
+ status.finished_at = now;
256
+ status.error_class = "cancelled";
257
+ status.cleanup_guarantee = "not-started";
258
+ atomicWriteJson(join(dir, "status.json"), status, 256 * 1024);
259
+ atomicWriteJson(join(dir, "result.json"), {
260
+ job_id: status.job_id,
261
+ name: status.name,
262
+ status: status.status,
263
+ steps: [],
264
+ finally_steps: [],
265
+ error_class: "cancelled",
266
+ cleanup_error_class: null,
267
+ finished_at: now,
268
+ }, 4 * 1024 * 1024);
269
+ scrubFinishedPlan(dir, status);
270
+ return { ...publicStatus(status), cancellation_requested: true, cleanup_will_run: false, execution_started: false };
271
+ }
272
+ if (!ACTIVE_JOB_STATES.has(status.status)) {
273
+ return { ...publicStatus(status), cancellation_requested: false, already_finished: true };
274
+ }
275
+ writeFileSync(join(dir, "cancel"), `${new Date().toISOString()}\n`, { mode: 0o600 });
276
+ return {
277
+ ...publicStatus(status),
278
+ cancellation_requested: true,
279
+ cancellation_delivery: "runner-poll",
280
+ cleanup_will_run: true,
281
+ };
282
+ } finally {
283
+ transition.release();
261
284
  }
262
- writeFileSync(join(dir, "cancel"), `${new Date().toISOString()}\n`, { mode: 0o600 });
263
- return {
264
- ...publicStatus(status),
265
- cancellation_requested: true,
266
- cancellation_delivery: "runner-poll",
267
- cleanup_will_run: true,
268
- };
269
-
270
285
  }
271
286
 
272
287
  currentResources() {
@@ -343,6 +358,7 @@ export class ManagedJobManager {
343
358
  status.recovery_attempts = recoveryAttempts + 1;
344
359
  atomicWriteJson(file, status, 256 * 1024);
345
360
  rmSync(join(dir, "runtime"), { recursive: true, force: true });
361
+ rmSync(join(dir, "runner.pid"), { force: true });
346
362
  const runnerPid = launchRunner(dir, true);
347
363
  recoveryLock.handoff(runnerPid);
348
364
  handedOff = true;
@@ -445,6 +461,7 @@ export function inspectResourceFile(inputPath, { allowInsecurePermissions = fals
445
461
  return {
446
462
  kind: "file",
447
463
  path: canonical,
464
+ pathAliases: normalizeResourcePathAliases([path, canonical]),
448
465
  size: info.size,
449
466
  mode: process.platform === "win32" ? null : `0${(info.mode & 0o777).toString(8)}`,
450
467
  updatedAt: new Date().toISOString(),
@@ -453,15 +470,16 @@ export function inspectResourceFile(inputPath, { allowInsecurePermissions = fals
453
470
  };
454
471
  }
455
472
 
456
- export function publicResourceRegistry(resources = {}) {
473
+ export function publicResourceRegistry(resources = {}, { includePaths = false } = {}) {
457
474
  const normalized = normalizeResourceRegistry(resources);
458
475
  return Object.fromEntries(Object.entries(normalized).map(([name, value]) => [name, {
459
476
  kind: value.kind,
460
- path: value.path,
461
477
  size: value.size ?? null,
462
478
  mode: value.mode ?? null,
463
479
  updatedAt: value.updatedAt ?? null,
464
480
  allowInsecurePermissions: value.allowInsecurePermissions === true,
481
+ paths_exposed: includePaths,
482
+ ...(includePaths ? { path: value.path } : {}),
465
483
  }]));
466
484
  }
467
485
 
@@ -580,7 +598,11 @@ function referencedResources(steps, registry) {
580
598
  const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true, includeHash: true });
581
599
  totalBytes += inspected.size;
582
600
  if (totalBytes > MAX_JOB_RESOURCE_BYTES) throw new Error(`job resources exceed ${MAX_JOB_RESOURCE_BYTES} bytes`);
583
- out[name] = { ...inspected, allowInsecurePermissions: resource.allowInsecurePermissions === true };
601
+ out[name] = {
602
+ ...inspected,
603
+ pathAliases: normalizeResourcePathAliases([...(resource.pathAliases || []), ...(inspected.pathAliases || [])]),
604
+ allowInsecurePermissions: resource.allowInsecurePermissions === true,
605
+ };
584
606
  }
585
607
  return out;
586
608
  }
@@ -594,6 +616,7 @@ function normalizeResourceRegistry(resources) {
594
616
  out[name] = {
595
617
  kind: "file",
596
618
  path: resolve(rawValue.path),
619
+ pathAliases: normalizeResourcePathAliases([rawValue.path, ...(Array.isArray(rawValue.pathAliases) ? rawValue.pathAliases : [])]),
597
620
  size: Number.isFinite(Number(rawValue.size)) ? Number(rawValue.size) : null,
598
621
  mode: rawValue.mode ?? null,
599
622
  updatedAt: rawValue.updatedAt ?? null,
@@ -603,6 +626,17 @@ function normalizeResourceRegistry(resources) {
603
626
  return out;
604
627
  }
605
628
 
629
+ function normalizeResourcePathAliases(values) {
630
+ const aliases = [];
631
+ for (const value of values) {
632
+ if (typeof value !== "string" || !value || value.includes("\0") || value.length > 4096) continue;
633
+ const absolute = resolve(value);
634
+ if (!aliases.includes(absolute)) aliases.push(absolute);
635
+ if (aliases.length >= 8) break;
636
+ }
637
+ return aliases;
638
+ }
639
+
606
640
  function resolveJobCwd(value, workspace, unrestrictedPaths) {
607
641
  const raw = boundedString(value, 4096, "cwd");
608
642
  const candidate = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw);
@@ -656,7 +690,42 @@ function acquireRecoveryLock(dir) {
656
690
  let owner = 0;
657
691
  try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
658
692
  const age = Date.now() - safeMtime(file);
659
- if ((owner > 0 && isPidAlive(owner)) || age < 60_000) return null;
693
+ const ownerAlive = owner > 0 && isPidAlive(owner);
694
+ const definitelyStale = age >= 5 * 60_000 || (!ownerAlive && age >= 60_000);
695
+ if (!definitelyStale) return null;
696
+ rmSync(file, { force: true });
697
+ }
698
+ }
699
+ return null;
700
+ }
701
+
702
+ function planSha256(plan) {
703
+ return createHash("sha256").update(JSON.stringify(plan)).digest("hex");
704
+ }
705
+
706
+ function assertPlanIntegrity(plan, status) {
707
+ const expected = String(status?.plan_sha256 || "");
708
+ const actual = planSha256(plan);
709
+ if (!/^[a-f0-9]{64}$/.test(expected) || actual !== expected) {
710
+ throw new Error("managed job plan integrity check failed; inspect the plan and do not approve it");
711
+ }
712
+ return actual;
713
+ }
714
+
715
+ function acquireJobTransitionLock(dir) {
716
+ const file = join(dir, "transition.lock");
717
+ for (let attempt = 0; attempt < 2; attempt += 1) {
718
+ try {
719
+ writeFileSync(file, `${process.pid}\n`, { mode: 0o600, flag: "wx" });
720
+ return { release() { rmSync(file, { force: true }); } };
721
+ } catch (error) {
722
+ if (error?.code !== "EEXIST") throw error;
723
+ let owner = 0;
724
+ try { owner = Number.parseInt(readBoundedFile(file, 64).toString("utf8").trim(), 10); } catch {}
725
+ const age = Date.now() - safeMtime(file);
726
+ const ownerAlive = owner > 0 && isPidAlive(owner);
727
+ const definitelyStale = age >= 5 * 60_000 || (!ownerAlive && age >= 60_000);
728
+ if (!definitelyStale) return null;
660
729
  rmSync(file, { force: true });
661
730
  }
662
731
  }
@@ -670,18 +739,20 @@ function launchRunner(dir, recover = false) {
670
739
  const stderrFile = join(dir, "runner.err.log");
671
740
  trimDiagnosticFile(stdoutFile);
672
741
  trimDiagnosticFile(stderrFile);
673
- const stdoutFd = openSync(stdoutFile, "a", 0o600);
674
- const stderrFd = openSync(stderrFile, "a", 0o600);
742
+ let stdoutFd;
743
+ let stderrFd;
675
744
  let child;
676
745
  try {
746
+ stdoutFd = openPrivateAppendFile(stdoutFile);
747
+ stderrFd = openPrivateAppendFile(stderrFile);
677
748
  child = spawn(process.execPath, args, {
678
749
  detached: true,
679
750
  stdio: ["ignore", stdoutFd, stderrFd],
680
751
  windowsHide: true,
681
752
  });
682
753
  } finally {
683
- closeSync(stdoutFd);
684
- closeSync(stderrFd);
754
+ if (stdoutFd !== undefined) closeSync(stdoutFd);
755
+ if (stderrFd !== undefined) closeSync(stderrFd);
685
756
  }
686
757
  ownerOnlyFile(stdoutFile);
687
758
  ownerOnlyFile(stderrFile);
@@ -704,6 +775,7 @@ function scrubFinishedPlan(dir, status) {
704
775
  rmSync(join(dir, "plan.json"), { force: true });
705
776
  rmSync(join(dir, "runner.pid"), { force: true });
706
777
  rmSync(join(dir, "recovery.lock"), { force: true });
778
+ rmSync(join(dir, "transition.lock"), { force: true });
707
779
  }
708
780
 
709
781
  function reviewablePlan(plan) {
@@ -786,10 +858,27 @@ function readBoundedFileWithInfo(file, maxBytes) {
786
858
  }
787
859
  }
788
860
 
861
+ function openPrivateAppendFile(file) {
862
+ if (existsSync(file)) {
863
+ const info = lstatSync(file);
864
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("runner diagnostic path must be a regular file and not a symbolic link");
865
+ }
866
+ const flags = Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND) | Number(fsConstants.O_NOFOLLOW || 0);
867
+ const fd = openSync(file, flags, 0o600);
868
+ try {
869
+ if (!fstatSync(fd).isFile()) throw new Error("runner diagnostic path is not a regular file");
870
+ chmodSync(file, 0o600);
871
+ return fd;
872
+ } catch (error) {
873
+ closeSync(fd);
874
+ throw error;
875
+ }
876
+ }
877
+
789
878
  function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
790
879
  let fd;
791
880
  try {
792
- const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
881
+ const flags = Number(fsConstants.O_RDWR) | Number(fsConstants.O_NOFOLLOW || 0);
793
882
  fd = openSync(file, flags);
794
883
  const info = fstatSync(fd);
795
884
  if (!info.isFile() || info.size <= maxBytes) return;
@@ -804,9 +893,9 @@ function trimDiagnosticFile(file, maxBytes = 64 * 1024, keepBytes = 32 * 1024) {
804
893
  let tail = buffer.subarray(0, offset);
805
894
  const newline = tail.indexOf(0x0a);
806
895
  if (newline >= 0 && newline < tail.length - 1) tail = tail.subarray(newline + 1);
807
- closeSync(fd);
808
- fd = undefined;
809
- writeFileSync(file, tail, { mode: 0o600 });
896
+ ftruncateSync(fd, 0);
897
+ if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
898
+ try { chmodSync(file, 0o600); } catch {}
810
899
  } catch {
811
900
  } finally {
812
901
  if (fd !== undefined) try { closeSync(fd); } catch {}
@@ -4,11 +4,11 @@ import { basename } from "node:path";
4
4
  import { executionEnv } from "./shell.mjs";
5
5
 
6
6
  export const MAX_COMMAND_BYTES = 64 * 1024;
7
- export const MAX_ARGV_ITEMS = 256;
8
- export const MAX_PROCESS_SESSIONS = 8;
9
- export const MAX_SESSION_OUTPUT_BYTES = 1024 * 1024;
10
- export const MAX_PROCESS_STDIN_BYTES = 64 * 1024;
11
- export const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
7
+ const MAX_ARGV_ITEMS = 256;
8
+ const MAX_PROCESS_SESSIONS = 8;
9
+ const MAX_SESSION_OUTPUT_BYTES = 1024 * 1024;
10
+ const MAX_PROCESS_STDIN_BYTES = 64 * 1024;
11
+ const PROCESS_SESSION_RETENTION_MS = 30 * 60 * 1000;
12
12
 
13
13
  export class ProcessSessionManager {
14
14
  constructor({ workspace, policy, runtimeDir, activeProcesses, callProcesses, resolveCwd, displayPath, throwIfCancelled }) {
@@ -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: [] };
@@ -72,13 +72,15 @@ export async function inspectSshKeyPair(privateKeyPath, publicKeyPath = `${priva
72
72
  timeoutMs: 15_000,
73
73
  maxOutputBytes: 64 * 1024,
74
74
  });
75
+ const fingerprintValue = fingerprint.stdout.trim().split(/\s+/)[1] || "";
76
+ if (!/^SHA256:[A-Za-z0-9+/=]+$/.test(fingerprintValue)) throw new Error("SSH key fingerprint output is invalid");
75
77
  return {
76
78
  created,
77
79
  privateKeyPath: resolve(privateKeyPath),
78
80
  publicKeyPath: resolve(publicKeyPath),
79
81
  privateMode: process.platform === "win32" ? null : `0${(privateInfo.mode & 0o777).toString(8)}`,
80
82
  publicMode: process.platform === "win32" ? null : `0${(publicInfo.mode & 0o777).toString(8)}`,
81
- fingerprint: fingerprint.stdout.trim(),
83
+ fingerprint: fingerprintValue,
82
84
  publicKeyType: publicLine.split(/\s+/, 1)[0],
83
85
  };
84
86
  }
@@ -112,7 +114,7 @@ async function safeLstat(path) {
112
114
  }
113
115
 
114
116
  function boundedComment(value) {
115
- const comment = String(value || "").replace(/[\r\n\0]/g, " ").trim();
117
+ const comment = String(value || "").replace(/[\r\n\0\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, " ").replace(/\s+/g, " ").trim();
116
118
  if (!comment) return "machine-mcp";
117
119
  if (Buffer.byteLength(comment) > 256) throw new Error("SSH key comment exceeds 256 bytes");
118
120
  return comment;