machine-bridge-mcp 0.11.1 → 0.12.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.
- package/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +5 -5
- package/SECURITY.md +15 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/AGENT_CONTEXT.md +1 -1
- package/docs/ARCHITECTURE.md +14 -8
- package/docs/AUDIT.md +110 -0
- package/docs/ENGINEERING.md +14 -4
- package/docs/LOCAL_AUTOMATION.md +5 -3
- package/docs/LOGGING.md +5 -3
- package/docs/MANAGED_JOBS.md +5 -1
- package/docs/OPERATIONS.md +12 -4
- package/docs/PRIVACY.md +5 -3
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +23 -8
- package/package.json +8 -8
- package/scripts/github-release.mjs +368 -0
- package/scripts/privacy-check.mjs +66 -5
- package/scripts/release-state.mjs +10 -0
- package/scripts/syntax-check.mjs +52 -0
- package/src/local/agent-context.mjs +19 -6
- package/src/local/app-automation.mjs +17 -9
- package/src/local/browser-bridge.mjs +94 -53
- package/src/local/cli.mjs +165 -81
- package/src/local/daemon-process.mjs +3 -67
- package/src/local/exclusive-file.mjs +94 -0
- package/src/local/job-runner.mjs +33 -17
- package/src/local/log.mjs +16 -1
- package/src/local/managed-jobs.mjs +187 -56
- package/src/local/process-identity.mjs +143 -0
- package/src/local/resource-operations.mjs +2 -3
- package/src/local/runtime.mjs +4 -2
- package/src/local/service-lifecycle.mjs +56 -0
- package/src/local/service.mjs +53 -25
- package/src/local/shell.mjs +23 -4
- package/src/local/state.mjs +228 -66
- package/src/shared/tool-catalog.json +10 -0
- package/src/worker/index.ts +1 -1
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,
|
|
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
|
-
|
|
19
|
-
|
|
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 =
|
|
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.
|
|
374
|
+
logger.safePlain(" Stop the existing foreground process with Ctrl+C in its terminal, then retry.");
|
|
376
375
|
} else {
|
|
377
|
-
logger.
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
943
|
-
|
|
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.
|
|
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.
|
|
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
|
|
1190
|
-
|
|
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(
|
|
1196
|
-
await
|
|
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
|
-
|
|
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
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
-
...
|
|
1271
|
-
ok: result?.ok !== false && workspaceDaemon.ok,
|
|
1279
|
+
...lifecycle,
|
|
1272
1280
|
workspace: state?.workspace?.path || null,
|
|
1273
|
-
workspace_daemon:
|
|
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
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
1339
|
-
|
|
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
|
|
1377
|
-
|
|
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
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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
|
|
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
|
+
}
|