machine-bridge-mcp 0.11.1 → 0.12.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.
@@ -0,0 +1,143 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { performance } from "node:perf_hooks";
3
+
4
+ const COMMAND_TIMEOUT_MS = 3000;
5
+ const COMMAND_OUTPUT_BYTES = 256 * 1024;
6
+ const START_TIME_TOLERANCE_MS = 15_000;
7
+
8
+ export function isPidAlive(pid) {
9
+ const parsed = normalizePid(pid);
10
+ if (!parsed) return false;
11
+ try {
12
+ process.kill(parsed, 0);
13
+ return true;
14
+ } catch (error) {
15
+ return error?.code === "EPERM";
16
+ }
17
+ }
18
+
19
+ export function currentProcessStartTimeMs() {
20
+ const value = Number(performance.timeOrigin);
21
+ return Number.isFinite(value) && value > 0 ? value : Date.now();
22
+ }
23
+
24
+ export function processStartTimeMs(pid) {
25
+ const parsed = normalizePid(pid);
26
+ if (!parsed) return null;
27
+ if (parsed === process.pid) return currentProcessStartTimeMs();
28
+ if (process.platform === "win32") {
29
+ const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${parsed}").CreationDate.ToUniversalTime().ToString('o')`;
30
+ const result = runBounded("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command]);
31
+ return result.ok ? parseTime(result.stdout) : null;
32
+ }
33
+ const result = runBounded("ps", ["-p", String(parsed), "-o", "lstart="]);
34
+ return result.ok ? parseTime(result.stdout) : null;
35
+ }
36
+
37
+ export function processCommandLine(pid) {
38
+ const parsed = normalizePid(pid);
39
+ if (!parsed) return "";
40
+ if (process.platform === "win32") {
41
+ const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${parsed}").CommandLine`;
42
+ const result = runBounded("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command]);
43
+ return result.ok ? result.stdout.trim() : "";
44
+ }
45
+ const result = runBounded("ps", ["-ww", "-p", String(parsed), "-o", "command="]);
46
+ return result.ok ? result.stdout.trim() : "";
47
+ }
48
+
49
+ export function splitProcessCommandLine(value) {
50
+ const args = [];
51
+ let current = "";
52
+ let quote = "";
53
+ const text = String(value || "");
54
+ for (let index = 0; index < text.length; index += 1) {
55
+ const character = text[index];
56
+ if (quote) {
57
+ if (character === quote) quote = "";
58
+ else if (character === "\\" && quote === '"' && ['"', "\\"].includes(text[index + 1])) {
59
+ current += text[index + 1];
60
+ index += 1;
61
+ } else current += character;
62
+ continue;
63
+ }
64
+ if (character === '"' || character === "'") {
65
+ quote = character;
66
+ continue;
67
+ }
68
+ if (/\s/.test(character)) {
69
+ if (current) {
70
+ args.push(current);
71
+ current = "";
72
+ }
73
+ continue;
74
+ }
75
+ if (character === "\\" && /[\s'"\\]/.test(text[index + 1] || "")) {
76
+ current += text[index + 1];
77
+ index += 1;
78
+ continue;
79
+ }
80
+ current += character;
81
+ }
82
+ if (current) args.push(current);
83
+ return args;
84
+ }
85
+
86
+ export function inspectProcessInstance(owner, options = {}) {
87
+ const pid = normalizePid(owner?.pid);
88
+ if (!pid) return { current: false, alive: false, reclaimable: true, reason: "invalid_pid", pid: null };
89
+ const alive = (options.isAlive || isPidAlive)(pid);
90
+ if (!alive) return { current: false, alive: false, reclaimable: true, reason: "not_running", pid };
91
+
92
+ const now = Number.isFinite(options.now) ? Number(options.now) : Date.now();
93
+ const lockStartedAt = parseTime(owner?.startedAt);
94
+ if (!lockStartedAt) return { current: false, alive: true, reclaimable: false, reason: "invalid_lock_timestamp", pid };
95
+ if (lockStartedAt > now + START_TIME_TOLERANCE_MS) return { current: false, alive: true, reclaimable: false, reason: "future_lock_timestamp", pid };
96
+
97
+ const observedStart = (options.getProcessStartTime || processStartTimeMs)(pid);
98
+ const recordedStart = parseTime(owner?.processStartedAt);
99
+ if (Number.isFinite(observedStart) && observedStart > 0) {
100
+ if (recordedStart && Math.abs(observedStart - recordedStart) > START_TIME_TOLERANCE_MS) {
101
+ return { current: false, alive: true, reclaimable: true, reason: "pid_reused", pid, process_started_at: observedStart };
102
+ }
103
+ if (!recordedStart && lockStartedAt + START_TIME_TOLERANCE_MS < observedStart) {
104
+ return { current: false, alive: true, reclaimable: true, reason: "pid_reused", pid, process_started_at: observedStart };
105
+ }
106
+ }
107
+ const maxAgeMs = Number(options.maxAgeMs);
108
+ if (Number.isFinite(maxAgeMs) && maxAgeMs > 0 && now - lockStartedAt > maxAgeMs) {
109
+ return { current: false, alive: true, reclaimable: false, reason: "lock_expired", pid, age_ms: now - lockStartedAt };
110
+ }
111
+ return {
112
+ current: true,
113
+ alive: true,
114
+ reclaimable: false,
115
+ reason: "current_process",
116
+ pid,
117
+ age_ms: Math.max(0, now - lockStartedAt),
118
+ process_started_at: Number.isFinite(observedStart) ? observedStart : null,
119
+ };
120
+ }
121
+
122
+ function runBounded(command, args) {
123
+ const result = spawnSync(command, args, {
124
+ encoding: "utf8",
125
+ timeout: COMMAND_TIMEOUT_MS,
126
+ maxBuffer: COMMAND_OUTPUT_BYTES,
127
+ windowsHide: true,
128
+ env: process.platform === "win32" ? process.env : { ...process.env, LC_ALL: "C", LANG: "C" },
129
+ stdio: ["ignore", "pipe", "ignore"],
130
+ });
131
+ return { ok: !result.error && result.status === 0, stdout: String(result.stdout || "") };
132
+ }
133
+
134
+ function normalizePid(value) {
135
+ const parsed = Number(value);
136
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
137
+ }
138
+
139
+ function parseTime(value) {
140
+ if (typeof value === "number") return Number.isFinite(value) && value > 0 ? value : null;
141
+ const parsed = Date.parse(String(value || "").trim());
142
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
143
+ }
@@ -3,15 +3,14 @@ import { rm } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { inspectResourceFile, validateResourceName } from "./managed-jobs.mjs";
5
5
  import { generateSshKeyPair } from "./ssh-key.mjs";
6
- import { acquireStartupLock, loadState, saveState } from "./state.mjs";
6
+ import { acquireStartupLockWithWait, loadState, saveState } from "./state.mjs";
7
7
 
8
8
  export async function generateRegisteredSshKey({ workspace, stateDir, name: rawName, targetPath, comment = "" }) {
9
9
  const name = validateResourceName(rawName);
10
10
  if (typeof targetPath !== "string" || !targetPath.trim()) throw new Error("SSH private key target path is required");
11
11
  const target = resolve(targetPath);
12
12
  const state = loadState(workspace, { stateDir });
13
- const lock = acquireStartupLock(state);
14
- if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
13
+ const lock = await acquireStartupLockWithWait(state, { operation: "generate-ssh-key" });
15
14
  let key = null;
16
15
  try {
17
16
  state.resources ||= {};
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
3
+ import { constants as fsConstants, mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
4
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";
@@ -108,6 +108,7 @@ export class LocalRuntime {
108
108
  policy: this.policy,
109
109
  resources,
110
110
  resourceStatePath,
111
+ stateRoot: browserStateRoot,
111
112
  logger: this.logger,
112
113
  recover: recoverJobs,
113
114
  });
@@ -1101,7 +1102,8 @@ function assertContainedPath(root, target) {
1101
1102
  }
1102
1103
 
1103
1104
  async function readBoundedFile(filePath, maxBytes, label) {
1104
- const handle = await open(filePath, "r");
1105
+ const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
1106
+ const handle = await open(filePath, flags);
1105
1107
  try {
1106
1108
  const info = await handle.stat();
1107
1109
  if (!info.isFile()) throw new Error(`${label} is not a regular file`);
@@ -0,0 +1,56 @@
1
+ export async function stopAndRemoveAutostart({
2
+ states = [],
3
+ stateRoot,
4
+ logger = console,
5
+ reason = "service uninstall",
6
+ stopAutostart,
7
+ uninstallAutostart,
8
+ stopWorkspaceServiceDaemon,
9
+ } = {}) {
10
+ assertFunction(stopAutostart, "stopAutostart");
11
+ assertFunction(uninstallAutostart, "uninstallAutostart");
12
+ assertFunction(stopWorkspaceServiceDaemon, "stopWorkspaceServiceDaemon");
13
+
14
+ const platformStop = await stopAutostart({ logger });
15
+ if (platformStop?.ok !== true) {
16
+ return {
17
+ ok: false,
18
+ removed: false,
19
+ reason: "platform_stop_failed",
20
+ platform_stop: platformStop,
21
+ workspace_daemons: [],
22
+ removal: null,
23
+ };
24
+ }
25
+
26
+ const workspaceDaemons = [];
27
+ for (const state of states) {
28
+ const result = await stopWorkspaceServiceDaemon(state, { logger, reason });
29
+ workspaceDaemons.push({ workspace: state?.workspace?.path || null, ...result });
30
+ if (result.found && !result.ok) {
31
+ return {
32
+ ok: false,
33
+ removed: false,
34
+ reason: "workspace_daemon_stop_failed",
35
+ platform_stop: platformStop,
36
+ workspace_daemons: workspaceDaemons,
37
+ removal: null,
38
+ };
39
+ }
40
+ }
41
+
42
+ const removal = await uninstallAutostart({ stateRoot, logger });
43
+ const removed = removal?.ok === true;
44
+ return {
45
+ ok: removed,
46
+ removed,
47
+ reason: removed ? "removed" : "platform_remove_failed",
48
+ platform_stop: platformStop,
49
+ workspace_daemons: workspaceDaemons,
50
+ removal,
51
+ };
52
+ }
53
+
54
+ function assertFunction(value, name) {
55
+ if (typeof value !== "function") throw new Error(`${name} is required`);
56
+ }
@@ -1,9 +1,9 @@
1
- import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
1
+ import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, rmSync, statSync, 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
5
  import { ensureOwnerOnlyDir, expandHome, ownerOnlyFile } from "./state.mjs";
6
- import { replaceFileSync } from "./atomic-fs.mjs";
6
+ import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
7
7
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
8
8
 
9
9
  const LABEL = "dev.machine-bridge-mcp.daemon";
@@ -40,17 +40,34 @@ export async function autostartStatus({ logger = console } = {}) {
40
40
 
41
41
  export async function startAutostart({ logger = console } = {}) {
42
42
  if (process.platform === "darwin") return startLaunchd(logger);
43
- if (process.platform === "win32") return serviceRun("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
44
- return serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]);
43
+ if (process.platform === "win32") {
44
+ return normalizeServiceCommandResult("schtasks", await serviceRun("schtasks", ["/Run", "/TN", WINDOWS_TASK]));
45
+ }
46
+ return normalizeServiceCommandResult("systemd", await serviceRun("systemctl", ["--user", "start", "machine-bridge-mcp.service"]));
45
47
  }
46
48
 
47
49
  export async function stopAutostart({ logger = console } = {}) {
48
50
  if (process.platform === "darwin") return stopLaunchd(logger);
49
- if (process.platform === "win32") return serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]);
50
- return serviceRun("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]);
51
+ if (process.platform === "win32") {
52
+ return normalizeServiceCommandResult("schtasks", await serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]), { allowAlreadyStopped: true });
53
+ }
54
+ return normalizeServiceCommandResult("systemd", await serviceRun("systemctl", ["--user", "stop", "machine-bridge-mcp.service"]), { allowAlreadyStopped: true });
51
55
  }
52
56
 
53
57
 
58
+
59
+ export function normalizeServiceCommandResult(provider, result, { allowAlreadyStopped = false } = {}) {
60
+ const detail = `${result?.stdout || ""}
61
+ ${result?.stderr || ""}`;
62
+ const alreadyStopped = allowAlreadyStopped && /(?:not loaded|not found|does not exist|cannot find|not running|inactive)/i.test(detail);
63
+ return {
64
+ ...result,
65
+ ok: result?.code === 0 || alreadyStopped,
66
+ provider,
67
+ already_stopped: alreadyStopped,
68
+ };
69
+ }
70
+
54
71
  export function trimAutostartLogs(stateRoot, options = {}) {
55
72
  const root = expandHome(stateRoot);
56
73
  const maxBytes = Number.isFinite(Number(options.maxBytes)) ? Math.max(1024, Number(options.maxBytes)) : 2 * 1024 * 1024;
@@ -233,15 +250,8 @@ function writePrivateServiceFile(file, content) {
233
250
  const info = lstatSync(file);
234
251
  if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart configuration path must be a regular non-symbolic-link file");
235
252
  }
236
- const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
237
- try {
238
- writeFileSync(temporary, content, { mode: 0o600, flag: "wx" });
239
- replaceFileSync(temporary, file);
240
- ownerOnlyFile(file);
241
- } catch (error) {
242
- try { rmSync(temporary, { force: true }); } catch {}
243
- throw error;
244
- }
253
+ replaceFileAtomicallySync(file, content, { mode: 0o600 });
254
+ ownerOnlyFile(file);
245
255
  }
246
256
 
247
257
  function launchdPlistPath() {
@@ -379,17 +389,28 @@ async function installSystemd(spec, logger) {
379
389
  const reload = await serviceRun("systemctl", ["--user", "daemon-reload"]);
380
390
  const enable = await serviceRun("systemctl", ["--user", "enable", "machine-bridge-mcp.service"]);
381
391
  const linger = await serviceRun("loginctl", ["enable-linger", os.userInfo().username]);
382
- logger.info?.("Autostart installed.");
383
- return { ok: reload.code === 0 && enable.code === 0, provider: "systemd", path: servicePath, reload, enable, linger };
392
+ const ok = reload.code === 0 && enable.code === 0;
393
+ if (ok) logger.info?.("Autostart installed.");
394
+ else logger.warn?.("Autostart installation failed; the service definition was kept for diagnosis.");
395
+ return { ok, provider: "systemd", path: servicePath, reload, enable, linger };
384
396
  }
385
397
 
386
398
  async function uninstallSystemd(logger) {
387
- await serviceRun("systemctl", ["--user", "disable", "--now", "machine-bridge-mcp.service"]);
388
399
  const servicePath = systemdPath();
400
+ const disable = await serviceRun("systemctl", ["--user", "disable", "--now", "machine-bridge-mcp.service"]);
401
+ const activeCheck = await serviceRun("systemctl", ["--user", "is-active", "machine-bridge-mcp.service"]);
402
+ const active = activeCheck.code === 0;
403
+ if (active) {
404
+ logger.warn?.("systemd service is still active; its definition was not removed.");
405
+ return { ok: false, provider: "systemd", path: servicePath, disable, active_check: activeCheck, active: true };
406
+ }
389
407
  if (existsSync(servicePath)) rmSync(servicePath, { force: true });
390
- await serviceRun("systemctl", ["--user", "daemon-reload"]);
391
- logger.info?.("Autostart removed.");
392
- return { ok: true, provider: "systemd", path: servicePath };
408
+ const reload = await serviceRun("systemctl", ["--user", "daemon-reload"]);
409
+ const ok = reload.code === 0 && (disable.code === 0 || /not loaded|not found|does not exist/i.test(`${disable.stdout}
410
+ ${disable.stderr}`));
411
+ if (ok) logger.info?.("Autostart removed.");
412
+ else logger.warn?.("Autostart removal reported an error.");
413
+ return { ok, provider: "systemd", path: servicePath, disable, active_check: activeCheck, reload, active: false };
393
414
  }
394
415
 
395
416
  async function statusSystemd() {
@@ -421,14 +442,21 @@ WantedBy=default.target
421
442
  async function installWindowsTask(spec, logger) {
422
443
  const command = windowsCommand(spec);
423
444
  const result = await serviceRun("schtasks", ["/Create", "/TN", WINDOWS_TASK, "/SC", "ONLOGON", "/TR", command, "/F"]);
424
- logger.info?.("Windows Scheduled Task installed for logon");
425
- return { ok: result.code === 0, provider: "schtasks", task: WINDOWS_TASK, result };
445
+ const ok = result.code === 0;
446
+ if (ok) logger.info?.("Windows Scheduled Task installed for logon");
447
+ else logger.warn?.("Windows Scheduled Task installation failed");
448
+ return { ok, provider: "schtasks", task: WINDOWS_TASK, result };
426
449
  }
427
450
 
428
451
  async function uninstallWindowsTask(logger) {
452
+ const end = await serviceRun("schtasks", ["/End", "/TN", WINDOWS_TASK]);
429
453
  const result = await serviceRun("schtasks", ["/Delete", "/TN", WINDOWS_TASK, "/F"]);
430
- logger.info?.("Windows Scheduled Task removed");
431
- return { ok: result.code === 0 || /cannot find/i.test(result.stderr), provider: "schtasks", task: WINDOWS_TASK, result };
454
+ const missing = /cannot find|not exist|not found/i.test(`${result.stdout}
455
+ ${result.stderr}`);
456
+ const ok = result.code === 0 || missing;
457
+ if (ok) logger.info?.("Windows Scheduled Task removed");
458
+ else logger.warn?.("Windows Scheduled Task removal failed");
459
+ return { ok, provider: "schtasks", task: WINDOWS_TASK, end, result };
432
460
  }
433
461
 
434
462
  async function statusWindowsTask() {
@@ -12,6 +12,7 @@ export function run(command, args = [], options = {}) {
12
12
  env: options.env || process.env,
13
13
  stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
14
14
  shell: false,
15
+ detached: process.platform !== "win32",
15
16
  windowsHide: true,
16
17
  });
17
18
  let stdout = "";
@@ -26,9 +27,8 @@ export function run(command, args = [], options = {}) {
26
27
  if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
27
28
  timer = setTimeout(() => {
28
29
  timedOut = true;
29
- try { child.kill("SIGTERM"); } catch {}
30
- killTimer = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 2000);
31
- killTimer.unref?.();
30
+ terminateCommandTree(child, false);
31
+ killTimer = setTimeout(() => terminateCommandTree(child, true), 2000);
32
32
  }, timeoutMs);
33
33
  timer.unref?.();
34
34
  }
@@ -36,7 +36,7 @@ export function run(command, args = [], options = {}) {
36
36
  if (settled) return;
37
37
  settled = true;
38
38
  if (timer) clearTimeout(timer);
39
- if (killTimer) clearTimeout(killTimer);
39
+ if (killTimer && !timedOut) clearTimeout(killTimer);
40
40
  callback();
41
41
  };
42
42
  if (capture) {
@@ -73,6 +73,25 @@ export function run(command, args = [], options = {}) {
73
73
  });
74
74
  }
75
75
 
76
+
77
+ function terminateCommandTree(child, force) {
78
+ if (!child?.pid) return;
79
+ if (process.platform === "win32") {
80
+ try {
81
+ const killer = spawn("taskkill.exe", ["/PID", String(child.pid), "/T", ...(force ? ["/F"] : [])], {
82
+ stdio: "ignore",
83
+ windowsHide: true,
84
+ });
85
+ killer.unref();
86
+ return;
87
+ } catch {}
88
+ }
89
+ const signal = force ? "SIGKILL" : "SIGTERM";
90
+ try { process.kill(-child.pid, signal); } catch {
91
+ try { child.kill(signal); } catch {}
92
+ }
93
+ }
94
+
76
95
  function appendLimited(current, chunk, max) {
77
96
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk ?? ""));
78
97
  const budget = Math.max(0, max - Buffer.byteLength(current));