machine-bridge-mcp 0.11.0 → 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/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +2 -2
- package/README.md +6 -6
- package/SECURITY.md +15 -5
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +15 -7
- package/docs/AUDIT.md +110 -0
- package/docs/ENGINEERING.md +14 -4
- package/docs/LOCAL_AUTOMATION.md +3 -3
- package/docs/LOGGING.md +5 -3
- package/docs/MANAGED_JOBS.md +5 -1
- package/docs/OPERATIONS.md +15 -3
- package/docs/PRIVACY.md +5 -3
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +11 -8
- package/package.json +7 -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/app-automation.mjs +3 -1
- package/src/local/browser-bridge.mjs +94 -53
- package/src/local/cli.mjs +218 -130
- package/src/local/daemon-process.mjs +199 -0
- 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 +115 -36
- package/src/local/shell.mjs +23 -4
- package/src/local/state.mjs +228 -66
- package/src/worker/index.ts +1 -1
package/src/local/cli.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
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";
|
|
7
7
|
import { LocalRuntime } from "./runtime.mjs";
|
|
8
|
+
import { acquireDaemonLockWithTakeover, inspectWorkspaceDaemon, stopWorkspaceServiceDaemon } from "./daemon-process.mjs";
|
|
8
9
|
import { runStdioServer } from "./stdio.mjs";
|
|
9
10
|
import { assertCanonicalFullPolicy, DEFAULT_POLICY_PROFILE, DEFAULT_POLICY_REVISION, POLICY_PROFILES, normalizePolicy, policyProfile, toolsForPolicy } from "./tools.mjs";
|
|
10
11
|
import { classifyOperationalError, createLogger, normalizeLogLevel, sanitizeLogText } from "./log.mjs";
|
|
@@ -13,9 +14,12 @@ import { runWrangler } from "./shell.mjs";
|
|
|
13
14
|
import { generateRegisteredSshKey } from "./resource-operations.mjs";
|
|
14
15
|
import { runFullAccessTest } from "./full-access-test.mjs";
|
|
15
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";
|
|
16
20
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
acquireMaintenanceLock,
|
|
22
|
+
acquireStartupLockWithWait,
|
|
19
23
|
appName,
|
|
20
24
|
daemonLockPathForState,
|
|
21
25
|
defaultStateRoot,
|
|
@@ -47,8 +51,6 @@ const BOOLEAN_OPTIONS = new Set([
|
|
|
47
51
|
const VALUE_OPTIONS = new Set([
|
|
48
52
|
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
|
|
49
53
|
]);
|
|
50
|
-
const DAEMON_TAKEOVER_TIMEOUT_MS = 15_000;
|
|
51
|
-
const DAEMON_TAKEOVER_POLL_MS = 100;
|
|
52
54
|
|
|
53
55
|
const COMMAND_HANDLERS = Object.freeze({
|
|
54
56
|
start: startCommand,
|
|
@@ -146,7 +148,7 @@ const ACTION_POSITIONAL_RULES = Object.freeze({
|
|
|
146
148
|
},
|
|
147
149
|
service(args) {
|
|
148
150
|
const action = String(args._[0] || "status");
|
|
149
|
-
return { max:
|
|
151
|
+
return { max: ["install", "status", "stop", "uninstall", "remove"].includes(action) ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
|
|
150
152
|
},
|
|
151
153
|
autostart(args) {
|
|
152
154
|
return ACTION_POSITIONAL_RULES.service(args);
|
|
@@ -315,16 +317,12 @@ async function startCommand(args) {
|
|
|
315
317
|
const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), component: "cli" });
|
|
316
318
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
|
|
317
319
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
318
|
-
const startupLock =
|
|
319
|
-
if (!startupLock.acquired) {
|
|
320
|
-
const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
|
|
321
|
-
throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
|
|
322
|
-
}
|
|
320
|
+
const startupLock = await acquireStartupLockWithWait(state, { operation: "start", logger });
|
|
323
321
|
|
|
324
322
|
try {
|
|
325
323
|
const startMode = await prepareStartMode(args, state, logger);
|
|
326
324
|
const daemonLock = await acquireDaemonLockWithTakeover(state, {
|
|
327
|
-
|
|
325
|
+
takeOverServiceOwner: startMode.takeOverServiceOwner,
|
|
328
326
|
ownerMetadata: {
|
|
329
327
|
mode: args.daemonOnly ? "service" : "foreground",
|
|
330
328
|
version: currentPackageVersion(),
|
|
@@ -345,43 +343,15 @@ async function prepareStartMode(args, state, logger) {
|
|
|
345
343
|
if (args.daemonOnly) {
|
|
346
344
|
const { trimAutostartLogs } = await import("./service.mjs");
|
|
347
345
|
trimAutostartLogs(state.paths.stateRoot);
|
|
348
|
-
return {
|
|
346
|
+
return { takeOverServiceOwner: false };
|
|
349
347
|
}
|
|
350
|
-
// A normal foreground start
|
|
351
|
-
//
|
|
352
|
-
//
|
|
348
|
+
// A normal foreground start first asks the platform service manager to
|
|
349
|
+
// unload the job, then independently reclaims a verified daemon-only
|
|
350
|
+
// process. The second step handles legacy/orphan daemons that launchd or
|
|
351
|
+
// another service manager no longer tracks.
|
|
353
352
|
return stopAutostartBestEffort(logger);
|
|
354
353
|
}
|
|
355
354
|
|
|
356
|
-
export async function acquireDaemonLockWithTakeover(state, options = {}) {
|
|
357
|
-
const ownerMetadata = options.ownerMetadata || {};
|
|
358
|
-
let lock = acquireDaemonLock(state, ownerMetadata);
|
|
359
|
-
if (lock.acquired || !options.waitForServiceExit) return lock;
|
|
360
|
-
|
|
361
|
-
const timeoutMs = Number.isFinite(Number(options.timeoutMs))
|
|
362
|
-
? Math.max(1, Number(options.timeoutMs))
|
|
363
|
-
: DAEMON_TAKEOVER_TIMEOUT_MS;
|
|
364
|
-
const pollMs = Number.isFinite(Number(options.pollMs))
|
|
365
|
-
? Math.max(1, Number(options.pollMs))
|
|
366
|
-
: DAEMON_TAKEOVER_POLL_MS;
|
|
367
|
-
const logger = options.logger || { info() {} };
|
|
368
|
-
const priorPid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "the existing process";
|
|
369
|
-
logger.info(`waiting for the background daemon (${priorPid}) to stop before foreground startup`);
|
|
370
|
-
|
|
371
|
-
const deadline = Date.now() + timeoutMs;
|
|
372
|
-
while (Date.now() < deadline) {
|
|
373
|
-
await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
|
|
374
|
-
lock = acquireDaemonLock(state, ownerMetadata);
|
|
375
|
-
if (lock.acquired) {
|
|
376
|
-
logger.info("background daemon stopped; foreground startup is taking over the workspace");
|
|
377
|
-
return lock;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
|
|
382
|
-
throw new Error(`background daemon did not release the workspace within ${Math.ceil(timeoutMs / 1000)} seconds (${pid}); run \`machine-mcp service stop\`, verify \`machine-mcp service status\`, and retry`);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
355
|
function reportExistingDaemon(args, state, owner, logger) {
|
|
386
356
|
const pid = owner?.pid ? `pid ${owner.pid}` : "unknown pid";
|
|
387
357
|
if (isIdempotentDaemonOnlyStart(args)) {
|
|
@@ -401,9 +371,9 @@ function reportExistingDaemon(args, state, owner, logger) {
|
|
|
401
371
|
return;
|
|
402
372
|
}
|
|
403
373
|
if (owner?.mode === "foreground") {
|
|
404
|
-
logger.
|
|
374
|
+
logger.safePlain(" Stop the existing foreground process with Ctrl+C in its terminal, then retry.");
|
|
405
375
|
} else {
|
|
406
|
-
logger.
|
|
376
|
+
logger.safePlain(" Run `machine-mcp service stop`, verify `machine-mcp service status`, then retry.");
|
|
407
377
|
}
|
|
408
378
|
logger.plain(` Workspace: ${state.workspace.path}`);
|
|
409
379
|
}
|
|
@@ -632,12 +602,11 @@ function resourceListAction({ args, workspace, state }) {
|
|
|
632
602
|
}
|
|
633
603
|
}
|
|
634
604
|
|
|
635
|
-
function resourceAddAction({ args, workspace, state }) {
|
|
605
|
+
async function resourceAddAction({ args, workspace, state }) {
|
|
636
606
|
const name = validateResourceName(args._[1]);
|
|
637
607
|
const inputPath = args._[2];
|
|
638
608
|
if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
|
|
639
|
-
const lock =
|
|
640
|
-
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" });
|
|
641
610
|
try {
|
|
642
611
|
const latest = loadState(workspace, { stateDir: args.stateDir });
|
|
643
612
|
latest.resources ||= {};
|
|
@@ -701,10 +670,9 @@ async function resourceGenerateSshKeyAction({ args, workspace }) {
|
|
|
701
670
|
}
|
|
702
671
|
}
|
|
703
672
|
|
|
704
|
-
function resourceRemoveAction({ args, workspace, state }) {
|
|
673
|
+
async function resourceRemoveAction({ args, workspace, state }) {
|
|
705
674
|
const name = validateResourceName(args._[1]);
|
|
706
|
-
const lock =
|
|
707
|
-
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" });
|
|
708
676
|
try {
|
|
709
677
|
const latest = loadState(workspace, { stateDir: args.stateDir });
|
|
710
678
|
latest.resources ||= {};
|
|
@@ -832,6 +800,7 @@ async function jobCommand(args) {
|
|
|
832
800
|
policy: resolvePolicy({}, state.policy),
|
|
833
801
|
resources: state.resources,
|
|
834
802
|
resourceStatePath: state.paths.statePath,
|
|
803
|
+
stateRoot: state.paths.stateRoot,
|
|
835
804
|
logger: createLogger({ level: "warn", component: "job" }),
|
|
836
805
|
});
|
|
837
806
|
let result;
|
|
@@ -951,7 +920,7 @@ async function withSecretsFile(state, callback) {
|
|
|
951
920
|
DAEMON_SHARED_SECRET: state.worker.daemonSecret,
|
|
952
921
|
OAUTH_TOKEN_VERSION: state.worker.oauthTokenVersion,
|
|
953
922
|
};
|
|
954
|
-
|
|
923
|
+
createExclusiveFileSync(tempPath, JSON.stringify(payload), { mode: 0o600 });
|
|
955
924
|
ownerOnlyFile(tempPath);
|
|
956
925
|
try {
|
|
957
926
|
return await callback(tempPath);
|
|
@@ -960,7 +929,7 @@ async function withSecretsFile(state, callback) {
|
|
|
960
929
|
}
|
|
961
930
|
}
|
|
962
931
|
|
|
963
|
-
function cleanupStaleSecretFiles(dir) {
|
|
932
|
+
export function cleanupStaleSecretFiles(dir) {
|
|
964
933
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
965
934
|
if (!entry.isFile()) continue;
|
|
966
935
|
const match = /^worker-secrets-(\d+)-(\d+)(?:-[a-f0-9]+)?\.json$/.exec(entry.name);
|
|
@@ -968,8 +937,9 @@ function cleanupStaleSecretFiles(dir) {
|
|
|
968
937
|
const file = resolve(dir, entry.name);
|
|
969
938
|
try {
|
|
970
939
|
const pid = Number(match[1]);
|
|
971
|
-
const
|
|
972
|
-
|
|
940
|
+
const createdAt = Number(match[2]);
|
|
941
|
+
const identity = inspectProcessInstance({ pid, startedAt: new Date(createdAt).toISOString() });
|
|
942
|
+
if (!identity.current) unlinkSync(file);
|
|
973
943
|
} catch {}
|
|
974
944
|
}
|
|
975
945
|
}
|
|
@@ -1103,13 +1073,13 @@ function printMcpConnection(state, {
|
|
|
1103
1073
|
else logger.plain(` MCP connection password: ${previewSecret(payload.mcp_connection_password)} (redacted)`);
|
|
1104
1074
|
} else {
|
|
1105
1075
|
logger.success("Remote MCP bridge is ready");
|
|
1106
|
-
logger.
|
|
1076
|
+
logger.safePlain(" Connection credentials unchanged; use --print-mcp-credentials only when reconnecting a client.");
|
|
1107
1077
|
}
|
|
1108
1078
|
if (policyMigrated) {
|
|
1109
1079
|
logger.warn("Legacy implicit policy migrated to full access; use --profile agent, edit, or review to narrow it.");
|
|
1110
1080
|
}
|
|
1111
1081
|
logger.plain(` Workspace: ${payload.workspace}`);
|
|
1112
|
-
logger.
|
|
1082
|
+
logger.safePlain(` Policy: ${formatPolicySummary(payload.policy)}`);
|
|
1113
1083
|
if (verbose) logger.plain(` State: ${payload.state_path}`);
|
|
1114
1084
|
}
|
|
1115
1085
|
|
|
@@ -1215,16 +1185,19 @@ async function fullTestCommand(args) {
|
|
|
1215
1185
|
async function rotateSecretsCommand(args) {
|
|
1216
1186
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
1217
1187
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1220
|
-
const pid = startupLock.owner?.pid ? `pid ${startupLock.owner.pid}` : "unknown pid";
|
|
1221
|
-
throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
|
|
1222
|
-
}
|
|
1188
|
+
const operationLogger = createLogger({ level: args.quiet ? "error" : "warn", component: "service" });
|
|
1189
|
+
const startupLock = await acquireStartupLockWithWait(state, { operation: "rotate-secrets", logger: operationLogger });
|
|
1223
1190
|
try {
|
|
1224
|
-
await stopAutostartBestEffort(
|
|
1225
|
-
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);
|
|
1226
1198
|
const daemonOwner = readDaemonLockOwner(daemonLockPathForState(state));
|
|
1227
|
-
|
|
1199
|
+
const daemonIdentity = daemonOwner ? inspectProcessInstance(daemonOwner) : null;
|
|
1200
|
+
if (daemonIdentity?.current) {
|
|
1228
1201
|
throw new Error(`refusing to rotate secrets while the daemon is active (pid ${daemonOwner.pid}); stop the foreground daemon and retry`);
|
|
1229
1202
|
}
|
|
1230
1203
|
ensureWorkerSecrets(state, { rotateSecrets: true, workerName: validateWorkerName(args.workerName) });
|
|
@@ -1244,7 +1217,15 @@ async function serviceCommand(args) {
|
|
|
1244
1217
|
const { installAutostart, uninstallAutostart, autostartStatus, startAutostart, stopAutostart } = await import("./service.mjs");
|
|
1245
1218
|
if (action === "status") {
|
|
1246
1219
|
const status = await autostartStatus({ logger: structuredLogger(Boolean(args.quiet)) });
|
|
1247
|
-
|
|
1220
|
+
const state = optionalServiceState(args, stateRoot);
|
|
1221
|
+
const workspaceDaemon = state ? inspectWorkspaceDaemon(state) : null;
|
|
1222
|
+
console.log(JSON.stringify({
|
|
1223
|
+
...status,
|
|
1224
|
+
workspace: state?.workspace?.path || null,
|
|
1225
|
+
workspace_daemon: workspaceDaemon,
|
|
1226
|
+
effective_active: Boolean(status.active || workspaceDaemon?.alive),
|
|
1227
|
+
orphaned_workspace_daemon: Boolean(!status.active && workspaceDaemon?.alive && workspaceDaemon?.verified_service_daemon),
|
|
1228
|
+
}, null, 2));
|
|
1248
1229
|
return;
|
|
1249
1230
|
}
|
|
1250
1231
|
if (action === "install") {
|
|
@@ -1256,26 +1237,63 @@ async function serviceCommand(args) {
|
|
|
1256
1237
|
}
|
|
1257
1238
|
const result = await installAutostart({ workspace, stateRoot, entryScript: process.argv[1], logger: structuredLogger(Boolean(args.quiet)) });
|
|
1258
1239
|
console.log(JSON.stringify(result, null, 2));
|
|
1240
|
+
if (result?.ok === false) process.exitCode = 1;
|
|
1259
1241
|
return;
|
|
1260
1242
|
}
|
|
1261
1243
|
if (action === "start") {
|
|
1262
1244
|
const result = await startAutostart({ logger: structuredLogger(Boolean(args.quiet)) });
|
|
1263
1245
|
console.log(JSON.stringify(result, null, 2));
|
|
1246
|
+
if (result?.ok === false) process.exitCode = 1;
|
|
1264
1247
|
return;
|
|
1265
1248
|
}
|
|
1266
1249
|
if (action === "stop") {
|
|
1267
|
-
const
|
|
1250
|
+
const logger = structuredLogger(Boolean(args.quiet));
|
|
1251
|
+
const provider = await stopAutostart({ logger });
|
|
1252
|
+
const state = optionalServiceState(args, stateRoot);
|
|
1253
|
+
const workspaceDaemon = state
|
|
1254
|
+
? await stopWorkspaceServiceDaemon(state, { logger, reason: "service stop" })
|
|
1255
|
+
: { ok: true, found: false, stopped: false, verified_service_daemon: false, reason: "workspace_not_selected" };
|
|
1256
|
+
const result = {
|
|
1257
|
+
...provider,
|
|
1258
|
+
ok: provider?.ok !== false && workspaceDaemon.ok,
|
|
1259
|
+
workspace: state?.workspace?.path || null,
|
|
1260
|
+
workspace_daemon: workspaceDaemon,
|
|
1261
|
+
};
|
|
1268
1262
|
console.log(JSON.stringify(result, null, 2));
|
|
1263
|
+
if (!result.ok) process.exitCode = 1;
|
|
1269
1264
|
return;
|
|
1270
1265
|
}
|
|
1271
1266
|
if (action === "uninstall" || action === "remove") {
|
|
1272
|
-
const
|
|
1273
|
-
|
|
1267
|
+
const logger = structuredLogger(Boolean(args.quiet));
|
|
1268
|
+
const state = optionalServiceState(args, stateRoot);
|
|
1269
|
+
const lifecycle = await stopAndRemoveAutostart({
|
|
1270
|
+
states: state ? [state] : [],
|
|
1271
|
+
stateRoot,
|
|
1272
|
+
logger,
|
|
1273
|
+
reason: "service uninstall",
|
|
1274
|
+
stopAutostart,
|
|
1275
|
+
uninstallAutostart,
|
|
1276
|
+
stopWorkspaceServiceDaemon,
|
|
1277
|
+
});
|
|
1278
|
+
const output = {
|
|
1279
|
+
...lifecycle,
|
|
1280
|
+
workspace: state?.workspace?.path || null,
|
|
1281
|
+
workspace_daemon: lifecycle.workspace_daemons[0] || null,
|
|
1282
|
+
autostart_removed: lifecycle.removed,
|
|
1283
|
+
};
|
|
1284
|
+
console.log(JSON.stringify(output, null, 2));
|
|
1285
|
+
if (!output.ok) process.exitCode = 1;
|
|
1274
1286
|
return;
|
|
1275
1287
|
}
|
|
1276
1288
|
throw new Error(`Unknown service action: ${action}`);
|
|
1277
1289
|
}
|
|
1278
1290
|
|
|
1291
|
+
function optionalServiceState(args, stateRoot) {
|
|
1292
|
+
const requested = args.workspace || args._[1] || selectedWorkspace(stateRoot);
|
|
1293
|
+
if (!requested || requested === true) return null;
|
|
1294
|
+
return loadState(resolveWorkspace(String(requested)), { stateDir: stateRoot });
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1279
1297
|
async function installAutostartBestEffort({ workspace, stateRoot, entryScript, logger }) {
|
|
1280
1298
|
try {
|
|
1281
1299
|
const { installAutostart } = await import("./service.mjs");
|
|
@@ -1288,30 +1306,25 @@ async function installAutostartBestEffort({ workspace, stateRoot, entryScript, l
|
|
|
1288
1306
|
}
|
|
1289
1307
|
|
|
1290
1308
|
async function stopAutostartBestEffort(logger) {
|
|
1309
|
+
let result = null;
|
|
1291
1310
|
try {
|
|
1292
|
-
const {
|
|
1293
|
-
|
|
1294
|
-
try {
|
|
1295
|
-
status = await autostartStatus({ logger: structuredLogger(true) });
|
|
1296
|
-
} catch (error) {
|
|
1297
|
-
logger.debug?.("Autostart status check failed before takeover", { error_class: classifyOperationalError(error) });
|
|
1298
|
-
}
|
|
1299
|
-
if (status?.active === false) return { waitForServiceExit: false };
|
|
1300
|
-
|
|
1301
|
-
const result = await stopAutostart({ logger: structuredLogger(true) });
|
|
1302
|
-
const stopSucceeded = result?.code === 0 || result?.ok === true;
|
|
1303
|
-
if (status?.active && stopSucceeded) logger.info("stopping the background service before foreground startup");
|
|
1304
|
-
return { waitForServiceExit: Boolean(stopSucceeded && status?.active !== false) };
|
|
1311
|
+
const { stopAutostart } = await import("./service.mjs");
|
|
1312
|
+
result = await stopAutostart({ logger: structuredLogger(true) });
|
|
1305
1313
|
} catch (error) {
|
|
1306
|
-
logger.warn("Autostart stop
|
|
1307
|
-
return {
|
|
1314
|
+
logger.warn("Autostart stop command was unavailable; checking the workspace daemon directly", { error_class: classifyOperationalError(error) });
|
|
1315
|
+
return { takeOverServiceOwner: true, provider: null };
|
|
1308
1316
|
}
|
|
1317
|
+
if (result?.active_before && result?.ok) logger.info("stopping the background service before foreground startup");
|
|
1318
|
+
if (result?.ok === false && result?.active === true) {
|
|
1319
|
+
throw new Error("the background service is still active after the stop request; run `machine-mcp service status` for details");
|
|
1320
|
+
}
|
|
1321
|
+
return { takeOverServiceOwner: true, provider: result };
|
|
1309
1322
|
}
|
|
1310
1323
|
|
|
1311
1324
|
async function uninstallCommand(args) {
|
|
1312
1325
|
const stateRoot = stateRootFromArgs(args);
|
|
1313
1326
|
const deleteRemote = !args.keepWorker;
|
|
1314
|
-
validateStateRootForRemoval(stateRoot);
|
|
1327
|
+
const validation = validateStateRootForRemoval(stateRoot);
|
|
1315
1328
|
const action = deleteRemote
|
|
1316
1329
|
? `delete deployed Worker(s), remove autostart entries, and remove local state at ${stateRoot}`
|
|
1317
1330
|
: `remove autostart entries and local state at ${stateRoot} while keeping deployed Worker(s)`;
|
|
@@ -1320,26 +1333,46 @@ async function uninstallCommand(args) {
|
|
|
1320
1333
|
console.log("Uninstall cancelled. Re-run with `machine-mcp uninstall --yes` to skip confirmation.");
|
|
1321
1334
|
return;
|
|
1322
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) {
|
|
1323
1364
|
const activeJobs = activeStateJobs(stateRoot);
|
|
1324
|
-
if (activeJobs.length)
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
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) {
|
|
1332
1372
|
const activeLocks = activeStateLocks(stateRoot);
|
|
1333
|
-
if (activeLocks.length)
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
}
|
|
1337
|
-
if (deleteRemote) await deleteKnownWorkers(stateRoot);
|
|
1338
|
-
removeStateRoot(stateRoot);
|
|
1339
|
-
console.log("Removed local autostart entries and state.");
|
|
1340
|
-
if (deleteRemote) console.log("Requested deletion for known deployed Worker(s).");
|
|
1341
|
-
console.log("If installed globally, remove the npm package with:");
|
|
1342
|
-
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`);
|
|
1343
1376
|
}
|
|
1344
1377
|
|
|
1345
1378
|
async function deleteKnownWorkers(stateRoot) {
|
|
@@ -1363,31 +1396,52 @@ async function deleteKnownWorkers(stateRoot) {
|
|
|
1363
1396
|
}
|
|
1364
1397
|
}
|
|
1365
1398
|
|
|
1366
|
-
function knownWorkerNames(stateRoot) {
|
|
1399
|
+
export function knownWorkerNames(stateRoot) {
|
|
1367
1400
|
const profiles = resolve(expandHome(stateRoot), "profiles");
|
|
1368
1401
|
if (!existsSync(profiles)) return [];
|
|
1369
1402
|
const names = new Set();
|
|
1370
1403
|
for (const entry of readdirSync(profiles, { withFileTypes: true })) {
|
|
1371
|
-
if (!entry.isDirectory()) continue;
|
|
1372
|
-
const
|
|
1373
|
-
|
|
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;
|
|
1374
1413
|
try {
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
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);
|
|
1380
1426
|
}
|
|
1381
1427
|
return [...names];
|
|
1382
1428
|
}
|
|
1383
1429
|
|
|
1384
1430
|
async function removeAutostartBestEffort(stateRoot) {
|
|
1431
|
+
const logger = structuredLogger(false);
|
|
1385
1432
|
try {
|
|
1386
1433
|
const { stopAutostart, uninstallAutostart } = await import("./service.mjs");
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
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.`);
|
|
1391
1445
|
return false;
|
|
1392
1446
|
}
|
|
1393
1447
|
return true;
|
|
@@ -1397,6 +1451,46 @@ async function removeAutostartBestEffort(stateRoot) {
|
|
|
1397
1451
|
}
|
|
1398
1452
|
}
|
|
1399
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
|
+
|
|
1400
1494
|
function activeStateJobs(stateRoot) {
|
|
1401
1495
|
const profiles = resolve(expandHome(stateRoot), "profiles");
|
|
1402
1496
|
if (!existsSync(profiles)) return [];
|
|
@@ -1420,23 +1514,17 @@ function activeStateLocks(stateRoot) {
|
|
|
1420
1514
|
const lockPath = resolve(profiles, profile.name, name);
|
|
1421
1515
|
if (!existsSync(lockPath)) continue;
|
|
1422
1516
|
const owner = readDaemonLockOwner(lockPath);
|
|
1423
|
-
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 });
|
|
1424
1523
|
}
|
|
1425
1524
|
}
|
|
1426
1525
|
return active;
|
|
1427
1526
|
}
|
|
1428
1527
|
|
|
1429
|
-
function isPidAlive(pid) {
|
|
1430
|
-
const parsed = Number(pid);
|
|
1431
|
-
if (!Number.isInteger(parsed) || parsed <= 0) return false;
|
|
1432
|
-
try {
|
|
1433
|
-
process.kill(parsed, 0);
|
|
1434
|
-
return true;
|
|
1435
|
-
} catch (error) {
|
|
1436
|
-
return error?.code === "EPERM";
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
1528
|
function structuredLogger(quiet) {
|
|
1441
1529
|
return createLogger({ quiet, component: "service" });
|
|
1442
1530
|
}
|