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.
package/src/local/cli.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
3
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
4
4
  import path, { join, resolve } from "node:path";
5
5
  import process from "node:process";
6
6
  import readline from "node:readline/promises";
@@ -14,9 +14,12 @@ import { runWrangler } from "./shell.mjs";
14
14
  import { generateRegisteredSshKey } from "./resource-operations.mjs";
15
15
  import { runFullAccessTest } from "./full-access-test.mjs";
16
16
  import { readBoundedRegularFileSync } from "./secure-file.mjs";
17
+ import { inspectProcessInstance } from "./process-identity.mjs";
18
+ import { stopAndRemoveAutostart } from "./service-lifecycle.mjs";
19
+ import { createExclusiveFileSync } from "./exclusive-file.mjs";
17
20
  import {
18
- acquireDaemonLock,
19
- acquireStartupLock,
21
+ acquireMaintenanceLock,
22
+ acquireStartupLockWithWait,
20
23
  appName,
21
24
  daemonLockPathForState,
22
25
  defaultStateRoot,
@@ -314,11 +317,7 @@ async function startCommand(args) {
314
317
  const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), component: "cli" });
315
318
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
316
319
  const state = loadState(workspace, { stateDir: args.stateDir });
317
- const startupLock = acquireStartupLock(state);
318
- if (!startupLock.acquired) {
319
- const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
320
- throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
321
- }
320
+ const startupLock = await acquireStartupLockWithWait(state, { operation: "start", logger });
322
321
 
323
322
  try {
324
323
  const startMode = await prepareStartMode(args, state, logger);
@@ -372,9 +371,9 @@ function reportExistingDaemon(args, state, owner, logger) {
372
371
  return;
373
372
  }
374
373
  if (owner?.mode === "foreground") {
375
- logger.plain(" Stop the existing foreground process with Ctrl+C in its terminal, then retry.");
374
+ logger.safePlain(" Stop the existing foreground process with Ctrl+C in its terminal, then retry.");
376
375
  } else {
377
- logger.plain(" Run `machine-mcp service stop`, verify `machine-mcp service status`, then retry.");
376
+ logger.safePlain(" Run `machine-mcp service stop`, verify `machine-mcp service status`, then retry.");
378
377
  }
379
378
  logger.plain(` Workspace: ${state.workspace.path}`);
380
379
  }
@@ -603,12 +602,11 @@ function resourceListAction({ args, workspace, state }) {
603
602
  }
604
603
  }
605
604
 
606
- function resourceAddAction({ args, workspace, state }) {
605
+ async function resourceAddAction({ args, workspace, state }) {
607
606
  const name = validateResourceName(args._[1]);
608
607
  const inputPath = args._[2];
609
608
  if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
610
- const lock = acquireStartupLock(state);
611
- if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
609
+ const lock = await acquireStartupLockWithWait(state, { operation: "resource-add" });
612
610
  try {
613
611
  const latest = loadState(workspace, { stateDir: args.stateDir });
614
612
  latest.resources ||= {};
@@ -672,10 +670,9 @@ async function resourceGenerateSshKeyAction({ args, workspace }) {
672
670
  }
673
671
  }
674
672
 
675
- function resourceRemoveAction({ args, workspace, state }) {
673
+ async function resourceRemoveAction({ args, workspace, state }) {
676
674
  const name = validateResourceName(args._[1]);
677
- const lock = acquireStartupLock(state);
678
- if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
675
+ const lock = await acquireStartupLockWithWait(state, { operation: "resource-remove" });
679
676
  try {
680
677
  const latest = loadState(workspace, { stateDir: args.stateDir });
681
678
  latest.resources ||= {};
@@ -803,6 +800,7 @@ async function jobCommand(args) {
803
800
  policy: resolvePolicy({}, state.policy),
804
801
  resources: state.resources,
805
802
  resourceStatePath: state.paths.statePath,
803
+ stateRoot: state.paths.stateRoot,
806
804
  logger: createLogger({ level: "warn", component: "job" }),
807
805
  });
808
806
  let result;
@@ -922,7 +920,7 @@ async function withSecretsFile(state, callback) {
922
920
  DAEMON_SHARED_SECRET: state.worker.daemonSecret,
923
921
  OAUTH_TOKEN_VERSION: state.worker.oauthTokenVersion,
924
922
  };
925
- writeFileSync(tempPath, JSON.stringify(payload), { mode: 0o600 });
923
+ createExclusiveFileSync(tempPath, JSON.stringify(payload), { mode: 0o600 });
926
924
  ownerOnlyFile(tempPath);
927
925
  try {
928
926
  return await callback(tempPath);
@@ -931,7 +929,7 @@ async function withSecretsFile(state, callback) {
931
929
  }
932
930
  }
933
931
 
934
- function cleanupStaleSecretFiles(dir) {
932
+ export function cleanupStaleSecretFiles(dir) {
935
933
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
936
934
  if (!entry.isFile()) continue;
937
935
  const match = /^worker-secrets-(\d+)-(\d+)(?:-[a-f0-9]+)?\.json$/.exec(entry.name);
@@ -939,8 +937,9 @@ function cleanupStaleSecretFiles(dir) {
939
937
  const file = resolve(dir, entry.name);
940
938
  try {
941
939
  const pid = Number(match[1]);
942
- const ageMs = Date.now() - statSync(file).mtimeMs;
943
- if (!isPidAlive(pid) || ageMs > 60 * 60 * 1000) unlinkSync(file);
940
+ const createdAt = Number(match[2]);
941
+ const identity = inspectProcessInstance({ pid, startedAt: new Date(createdAt).toISOString() });
942
+ if (!identity.current) unlinkSync(file);
944
943
  } catch {}
945
944
  }
946
945
  }
@@ -1074,13 +1073,13 @@ function printMcpConnection(state, {
1074
1073
  else logger.plain(` MCP connection password: ${previewSecret(payload.mcp_connection_password)} (redacted)`);
1075
1074
  } else {
1076
1075
  logger.success("Remote MCP bridge is ready");
1077
- logger.plain(" Connection credentials unchanged; use --print-mcp-credentials only when reconnecting a client.");
1076
+ logger.safePlain(" Connection credentials unchanged; use --print-mcp-credentials only when reconnecting a client.");
1078
1077
  }
1079
1078
  if (policyMigrated) {
1080
1079
  logger.warn("Legacy implicit policy migrated to full access; use --profile agent, edit, or review to narrow it.");
1081
1080
  }
1082
1081
  logger.plain(` Workspace: ${payload.workspace}`);
1083
- logger.plain(` Policy: ${formatPolicySummary(payload.policy)}`);
1082
+ logger.safePlain(` Policy: ${formatPolicySummary(payload.policy)}`);
1084
1083
  if (verbose) logger.plain(` State: ${payload.state_path}`);
1085
1084
  }
1086
1085
 
@@ -1186,16 +1185,19 @@ async function fullTestCommand(args) {
1186
1185
  async function rotateSecretsCommand(args) {
1187
1186
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
1188
1187
  const state = loadState(workspace, { stateDir: args.stateDir });
1189
- const startupLock = acquireStartupLock(state);
1190
- if (!startupLock.acquired) {
1191
- const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
1192
- throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
1193
- }
1188
+ const operationLogger = createLogger({ level: args.quiet ? "error" : "warn", component: "service" });
1189
+ const startupLock = await acquireStartupLockWithWait(state, { operation: "rotate-secrets", logger: operationLogger });
1194
1190
  try {
1195
- await stopAutostartBestEffort(createLogger({ level: args.quiet ? "error" : "warn", component: "service" }));
1196
- await sleep(500);
1191
+ await stopAutostartBestEffort(operationLogger);
1192
+ const stopped = await stopWorkspaceServiceDaemon(state, { logger: operationLogger, reason: "secret rotation" });
1193
+ if (stopped.found && !stopped.ok) {
1194
+ const pid = stopped.pid ? `pid ${stopped.pid}` : "unknown pid";
1195
+ throw new Error(`refusing to rotate secrets while a daemon cannot be safely stopped (${pid}; ${stopped.reason})`);
1196
+ }
1197
+ await sleep(100);
1197
1198
  const daemonOwner = readDaemonLockOwner(daemonLockPathForState(state));
1198
- if (daemonOwner?.pid && isPidAlive(daemonOwner.pid)) {
1199
+ const daemonIdentity = daemonOwner ? inspectProcessInstance(daemonOwner) : null;
1200
+ if (daemonIdentity?.current) {
1199
1201
  throw new Error(`refusing to rotate secrets while the daemon is active (pid ${daemonOwner.pid}); stop the foreground daemon and retry`);
1200
1202
  }
1201
1203
  ensureWorkerSecrets(state, { rotateSecrets: true, workerName: validateWorkerName(args.workerName) });
@@ -1235,11 +1237,13 @@ async function serviceCommand(args) {
1235
1237
  }
1236
1238
  const result = await installAutostart({ workspace, stateRoot, entryScript: process.argv[1], logger: structuredLogger(Boolean(args.quiet)) });
1237
1239
  console.log(JSON.stringify(result, null, 2));
1240
+ if (result?.ok === false) process.exitCode = 1;
1238
1241
  return;
1239
1242
  }
1240
1243
  if (action === "start") {
1241
1244
  const result = await startAutostart({ logger: structuredLogger(Boolean(args.quiet)) });
1242
1245
  console.log(JSON.stringify(result, null, 2));
1246
+ if (result?.ok === false) process.exitCode = 1;
1243
1247
  return;
1244
1248
  }
1245
1249
  if (action === "stop") {
@@ -1261,16 +1265,21 @@ async function serviceCommand(args) {
1261
1265
  }
1262
1266
  if (action === "uninstall" || action === "remove") {
1263
1267
  const logger = structuredLogger(Boolean(args.quiet));
1264
- const result = await uninstallAutostart({ stateRoot, logger });
1265
1268
  const state = optionalServiceState(args, stateRoot);
1266
- const workspaceDaemon = state
1267
- ? await stopWorkspaceServiceDaemon(state, { logger, reason: "service uninstall" })
1268
- : { ok: true, found: false, stopped: false, verified_service_daemon: false, reason: "workspace_not_selected" };
1269
+ const lifecycle = await stopAndRemoveAutostart({
1270
+ states: state ? [state] : [],
1271
+ stateRoot,
1272
+ logger,
1273
+ reason: "service uninstall",
1274
+ stopAutostart,
1275
+ uninstallAutostart,
1276
+ stopWorkspaceServiceDaemon,
1277
+ });
1269
1278
  const output = {
1270
- ...result,
1271
- ok: result?.ok !== false && workspaceDaemon.ok,
1279
+ ...lifecycle,
1272
1280
  workspace: state?.workspace?.path || null,
1273
- workspace_daemon: workspaceDaemon,
1281
+ workspace_daemon: lifecycle.workspace_daemons[0] || null,
1282
+ autostart_removed: lifecycle.removed,
1274
1283
  };
1275
1284
  console.log(JSON.stringify(output, null, 2));
1276
1285
  if (!output.ok) process.exitCode = 1;
@@ -1315,7 +1324,7 @@ async function stopAutostartBestEffort(logger) {
1315
1324
  async function uninstallCommand(args) {
1316
1325
  const stateRoot = stateRootFromArgs(args);
1317
1326
  const deleteRemote = !args.keepWorker;
1318
- validateStateRootForRemoval(stateRoot);
1327
+ const validation = validateStateRootForRemoval(stateRoot);
1319
1328
  const action = deleteRemote
1320
1329
  ? `delete deployed Worker(s), remove autostart entries, and remove local state at ${stateRoot}`
1321
1330
  : `remove autostart entries and local state at ${stateRoot} while keeping deployed Worker(s)`;
@@ -1324,26 +1333,46 @@ async function uninstallCommand(args) {
1324
1333
  console.log("Uninstall cancelled. Re-run with `machine-mcp uninstall --yes` to skip confirmation.");
1325
1334
  return;
1326
1335
  }
1336
+ const currentValidation = validateStateRootForRemoval(stateRoot);
1337
+ const maintenance = currentValidation.exists ? acquireMaintenanceLock(stateRoot, { operation: "uninstall" }) : null;
1338
+ if (maintenance && !maintenance.acquired) {
1339
+ const pid = maintenance.owner?.pid ? `pid ${maintenance.owner.pid}` : "another process";
1340
+ throw new Error(`another state maintenance operation is active (${pid})`);
1341
+ }
1342
+ try {
1343
+ if (currentValidation.exists) validateStateRootForRemoval(stateRoot);
1344
+ assertNoActiveJobsForUninstall(stateRoot);
1345
+ const autostartRemoved = await removeAutostartBestEffort(stateRoot);
1346
+ if (!autostartRemoved) throw new Error("autostart removal failed; state and Worker were kept so the uninstall can be retried safely");
1347
+ await sleep(100);
1348
+ assertNoActiveJobsForUninstall(stateRoot);
1349
+ assertNoActiveLocksForUninstall(stateRoot);
1350
+ if (deleteRemote) await deleteKnownWorkers(stateRoot);
1351
+ assertNoActiveJobsForUninstall(stateRoot);
1352
+ assertNoActiveLocksForUninstall(stateRoot);
1353
+ removeStateRoot(stateRoot);
1354
+ console.log("Removed local autostart entries and state.");
1355
+ if (deleteRemote) console.log("Requested deletion for known deployed Worker(s).");
1356
+ console.log("If installed globally, remove the npm package with:");
1357
+ console.log(" npm uninstall -g machine-bridge-mcp");
1358
+ } finally {
1359
+ maintenance?.release?.();
1360
+ }
1361
+ }
1362
+
1363
+ function assertNoActiveJobsForUninstall(stateRoot) {
1327
1364
  const activeJobs = activeStateJobs(stateRoot);
1328
- if (activeJobs.length) {
1329
- const detail = activeJobs.slice(0, 5).map((item) => `${item.job_id}:${item.status}`).join(", ");
1330
- const suffix = activeJobs.length > 5 ? `, and ${activeJobs.length - 5} more` : "";
1331
- throw new Error(`refusing to uninstall while managed jobs are active (${detail}${suffix}); inspect or cancel them with machine-mcp job list/cancel`);
1332
- }
1333
- const autostartRemoved = await removeAutostartBestEffort(stateRoot);
1334
- if (!autostartRemoved) throw new Error("autostart removal failed; state and Worker were kept so the uninstall can be retried safely");
1335
- await sleep(500);
1365
+ if (!activeJobs.length) return;
1366
+ const detail = activeJobs.slice(0, 5).map((item) => `${item.job_id}:${item.status}`).join(", ");
1367
+ const suffix = activeJobs.length > 5 ? `, and ${activeJobs.length - 5} more` : "";
1368
+ throw new Error(`refusing to uninstall while managed jobs are active (${detail}${suffix}); inspect or cancel them with machine-mcp job list/cancel`);
1369
+ }
1370
+
1371
+ function assertNoActiveLocksForUninstall(stateRoot) {
1336
1372
  const activeLocks = activeStateLocks(stateRoot);
1337
- if (activeLocks.length) {
1338
- const detail = activeLocks.map((item) => `${item.kind}:${item.pid || "unknown"}`).join(", ");
1339
- throw new Error(`refusing to uninstall while Machine Bridge processes are active (${detail}); stop foreground sessions and retry`);
1340
- }
1341
- if (deleteRemote) await deleteKnownWorkers(stateRoot);
1342
- removeStateRoot(stateRoot);
1343
- console.log("Removed local autostart entries and state.");
1344
- if (deleteRemote) console.log("Requested deletion for known deployed Worker(s).");
1345
- console.log("If installed globally, remove the npm package with:");
1346
- console.log(" npm uninstall -g machine-bridge-mcp");
1373
+ if (!activeLocks.length) return;
1374
+ const detail = activeLocks.map((item) => `${item.kind}:${item.pid || "unknown"}`).join(", ");
1375
+ throw new Error(`refusing to uninstall while Machine Bridge processes are active (${detail}); stop foreground sessions and retry`);
1347
1376
  }
1348
1377
 
1349
1378
  async function deleteKnownWorkers(stateRoot) {
@@ -1367,31 +1396,52 @@ async function deleteKnownWorkers(stateRoot) {
1367
1396
  }
1368
1397
  }
1369
1398
 
1370
- function knownWorkerNames(stateRoot) {
1399
+ export function knownWorkerNames(stateRoot) {
1371
1400
  const profiles = resolve(expandHome(stateRoot), "profiles");
1372
1401
  if (!existsSync(profiles)) return [];
1373
1402
  const names = new Set();
1374
1403
  for (const entry of readdirSync(profiles, { withFileTypes: true })) {
1375
- if (!entry.isDirectory()) continue;
1376
- const stateFile = resolve(profiles, entry.name, "state.json");
1377
- if (!existsSync(stateFile)) continue;
1404
+ if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
1405
+ const profileDir = resolve(profiles, entry.name);
1406
+ const stateFile = resolve(profileDir, "state.json");
1407
+ if (!existsSync(stateFile)) {
1408
+ const evidence = readdirSync(profileDir).some((name) => /^state\.json\.corrupt-/.test(name) || name === "daemon.lock");
1409
+ if (evidence) throw new Error(`cannot determine deployed Worker from profile ${entry.name}; local state was kept for inspection`);
1410
+ continue;
1411
+ }
1412
+ let state;
1378
1413
  try {
1379
- if (statSync(stateFile).size > 2 * 1024 * 1024) continue;
1380
- const state = JSON.parse(readFileSync(stateFile, "utf8"));
1381
- const name = String(state?.worker?.name || "");
1382
- if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) names.add(name);
1383
- } catch {}
1414
+ state = JSON.parse(readBoundedRegularFileSync(stateFile, 2 * 1024 * 1024).toString("utf8"));
1415
+ } catch {
1416
+ throw new Error(`cannot determine deployed Worker from profile ${entry.name}; local state was kept for inspection`);
1417
+ }
1418
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
1419
+ throw new Error(`cannot determine deployed Worker from profile ${entry.name}; local state was kept for inspection`);
1420
+ }
1421
+ const name = String(state?.worker?.name || "");
1422
+ if (name && !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) {
1423
+ throw new Error(`profile ${entry.name} contains an invalid Worker name; local state was kept for inspection`);
1424
+ }
1425
+ if (name) names.add(name);
1384
1426
  }
1385
1427
  return [...names];
1386
1428
  }
1387
1429
 
1388
1430
  async function removeAutostartBestEffort(stateRoot) {
1431
+ const logger = structuredLogger(false);
1389
1432
  try {
1390
1433
  const { stopAutostart, uninstallAutostart } = await import("./service.mjs");
1391
- await stopAutostart({ logger: structuredLogger(true) }).catch(() => {});
1392
- const result = await uninstallAutostart({ stateRoot, logger: structuredLogger(false) });
1393
- if (result?.ok === false) {
1394
- console.warn("Autostart removal reported failure.");
1434
+ const lifecycle = await stopAndRemoveAutostart({
1435
+ states: knownProfileStates(stateRoot),
1436
+ stateRoot,
1437
+ logger,
1438
+ reason: "uninstall",
1439
+ stopAutostart,
1440
+ uninstallAutostart,
1441
+ stopWorkspaceServiceDaemon,
1442
+ });
1443
+ if (!lifecycle.ok) {
1444
+ console.warn(`Autostart removal stopped at ${lifecycle.reason}; service definitions and state were kept.`);
1395
1445
  return false;
1396
1446
  }
1397
1447
  return true;
@@ -1401,6 +1451,46 @@ async function removeAutostartBestEffort(stateRoot) {
1401
1451
  }
1402
1452
  }
1403
1453
 
1454
+ export function knownProfileStates(stateRoot) {
1455
+ const canonicalStateRoot = resolve(expandHome(stateRoot));
1456
+ const profiles = resolve(canonicalStateRoot, "profiles");
1457
+ if (!existsSync(profiles)) return [];
1458
+ const states = [];
1459
+ const seen = new Set();
1460
+ for (const entry of readdirSync(profiles, { withFileTypes: true })) {
1461
+ if (!entry.isDirectory() || !/^[a-f0-9]{24}$/.test(entry.name)) continue;
1462
+ const profileDir = resolve(profiles, entry.name);
1463
+ const statePath = resolve(profileDir, "state.json");
1464
+ const candidates = [];
1465
+ if (existsSync(statePath)) {
1466
+ try {
1467
+ const value = JSON.parse(readBoundedRegularFileSync(statePath, 2 * 1024 * 1024).toString("utf8"));
1468
+ if (typeof value?.workspace?.path === "string") candidates.push(value.workspace.path);
1469
+ } catch {}
1470
+ }
1471
+ const daemonLock = resolve(profileDir, "daemon.lock");
1472
+ const daemonOwner = readDaemonLockOwner(daemonLock);
1473
+ if (existsSync(daemonLock) && !daemonOwner) {
1474
+ throw new Error(`cannot inspect daemon lock for profile ${entry.name}; service definitions and state were kept`);
1475
+ }
1476
+ if (typeof daemonOwner?.workspace === "string") candidates.push(daemonOwner.workspace);
1477
+ for (const candidate of candidates) {
1478
+ try {
1479
+ const workspace = resolveWorkspace(candidate);
1480
+ if (seen.has(workspace)) break;
1481
+ states.push({
1482
+ schemaVersion: 5,
1483
+ workspace: { path: workspace, hash: entry.name },
1484
+ paths: { stateRoot: canonicalStateRoot, profileDir, statePath },
1485
+ });
1486
+ seen.add(workspace);
1487
+ break;
1488
+ } catch {}
1489
+ }
1490
+ }
1491
+ return states;
1492
+ }
1493
+
1404
1494
  function activeStateJobs(stateRoot) {
1405
1495
  const profiles = resolve(expandHome(stateRoot), "profiles");
1406
1496
  if (!existsSync(profiles)) return [];
@@ -1424,23 +1514,17 @@ function activeStateLocks(stateRoot) {
1424
1514
  const lockPath = resolve(profiles, profile.name, name);
1425
1515
  if (!existsSync(lockPath)) continue;
1426
1516
  const owner = readDaemonLockOwner(lockPath);
1427
- if (owner?.pid && isPidAlive(owner.pid)) active.push({ kind, pid: owner.pid, path: lockPath });
1517
+ if (!owner) {
1518
+ active.push({ kind, pid: null, path: lockPath, reason: "invalid_or_unreadable_lock" });
1519
+ continue;
1520
+ }
1521
+ const identity = inspectProcessInstance(owner, { maxAgeMs: kind === "startup" ? 2 * 60 * 60 * 1000 : Number.POSITIVE_INFINITY });
1522
+ if (identity.current || (identity.alive && !identity.reclaimable)) active.push({ kind, pid: owner.pid, path: lockPath, reason: identity.reason });
1428
1523
  }
1429
1524
  }
1430
1525
  return active;
1431
1526
  }
1432
1527
 
1433
- function isPidAlive(pid) {
1434
- const parsed = Number(pid);
1435
- if (!Number.isInteger(parsed) || parsed <= 0) return false;
1436
- try {
1437
- process.kill(parsed, 0);
1438
- return true;
1439
- } catch (error) {
1440
- return error?.code === "EPERM";
1441
- }
1442
- }
1443
-
1444
1528
  function structuredLogger(quiet) {
1445
1529
  return createLogger({ quiet, component: "service" });
1446
1530
  }
@@ -1,4 +1,3 @@
1
- import { spawnSync } from "node:child_process";
2
1
  import path from "node:path";
3
2
  import process from "node:process";
4
3
  import {
@@ -7,6 +6,7 @@ import {
7
6
  readDaemonLockOwner,
8
7
  resolveWorkspace,
9
8
  } from "./state.mjs";
9
+ import { inspectProcessInstance, isPidAlive, processCommandLine, splitProcessCommandLine } from "./process-identity.mjs";
10
10
 
11
11
  const DEFAULT_TAKEOVER_TIMEOUT_MS = 15_000;
12
12
  const DEFAULT_TAKEOVER_POLL_MS = 100;
@@ -137,6 +137,8 @@ function inspectWorkspaceDaemonOwner(state, owner) {
137
137
  if (!owner || owner.purpose !== "daemon") return { verified_service_daemon: false, reason: "invalid_lock_owner" };
138
138
  const pid = Number(owner.pid);
139
139
  if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return { verified_service_daemon: false, reason: "invalid_pid" };
140
+ const processIdentity = inspectProcessInstance(owner);
141
+ if (!processIdentity.current) return { verified_service_daemon: false, reason: processIdentity.reason };
140
142
  if (!sameCanonicalPath(owner.workspace, state.workspace.path)) return { verified_service_daemon: false, reason: "workspace_mismatch" };
141
143
  if (owner.mode === "foreground") return { verified_service_daemon: false, reason: "foreground_daemon" };
142
144
 
@@ -159,61 +161,6 @@ function inspectWorkspaceDaemonOwner(state, owner) {
159
161
  return { verified_service_daemon: matches, reason: matches ? "service_command" : "command_mismatch" };
160
162
  }
161
163
 
162
- function processCommandLine(pid) {
163
- if (process.platform === "win32") {
164
- const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`;
165
- const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
166
- encoding: "utf8",
167
- timeout: 3000,
168
- windowsHide: true,
169
- });
170
- return result.status === 0 ? String(result.stdout || "").trim() : "";
171
- }
172
- const result = spawnSync("ps", ["-ww", "-p", String(pid), "-o", "command="], {
173
- encoding: "utf8",
174
- timeout: 3000,
175
- windowsHide: true,
176
- });
177
- return result.status === 0 ? String(result.stdout || "").trim() : "";
178
- }
179
-
180
- function splitProcessCommandLine(value) {
181
- const args = [];
182
- let current = "";
183
- let quote = "";
184
- const text = String(value);
185
- for (let index = 0; index < text.length; index += 1) {
186
- const character = text[index];
187
- if (quote) {
188
- if (character === quote) quote = "";
189
- else if (character === "\\" && quote === '"' && ['"', "\\"].includes(text[index + 1])) {
190
- current += text[index + 1];
191
- index += 1;
192
- } else current += character;
193
- continue;
194
- }
195
- if (character === '"' || character === "'") {
196
- quote = character;
197
- continue;
198
- }
199
- if (/\s/.test(character)) {
200
- if (current) {
201
- args.push(current);
202
- current = "";
203
- }
204
- continue;
205
- }
206
- if (character === "\\" && /[\s'"\\]/.test(text[index + 1] || "")) {
207
- current += text[index + 1];
208
- index += 1;
209
- continue;
210
- }
211
- current += character;
212
- }
213
- if (current) args.push(current);
214
- return args;
215
- }
216
-
217
164
  function commandFlagValue(argv, name) {
218
165
  const index = argv.indexOf(name);
219
166
  return index >= 0 && index + 1 < argv.length ? argv[index + 1] : "";
@@ -242,17 +189,6 @@ function publicDaemonMode(owner) {
242
189
  return owner?.mode === "service" || owner?.mode === "foreground" ? owner.mode : "legacy";
243
190
  }
244
191
 
245
- function isPidAlive(pid) {
246
- const parsed = Number(pid);
247
- if (!Number.isInteger(parsed) || parsed <= 0) return false;
248
- try {
249
- process.kill(parsed, 0);
250
- return true;
251
- } catch (error) {
252
- return error?.code === "EPERM";
253
- }
254
- }
255
-
256
192
  function boundedPositiveInt(value, fallback) {
257
193
  const parsed = Number(value);
258
194
  return Number.isFinite(parsed) ? Math.max(1, parsed) : fallback;
@@ -0,0 +1,94 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ fsyncSync,
6
+ linkSync,
7
+ lstatSync,
8
+ openSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { basename, dirname, join } from "node:path";
13
+ import { replaceFileSync } from "./atomic-fs.mjs";
14
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
15
+
16
+ export function createExclusiveFileSync(target, content, options = {}) {
17
+ const mode = Number.isInteger(options.mode) ? options.mode : 0o600;
18
+ const temporary = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
19
+ let fd;
20
+ let linked = false;
21
+ try {
22
+ fd = openSync(temporary, "wx", mode);
23
+ writeFileSync(fd, content);
24
+ fsyncSync(fd);
25
+ closeSync(fd);
26
+ fd = undefined;
27
+ linkSync(temporary, target);
28
+ linked = true;
29
+ try { chmodSync(target, mode); } catch {}
30
+ return { created: true, path: target };
31
+ } finally {
32
+ if (fd !== undefined) try { closeSync(fd); } catch {}
33
+ try { unlinkSync(temporary); } catch {}
34
+ if (!linked && options.cleanupTargetOnFailure === true) {
35
+ try { unlinkSync(target); } catch {}
36
+ }
37
+ }
38
+ }
39
+
40
+ export function replaceFileAtomicallySync(target, content, options = {}) {
41
+ const mode = Number.isInteger(options.mode) ? options.mode : 0o600;
42
+ const temporary = join(dirname(target), `.${basename(target)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
43
+ let fd;
44
+ try {
45
+ fd = openSync(temporary, "wx", mode);
46
+ writeFileSync(fd, content);
47
+ fsyncSync(fd);
48
+ closeSync(fd);
49
+ fd = undefined;
50
+ replaceFileSync(temporary, target);
51
+ try { chmodSync(target, mode); } catch {}
52
+ return { replaced: true, path: target };
53
+ } catch (error) {
54
+ if (fd !== undefined) try { closeSync(fd); } catch {}
55
+ try { unlinkSync(temporary); } catch {}
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ export function removeOwnedJsonFileSync(target, expected = {}, options = {}) {
61
+ const maxBytes = Number.isInteger(options.maxBytes) ? options.maxBytes : 4096;
62
+ const snapshot = ownedJsonSnapshot(target, maxBytes);
63
+ if (!snapshot || !matchesExpected(snapshot.value, expected)) return false;
64
+ let current;
65
+ try { current = lstatSync(target); } catch (error) { return error?.code === "ENOENT"; }
66
+ if (current.isSymbolicLink() || !current.isFile() || !sameIdentity(snapshot.info, current)) return false;
67
+ const currentSnapshot = ownedJsonSnapshot(target, maxBytes);
68
+ if (!currentSnapshot || !sameIdentity(snapshot.info, currentSnapshot.info) || !matchesExpected(currentSnapshot.value, expected)) return false;
69
+ try { unlinkSync(target); return true; } catch (error) { return error?.code === "ENOENT"; }
70
+ }
71
+
72
+ function ownedJsonSnapshot(target, maxBytes) {
73
+ let info;
74
+ try { info = lstatSync(target); } catch (error) { if (error?.code === "ENOENT") return null; throw error; }
75
+ if (info.isSymbolicLink() || !info.isFile()) return null;
76
+ try {
77
+ const value = JSON.parse(readBoundedRegularFileSync(target, maxBytes).toString("utf8"));
78
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
79
+ return { value, info };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function matchesExpected(value, expected) {
86
+ return Object.entries(expected).every(([key, expectedValue]) => value?.[key] === expectedValue);
87
+ }
88
+
89
+ function sameIdentity(left, right) {
90
+ return Number(left.dev) === Number(right.dev)
91
+ && Number(left.ino) === Number(right.ino)
92
+ && Number(left.size) === Number(right.size)
93
+ && Number(left.mtimeMs) === Number(right.mtimeMs);
94
+ }