machine-bridge-mcp 0.6.2 → 0.8.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
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
3
3
  import path, { join, resolve } from "node:path";
4
4
  import process from "node:process";
5
5
  import readline from "node:readline/promises";
6
- import { LocalDaemon } from "./daemon.mjs";
6
+ import { LocalRuntime } from "./runtime.mjs";
7
7
  import { runStdioServer } from "./stdio.mjs";
8
8
  import { assertCanonicalFullPolicy, DEFAULT_POLICY_PROFILE, DEFAULT_POLICY_REVISION, POLICY_PROFILES, normalizePolicy, policyProfile, toolsForPolicy } from "./tools.mjs";
9
9
  import { classifyOperationalError, createLogger, normalizeLogLevel, sanitizeLogText } from "./log.mjs";
@@ -40,12 +40,28 @@ const BOOLEAN_OPTIONS = new Set([
40
40
  "help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
41
41
  "daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
42
42
  "printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
43
- "yes", "keepWorker", "allowInsecurePermissions",
43
+ "yes", "keepWorker", "allowInsecurePermissions", "showPaths",
44
44
  ]);
45
45
  const VALUE_OPTIONS = new Set([
46
46
  "workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
47
47
  ]);
48
48
 
49
+ const COMMAND_HANDLERS = Object.freeze({
50
+ start: startCommand,
51
+ stdio: stdioCommand,
52
+ "client-config": clientConfigCommand,
53
+ status: statusCommand,
54
+ doctor: doctorCommand,
55
+ "full-test": fullTestCommand,
56
+ workspace: workspaceCommand,
57
+ service: serviceCommand,
58
+ autostart: serviceCommand,
59
+ "rotate-secrets": rotateSecretsCommand,
60
+ resource: resourceCommand,
61
+ job: jobCommand,
62
+ uninstall: uninstallCommand,
63
+ });
64
+
49
65
  export async function main(argv = process.argv.slice(2)) {
50
66
  const [command, rest] = normalizeCommand(argv);
51
67
  const args = parseArgs(rest);
@@ -55,26 +71,11 @@ export async function main(argv = process.argv.slice(2)) {
55
71
  validateCommandOptions(command, args);
56
72
  validatePositionals(command, args);
57
73
  validateLoggingOptions(args);
58
-
59
- switch (command) {
60
- case "start": return startCommand(args);
61
- case "stdio": return stdioCommand(args);
62
- case "client-config": return clientConfigCommand(args);
63
- case "status": return statusCommand(args);
64
- case "doctor": return doctorCommand(args);
65
- case "full-test": return fullTestCommand(args);
66
- case "workspace": return workspaceCommand(args);
67
- case "service":
68
- case "autostart": return serviceCommand(args);
69
- case "rotate-secrets": return rotateSecretsCommand(args);
70
- case "resource": return resourceCommand(args);
71
- case "job": return jobCommand(args);
72
- case "uninstall": return uninstallCommand(args);
73
- default:
74
- console.error(`Unknown command: ${command}`);
75
- usage();
76
- process.exitCode = 2;
77
- }
74
+ const handler = COMMAND_HANDLERS[command];
75
+ if (handler) return handler(args);
76
+ console.error(`Unknown command: ${command}`);
77
+ usage();
78
+ process.exitCode = 2;
78
79
  }
79
80
 
80
81
  const COMMAND_OPTIONS = {
@@ -92,7 +93,7 @@ const COMMAND_OPTIONS = {
92
93
  workspace: new Set(["workspace", "stateDir"]),
93
94
  service: new Set(["workspace", "stateDir", "quiet"]),
94
95
  autostart: new Set(["workspace", "stateDir", "quiet"]),
95
- resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "json"]),
96
+ resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "showPaths", "json"]),
96
97
  job: new Set(["workspace", "stateDir", "json", "yes"]),
97
98
  uninstall: new Set(["stateDir", "keepWorker", "yes"]),
98
99
  };
@@ -125,44 +126,55 @@ function toKebab(value) {
125
126
  return String(value).replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
126
127
  }
127
128
 
128
- export function validatePositionals(command, args) {
129
- const count = args._.length;
130
- if (["start", "stdio", "status", "doctor", "full-test", "rotate-secrets"].includes(command)) {
131
- if (count > 1) throw new Error(`${command} accepts at most one positional workspace path`);
132
- if (args.workspace && count) throw new Error("workspace path was provided both positionally and with --workspace");
133
- return;
134
- }
135
- if (command === "workspace") {
129
+ const SINGLE_WORKSPACE_POSITIONAL_COMMANDS = new Set(["start", "stdio", "status", "doctor", "full-test", "rotate-secrets"]);
130
+ const STATIC_POSITIONAL_RULES = Object.freeze({
131
+ "client-config": Object.freeze({ max: 1, tooMany: "client-config accepts at most one positional client name" }),
132
+ uninstall: Object.freeze({ max: 0, tooMany: "uninstall does not accept positional arguments" }),
133
+ });
134
+ const RESOURCE_POSITIONAL_LIMITS = Object.freeze({ add: 3, "generate-ssh-key": 3, remove: 2, check: 2 });
135
+ const JOB_POSITIONAL_LIMITS = Object.freeze({ read: 2, inspect: 2, cancel: 2, approve: 2, submit: 2 });
136
+ const ACTION_POSITIONAL_RULES = Object.freeze({
137
+ workspace(args) {
136
138
  const action = String(args._[0] || "show");
137
- const max = action === "set" || action === "select" ? 2 : 1;
138
- if (count > max) throw new Error(`workspace ${action} received too many positional arguments`);
139
- if (args.workspace && count > 1) throw new Error("workspace path was provided both positionally and with --workspace");
140
- return;
141
- }
142
- if (command === "service" || command === "autostart") {
139
+ return { max: action === "set" || action === "select" ? 2 : 1, tooMany: `workspace ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
140
+ },
141
+ service(args) {
143
142
  const action = String(args._[0] || "status");
144
- const max = action === "install" ? 2 : 1;
145
- if (count > max) throw new Error(`service ${action} received too many positional arguments`);
146
- if (args.workspace && count > 1) throw new Error("workspace path was provided both positionally and with --workspace");
147
- return;
148
- }
149
- if (command === "client-config") {
150
- if (count > 1) throw new Error("client-config accepts at most one positional client name");
151
- return;
152
- }
153
- if (command === "resource") {
143
+ return { max: action === "install" ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
144
+ },
145
+ autostart(args) {
146
+ return ACTION_POSITIONAL_RULES.service(args);
147
+ },
148
+ resource(args) {
154
149
  const action = String(args._[0] || "list");
155
- const max = action === "add" || action === "generate-ssh-key" ? 3 : action === "remove" || action === "check" ? 2 : 1;
156
- if (count > max) throw new Error(`resource ${action} received too many positional arguments`);
157
- return;
158
- }
159
- if (command === "job") {
150
+ return { max: RESOURCE_POSITIONAL_LIMITS[action] ?? 1, tooMany: `resource ${action} received too many positional arguments` };
151
+ },
152
+ job(args) {
160
153
  const action = String(args._[0] || "list");
161
- const max = action === "read" || action === "inspect" || action === "cancel" || action === "approve" || action === "submit" ? 2 : 1;
162
- if (count > max) throw new Error(`job ${action} received too many positional arguments`);
163
- return;
154
+ return { max: JOB_POSITIONAL_LIMITS[action] ?? 1, tooMany: `job ${action} received too many positional arguments` };
155
+ },
156
+ });
157
+
158
+ export function validatePositionals(command, args) {
159
+ const count = args._.length;
160
+ const rule = positionalRule(command, args);
161
+ if (!rule) return;
162
+ if (count > rule.max) throw new Error(rule.tooMany);
163
+ if (args.workspace && Number.isInteger(rule.workspaceConflictAfter) && count > rule.workspaceConflictAfter) {
164
+ throw new Error("workspace path was provided both positionally and with --workspace");
164
165
  }
165
- if (command === "uninstall" && count) throw new Error("uninstall does not accept positional arguments");
166
+ }
167
+
168
+ function positionalRule(command, args) {
169
+ if (SINGLE_WORKSPACE_POSITIONAL_COMMANDS.has(command)) {
170
+ return {
171
+ max: 1,
172
+ tooMany: `${command} accepts at most one positional workspace path`,
173
+ workspaceConflictAfter: 0,
174
+ };
175
+ }
176
+ if (STATIC_POSITIONAL_RULES[command]) return STATIC_POSITIONAL_RULES[command];
177
+ return ACTION_POSITIONAL_RULES[command]?.(args) || null;
166
178
  }
167
179
 
168
180
  function normalizeCommand(argv) {
@@ -300,138 +312,173 @@ async function startCommand(args) {
300
312
  }
301
313
 
302
314
  try {
303
- if (args.daemonOnly) {
304
- const { trimAutostartLogs } = await import("./service.mjs");
305
- trimAutostartLogs(state.paths.stateRoot);
306
- } else {
307
- // Stop an installed service before acquiring the runtime lock. If a
308
- // foreground daemon owns the lock, no new policy or secret state is saved.
309
- await stopAutostartBestEffort(logger);
310
- }
311
-
312
- const lock = acquireDaemonLock(state);
313
- if (!lock.acquired) {
314
- const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
315
- logger.warn(`local daemon already running for this workspace (${pid}); requested changes were not applied`);
316
- if (args.json) printStartJson(state, {
317
- showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
318
- requestedChangesApplied: false,
319
- notice: "local daemon already running; requested changes were not applied",
320
- });
321
- else printMcpConnection(state, {
322
- noPrintCredentials: Boolean(args.noPrintCredentials),
323
- includeCredentials: Boolean(args.printMcpCredentials || args.printCredentials),
324
- quiet: Boolean(args.quiet),
325
- verbose: Boolean(args.verbose),
326
- });
315
+ await prepareStartMode(args, state, logger);
316
+ const daemonLock = acquireDaemonLock(state);
317
+ if (!daemonLock.acquired) {
318
+ reportExistingDaemon(args, state, daemonLock.owner, logger);
327
319
  return;
328
320
  }
329
-
330
- let daemon = null;
331
- try {
332
- const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
333
- const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
334
- const workerName = validateWorkerName(args.workerName);
335
- ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
336
- const previousPolicyOrigin = state.policy?.origin;
337
- state.policy = resolvePolicy(args, state.policy);
338
- const policyMigrated = !previousPolicyOrigin && state.policy.origin === "migrated";
339
- state.policy.updatedAt = new Date().toISOString();
340
- saveState(state);
341
-
342
- if (!args.daemonOnly) await ensureWorker(state, args);
343
- else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
344
-
345
- const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
346
- const shouldPrintMcpCredentials = Boolean(args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
347
-
348
- if (!args.daemonOnly && !args.noAutostart) {
349
- await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], logger });
350
- }
351
-
352
- daemon = new LocalDaemon({
353
- workerUrl: state.worker.url,
354
- secret: state.worker.daemonSecret,
355
- workspace,
356
- policy: state.policy,
357
- logger: createLogger({ level: args.json ? "error" : effectiveLogLevel(args), component: "daemon" }),
358
- jobRoot: join(state.paths.profileDir, "jobs"),
359
- resources: state.resources,
360
- resourceStatePath: state.paths.statePath,
361
- onSuperseded: () => {
362
- logger.warn("this daemon was replaced by a newer authenticated instance; exiting without reconnecting");
363
- lock.release();
364
- process.exit(0);
365
- },
366
- });
367
-
368
- const waitForConnect = daemon.start();
369
- await waitForConnectWithNotice(waitForConnect, 20_000, logger);
370
- if (args.json) printStartJson(state, {
371
- showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
372
- notice: policyMigrated ? "legacy implicit policy migrated to full access" : "",
373
- });
374
- else {
375
- printMcpConnection(state, {
376
- noPrintCredentials: Boolean(args.noPrintCredentials),
377
- includeCredentials: shouldPrintMcpCredentials,
378
- quiet: Boolean(args.quiet),
379
- verbose: Boolean(args.verbose),
380
- policyMigrated,
381
- });
382
- }
383
- keepProcessAlive({ daemon, lock, logger });
384
- } catch (error) {
385
- try { daemon?.stop?.(); } catch {}
386
- lock.release();
387
- throw error;
388
- }
321
+ await startRemoteRuntime({ args, workspace, state, daemonLock, logger });
389
322
  } finally {
390
323
  startupLock.release();
391
324
  }
392
325
  }
393
326
 
327
+ async function prepareStartMode(args, state, logger) {
328
+ if (args.daemonOnly) {
329
+ const { trimAutostartLogs } = await import("./service.mjs");
330
+ trimAutostartLogs(state.paths.stateRoot);
331
+ return;
332
+ }
333
+ // Stop an installed service before acquiring the runtime lock. If a
334
+ // foreground daemon owns the lock, no new policy or secret state is saved.
335
+ await stopAutostartBestEffort(logger);
336
+ }
337
+
338
+ function reportExistingDaemon(args, state, owner, logger) {
339
+ const pid = owner?.pid ? `pid ${owner.pid}` : "unknown pid";
340
+ logger.warn(`local daemon already running for this workspace (${pid}); requested changes were not applied`);
341
+ if (args.json) {
342
+ printStartJson(state, {
343
+ showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
344
+ requestedChangesApplied: false,
345
+ notice: "local daemon already running; requested changes were not applied",
346
+ });
347
+ return;
348
+ }
349
+ printMcpConnection(state, {
350
+ noPrintCredentials: Boolean(args.noPrintCredentials),
351
+ includeCredentials: Boolean(args.printMcpCredentials || args.printCredentials),
352
+ quiet: Boolean(args.quiet),
353
+ verbose: Boolean(args.verbose),
354
+ });
355
+ }
356
+
357
+ async function startRemoteRuntime({ args, workspace, state, daemonLock, logger }) {
358
+ let runtime = null;
359
+ try {
360
+ const readiness = await prepareRemoteState({ args, workspace, state, logger });
361
+ runtime = createRemoteRuntime({ args, workspace, state, daemonLock, logger });
362
+ await runtime.start();
363
+ reportRemoteReady(args, state, readiness);
364
+ keepProcessAlive({ daemon: runtime, lock: daemonLock, logger });
365
+ } catch (error) {
366
+ try { runtime?.stop?.(); } catch {}
367
+ daemonLock.release();
368
+ throw error;
369
+ }
370
+ }
371
+
372
+ async function prepareRemoteState({ args, workspace, state, logger }) {
373
+ const previousMcpServerUrl = state.worker?.mcpServerUrl || "";
374
+ const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
375
+ const workerName = validateWorkerName(args.workerName);
376
+ ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
377
+ const previousPolicyOrigin = state.policy?.origin;
378
+ state.policy = resolvePolicy(args, state.policy);
379
+ const policyMigrated = !previousPolicyOrigin && state.policy.origin === "migrated";
380
+ state.policy.updatedAt = new Date().toISOString();
381
+ saveState(state);
382
+
383
+ if (!args.daemonOnly) await ensureWorker(state, args);
384
+ else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
385
+
386
+ if (!args.daemonOnly && !args.noAutostart) {
387
+ await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], logger });
388
+ }
389
+ const mcpConnectionChanged = Boolean(previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl);
390
+ return {
391
+ policyMigrated,
392
+ shouldPrintMcpCredentials: Boolean(args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged),
393
+ };
394
+ }
395
+
396
+ function createRemoteRuntime({ args, workspace, state, daemonLock, logger }) {
397
+ return new LocalRuntime({
398
+ workerUrl: state.worker.url,
399
+ secret: state.worker.daemonSecret,
400
+ expectedRelayVersion: currentPackageVersion(),
401
+ workspace,
402
+ policy: state.policy,
403
+ logger: createLogger({ level: args.json ? "error" : effectiveLogLevel(args), component: "daemon" }),
404
+ jobRoot: join(state.paths.profileDir, "jobs"),
405
+ resources: state.resources,
406
+ resourceStatePath: state.paths.statePath,
407
+ onSuperseded: () => {
408
+ daemonLock.release();
409
+ process.exit(0);
410
+ },
411
+ onFatal: () => {
412
+ daemonLock.release();
413
+ process.exit(1);
414
+ },
415
+ });
416
+ }
417
+
418
+ function reportRemoteReady(args, state, readiness) {
419
+ if (args.json) {
420
+ printStartJson(state, {
421
+ showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
422
+ notice: readiness.policyMigrated ? "legacy implicit policy migrated to full access" : "",
423
+ });
424
+ return;
425
+ }
426
+ printMcpConnection(state, {
427
+ noPrintCredentials: Boolean(args.noPrintCredentials),
428
+ includeCredentials: readiness.shouldPrintMcpCredentials,
429
+ quiet: Boolean(args.quiet),
430
+ verbose: Boolean(args.verbose),
431
+ policyMigrated: readiness.policyMigrated,
432
+ });
433
+ }
434
+
394
435
  export function resolvePolicy(args = {}, stored = {}) {
395
436
  const hasStored = stored && typeof stored === "object" && (
396
437
  typeof stored.allowWrite === "boolean" || typeof stored.allowExec === "boolean" || typeof stored.execMode === "string"
397
438
  );
398
- const explicitKeys = ["profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
439
+ const explicitKeys = ["profile", ...POLICY_OVERRIDE_KEYS];
399
440
  const hasExplicit = explicitKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key));
400
- let base;
441
+ const base = selectPolicyBase(args, stored, hasStored);
442
+ if (!hasExplicit) return normalizePolicy(base);
443
+ applyPolicyOverrides(base, args);
444
+ if (args.profile === undefined || POLICY_OVERRIDE_KEYS.some((key) => Object.prototype.hasOwnProperty.call(args, key))) {
445
+ base.profile = "custom";
446
+ base.origin = "custom";
447
+ base.revision = DEFAULT_POLICY_REVISION;
448
+ }
449
+ return normalizePolicy(base);
450
+ }
401
451
 
452
+ const POLICY_OVERRIDE_KEYS = Object.freeze(["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"]);
453
+
454
+ function selectPolicyBase(args, stored, hasStored) {
402
455
  if (args.profile !== undefined) {
403
456
  const profile = String(args.profile).trim().toLowerCase();
404
457
  if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
405
- base = policyProfile(profile, "explicit");
406
- } else if (hasStored) {
407
- base = migrateLegacyPolicy(stored);
408
- } else {
409
- base = policyProfile(DEFAULT_POLICY_PROFILE, "default");
458
+ return policyProfile(profile, "explicit");
410
459
  }
460
+ if (hasStored) return migrateLegacyPolicy(stored);
461
+ return policyProfile(DEFAULT_POLICY_PROFILE, "default");
462
+ }
411
463
 
412
- if (!hasExplicit) return normalizePolicy(base);
464
+ function applyPolicyOverrides(policy, args) {
413
465
  if (args.execMode !== undefined) {
414
466
  const execMode = String(args.execMode).trim().toLowerCase();
415
467
  if (!["off", "direct", "shell"].includes(execMode)) throw new Error("--exec-mode must be off, direct, or shell");
416
- base.execMode = execMode;
417
- }
418
- if (args.noWrite === true) base.allowWrite = false;
419
- if (args.noWrite === false) base.allowWrite = true;
420
- if (args.noExec === true) base.execMode = "off";
421
- if (args.noExec === false && base.execMode === "off") base.execMode = "direct";
422
- if (args.fullEnv === true) base.minimalEnv = false;
423
- if (args.fullEnv === false) base.minimalEnv = true;
424
- if (args.unrestrictedPaths === true) base.unrestrictedPaths = true;
425
- if (args.unrestrictedPaths === false) base.unrestrictedPaths = false;
426
- if (args.absolutePaths === true) base.exposeAbsolutePaths = true;
427
- if (args.absolutePaths === false) base.exposeAbsolutePaths = false;
428
- const overrideKeys = ["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
429
- if (args.profile === undefined || overrideKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key))) {
430
- base.profile = "custom";
431
- base.origin = "custom";
432
- base.revision = DEFAULT_POLICY_REVISION;
468
+ policy.execMode = execMode;
433
469
  }
434
- return normalizePolicy(base);
470
+ applyBooleanOverride(args, "noWrite", (enabled) => { policy.allowWrite = !enabled; });
471
+ applyBooleanOverride(args, "noExec", (enabled) => {
472
+ if (enabled) policy.execMode = "off";
473
+ else if (policy.execMode === "off") policy.execMode = "direct";
474
+ });
475
+ applyBooleanOverride(args, "fullEnv", (enabled) => { policy.minimalEnv = !enabled; });
476
+ applyBooleanOverride(args, "unrestrictedPaths", (enabled) => { policy.unrestrictedPaths = enabled; });
477
+ applyBooleanOverride(args, "absolutePaths", (enabled) => { policy.exposeAbsolutePaths = enabled; });
478
+ }
479
+
480
+ function applyBooleanOverride(args, key, apply) {
481
+ if (typeof args[key] === "boolean") apply(args[key]);
435
482
  }
436
483
 
437
484
  function migrateLegacyPolicy(stored = {}) {
@@ -471,119 +518,162 @@ async function stdioCommand(args) {
471
518
  });
472
519
  }
473
520
 
521
+ const RESOURCE_ACTION_HANDLERS = Object.freeze({
522
+ list: resourceListAction,
523
+ add: resourceAddAction,
524
+ "generate-ssh-key": resourceGenerateSshKeyAction,
525
+ remove: resourceRemoveAction,
526
+ check: resourceCheckAction,
527
+ });
528
+
474
529
  async function resourceCommand(args) {
475
530
  const action = String(args._[0] || "list").toLowerCase();
531
+ const handler = RESOURCE_ACTION_HANDLERS[action];
532
+ if (!handler) throw new Error(`Unknown resource action: ${action}`);
476
533
  const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
477
534
  const state = loadState(workspace, { stateDir: args.stateDir });
478
535
  state.resources ||= {};
536
+ return handler({ args, workspace, state });
537
+ }
479
538
 
480
- if (action === "list") {
481
- const resources = publicResourceRegistry(state.resources);
482
- if (args.json) console.log(JSON.stringify({ workspace, resources }, null, 2));
483
- else if (!Object.keys(resources).length) console.log("No local resources registered.");
484
- else for (const [name, value] of Object.entries(resources)) console.log(`${name} ${value.path} ${value.mode || "n/a"} ${value.size ?? "n/a"} bytes`);
539
+ function resourceListAction({ args, workspace, state }) {
540
+ const includePaths = args.showPaths === true;
541
+ const resources = publicResourceRegistry(state.resources, { includePaths });
542
+ if (args.json) {
543
+ console.log(JSON.stringify({
544
+ workspace: includePaths ? workspace : "<local-workspace>",
545
+ paths_exposed: includePaths,
546
+ resources,
547
+ }, null, 2));
485
548
  return;
486
549
  }
487
-
488
- if (action === "add") {
489
- const name = validateResourceName(args._[1]);
490
- const inputPath = args._[2];
491
- if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
492
- const lock = acquireStartupLock(state);
493
- if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
494
- try {
495
- const latest = loadState(workspace, { stateDir: args.stateDir });
496
- latest.resources ||= {};
497
- const inspected = inspectResourceFile(expandHome(inputPath), { allowInsecurePermissions: args.allowInsecurePermissions === true });
498
- if (!Object.prototype.hasOwnProperty.call(latest.resources, name) && Object.keys(latest.resources).length >= 64) {
499
- throw new Error("local resource registry limit reached (64)");
500
- }
501
- latest.resources[name] = inspected;
502
- saveState(latest);
503
- const result = { name, ...inspected, contents_exposed: false, available_to_new_jobs_immediately: true };
504
- if (args.json) console.log(JSON.stringify(result, null, 2));
505
- else {
506
- console.log(`Registered local resource: ${name}`);
507
- console.log(`Path: ${inspected.path}`);
508
- console.log(`Mode: ${inspected.mode || "n/a"}; size: ${inspected.size} bytes`);
509
- console.log("The resource is available to newly submitted managed jobs immediately.");
510
- }
511
- } finally {
512
- lock.release();
513
- }
550
+ if (!Object.keys(resources).length) {
551
+ console.log("No local resources registered.");
514
552
  return;
515
553
  }
554
+ for (const [name, value] of Object.entries(resources)) {
555
+ const fields = [name, value.mode || "n/a", `${value.size ?? "n/a"} bytes`];
556
+ if (includePaths) fields.splice(1, 0, value.path);
557
+ console.log(fields.join(" "));
558
+ }
559
+ }
516
560
 
517
- if (action === "generate-ssh-key") {
518
- const name = validateResourceName(args._[1]);
519
- const home = process.env.HOME || process.env.USERPROFILE;
520
- if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
521
- const requestedPath = args._[2] ? expandHome(args._[2]) : join(home, ".ssh", `machine-mcp-${name}-ed25519`);
522
- const key = await generateRegisteredSshKey({
523
- workspace,
524
- stateDir: args.stateDir,
525
- name,
526
- targetPath: requestedPath,
527
- comment: `machine-mcp:${name}`,
561
+ function resourceAddAction({ args, workspace, state }) {
562
+ const name = validateResourceName(args._[1]);
563
+ const inputPath = args._[2];
564
+ if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
565
+ const lock = acquireStartupLock(state);
566
+ if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
567
+ try {
568
+ const latest = loadState(workspace, { stateDir: args.stateDir });
569
+ latest.resources ||= {};
570
+ const inspected = inspectResourceFile(expandHome(inputPath), { allowInsecurePermissions: args.allowInsecurePermissions === true });
571
+ if (!Object.prototype.hasOwnProperty.call(latest.resources, name) && Object.keys(latest.resources).length >= 64) {
572
+ throw new Error("local resource registry limit reached (64)");
573
+ }
574
+ latest.resources[name] = inspected;
575
+ saveState(latest);
576
+ const result = publicResourceInspection(name, inspected, {
577
+ includePath: args.showPaths === true,
578
+ available_to_new_jobs_immediately: true,
528
579
  });
529
- const result = {
530
- name: key.name,
531
- created: key.created,
532
- private_key_path: key.privateKeyPath,
533
- public_key_path: key.publicKeyPath,
534
- fingerprint: key.fingerprint,
535
- key_type: key.keyType,
536
- private_mode: key.privateMode,
537
- public_mode: key.publicMode,
538
- private_key_content_exposed: key.privateKeyContentExposed,
539
- registered: key.registered,
540
- available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
541
- };
542
580
  if (args.json) console.log(JSON.stringify(result, null, 2));
543
581
  else {
544
- console.log(`${key.created ? "Generated and registered" : "Reused and registered"} SSH key resource: ${name}`);
582
+ console.log(`Registered local resource: ${name}`);
583
+ if (args.showPaths === true) console.log(`Path: ${inspected.path}`);
584
+ console.log(`Mode: ${inspected.mode || "n/a"}; size: ${inspected.size} bytes`);
585
+ console.log("The resource is available to newly submitted managed jobs immediately.");
586
+ }
587
+ } finally {
588
+ lock.release();
589
+ }
590
+ }
591
+
592
+ async function resourceGenerateSshKeyAction({ args, workspace }) {
593
+ const name = validateResourceName(args._[1]);
594
+ const home = process.env.HOME || process.env.USERPROFILE;
595
+ if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
596
+ const requestedPath = args._[2] ? expandHome(args._[2]) : join(home, ".ssh", `machine-mcp-${name}-ed25519`);
597
+ const key = await generateRegisteredSshKey({
598
+ workspace,
599
+ stateDir: args.stateDir,
600
+ name,
601
+ targetPath: requestedPath,
602
+ comment: `machine-mcp:${name}`,
603
+ });
604
+ const includePaths = args.showPaths === true;
605
+ const result = {
606
+ name: key.name,
607
+ created: key.created,
608
+ fingerprint: key.fingerprint,
609
+ key_type: key.keyType,
610
+ private_mode: key.privateMode,
611
+ public_mode: key.publicMode,
612
+ private_key_content_exposed: key.privateKeyContentExposed,
613
+ registered: key.registered,
614
+ available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
615
+ paths_exposed: includePaths,
616
+ ...(includePaths ? { private_key_path: key.privateKeyPath, public_key_path: key.publicKeyPath } : {}),
617
+ };
618
+ if (args.json) console.log(JSON.stringify(result, null, 2));
619
+ else {
620
+ console.log(`${key.created ? "Generated and registered" : "Reused and registered"} SSH key resource: ${name}`);
621
+ if (includePaths) {
545
622
  console.log(`Private key: ${key.privateKeyPath}`);
546
623
  console.log(`Public key: ${key.publicKeyPath}`);
547
- console.log(`Fingerprint: ${key.fingerprint}`);
548
- console.log("Private key content was not printed or sent through MCP.");
549
624
  }
550
- return;
625
+ console.log(`Fingerprint: ${key.fingerprint}`);
626
+ console.log("Private key content was not printed or sent through MCP.");
551
627
  }
628
+ }
552
629
 
553
- if (action === "remove") {
554
- const name = validateResourceName(args._[1]);
555
- const lock = acquireStartupLock(state);
556
- if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
557
- try {
558
- const latest = loadState(workspace, { stateDir: args.stateDir });
559
- latest.resources ||= {};
560
- const existed = Object.prototype.hasOwnProperty.call(latest.resources, name);
561
- delete latest.resources[name];
562
- saveState(latest);
563
- const result = { name, removed: existed, affects_new_jobs_immediately: true };
564
- if (args.json) console.log(JSON.stringify(result, null, 2));
565
- else {
566
- console.log(existed ? `Removed local resource: ${name}` : `Local resource was not registered: ${name}`);
567
- console.log("The change applies to newly submitted managed jobs immediately.");
568
- }
569
- } finally {
570
- lock.release();
630
+ function resourceRemoveAction({ args, workspace, state }) {
631
+ const name = validateResourceName(args._[1]);
632
+ const lock = acquireStartupLock(state);
633
+ if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
634
+ try {
635
+ const latest = loadState(workspace, { stateDir: args.stateDir });
636
+ latest.resources ||= {};
637
+ const existed = Object.prototype.hasOwnProperty.call(latest.resources, name);
638
+ delete latest.resources[name];
639
+ saveState(latest);
640
+ const result = { name, removed: existed, affects_new_jobs_immediately: true };
641
+ if (args.json) console.log(JSON.stringify(result, null, 2));
642
+ else {
643
+ console.log(existed ? `Removed local resource: ${name}` : `Local resource was not registered: ${name}`);
644
+ console.log("The change applies to newly submitted managed jobs immediately.");
571
645
  }
572
- return;
646
+ } finally {
647
+ lock.release();
573
648
  }
649
+ }
574
650
 
575
- if (action === "check") {
576
- const name = validateResourceName(args._[1]);
577
- const resource = state.resources[name];
578
- if (!resource) throw new Error(`local resource is not registered: ${name}`);
579
- const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
580
- const result = { name, ...inspected, contents_exposed: false };
581
- if (args.json) console.log(JSON.stringify(result, null, 2));
582
- else console.log(`${name}: available (${inspected.mode || "n/a"}, ${inspected.size} bytes)`);
583
- return;
651
+ function resourceCheckAction({ args, state }) {
652
+ const name = validateResourceName(args._[1]);
653
+ const resource = state.resources[name];
654
+ if (!resource) throw new Error(`local resource is not registered: ${name}`);
655
+ const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
656
+ const result = publicResourceInspection(name, inspected, { includePath: args.showPaths === true });
657
+ if (args.json) console.log(JSON.stringify(result, null, 2));
658
+ else {
659
+ const pathDetail = args.showPaths === true ? ` at ${inspected.path}` : "";
660
+ console.log(`${name}: available${pathDetail} (${inspected.mode || "n/a"}, ${inspected.size} bytes)`);
584
661
  }
662
+ }
585
663
 
586
- throw new Error(`Unknown resource action: ${action}`);
664
+ function publicResourceInspection(name, inspected, { includePath = false, ...extra } = {}) {
665
+ return {
666
+ name,
667
+ kind: inspected.kind,
668
+ size: inspected.size ?? null,
669
+ mode: inspected.mode ?? null,
670
+ updated_at: inspected.updatedAt ?? null,
671
+ allow_insecure_permissions: inspected.allowInsecurePermissions === true,
672
+ ...extra,
673
+ paths_exposed: includePath,
674
+ contents_exposed: false,
675
+ ...(includePath ? { path: inspected.path } : {}),
676
+ };
587
677
  }
588
678
 
589
679
  async function jobCommand(args) {
@@ -667,7 +757,8 @@ async function ensureWorker(state, args) {
667
757
  logger.success("Worker unchanged and healthy", { url: state.worker.url });
668
758
  return state.worker;
669
759
  }
670
- logger.warn("Worker health check failed; redeploying", { error: health.error });
760
+ logger.warn("Worker is not healthy at the expected version; redeploying automatically", { reason: workerHealthUserReason(health.error) });
761
+ logger.debug("Worker health check detail", { health_error: health.error });
671
762
  }
672
763
 
673
764
  logger.info("Checking Cloudflare Wrangler login");
@@ -791,6 +882,18 @@ async function workerHealth(workerUrl, expectedVersion = currentPackageVersion()
791
882
  }
792
883
  }
793
884
 
885
+ export function workerHealthUserReason(value) {
886
+ const reason = String(value || "");
887
+ if (reason.startsWith("version_mismatch:")) return "deployed version does not match the local package";
888
+ if (/^HTTP \d+$/.test(reason)) return "health endpoint returned an HTTP error";
889
+ if (reason === "unexpected_health_response") return "health endpoint returned an unexpected response";
890
+ if (reason === "timeout") return "health check timed out";
891
+ if (reason === "tls_error") return "TLS validation failed";
892
+ if (reason === "network_error") return "network request failed";
893
+ if (reason === "missing_worker_url") return "Worker URL is missing";
894
+ return "health check failed";
895
+ }
896
+
794
897
  function workerHealthError(error) {
795
898
  const message = String(error?.message || error || "");
796
899
  if (/timeout|aborted/i.test(message)) return "timeout";
@@ -816,17 +919,6 @@ function extractWorkerUrl(text = "") {
816
919
  return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
817
920
  }
818
921
 
819
- async function waitForConnectWithNotice(promise, timeoutMs, logger) {
820
- let timeout;
821
- const timed = new Promise(resolvePromise => {
822
- timeout = setTimeout(() => resolvePromise("timeout"), timeoutMs);
823
- });
824
- const result = await Promise.race([promise.then(() => "connected"), timed]);
825
- clearTimeout(timeout);
826
- if (result === "timeout") logger.warn("Still connecting; the process will keep retrying");
827
- }
828
-
829
-
830
922
  function printStartJson(state, { showCredentials = false, requestedChangesApplied = true, notice = "" } = {}) {
831
923
  createLogger({ component: "ready" }).json({
832
924
  mcp: {
@@ -915,7 +1007,7 @@ async function statusCommand(args) {
915
1007
  async function doctorCommand(args) {
916
1008
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
917
1009
  const checks = [];
918
- checks.push({ name: "node", ok: Number(process.versions.node.split(".")[0]) >= 22, detail: process.version });
1010
+ checks.push({ name: "node", ok: isSupportedNodeVersion(), detail: process.version });
919
1011
  const wrangler = await runWrangler(["--version"], { capture: true, allowFailure: true });
920
1012
  checks.push({ name: "wrangler", ok: wrangler.code === 0, detail: (wrangler.stdout || wrangler.stderr).trim() });
921
1013
  const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
@@ -934,7 +1026,7 @@ async function doctorCommand(args) {
934
1026
  }
935
1027
  const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
936
1028
  checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
937
- const diagnosticRuntime = new LocalDaemon({
1029
+ const diagnosticRuntime = new LocalRuntime({
938
1030
  workspace,
939
1031
  policy: state.policy,
940
1032
  logger: createLogger({ level: "error", component: "doctor" }),
@@ -1218,16 +1310,20 @@ function validateWorkerName(value) {
1218
1310
  return name;
1219
1311
  }
1220
1312
 
1313
+ export function isSupportedNodeVersion(version = process.versions.node) {
1314
+ const major = Number(String(version || "").split(".")[0]);
1315
+ return Number.isInteger(major) && major >= 26;
1316
+ }
1317
+
1221
1318
  function assertNodeVersion() {
1222
- const major = Number(process.versions.node.split(".")[0]);
1223
- if (major < 22) throw new Error(`Node.js >=22 is required; current ${process.version}`);
1319
+ if (!isSupportedNodeVersion()) throw new Error(`Node.js >=26 is required; current ${process.version}`);
1224
1320
  }
1225
1321
 
1226
1322
  function usage() {
1227
1323
  console.log(`machine-bridge-mcp
1228
1324
 
1229
1325
  Usage:
1230
- npm install -g --allow-scripts=esbuild,workerd,sharp machine-bridge-mcp@latest && machine-mcp
1326
+ npm install -g --omit=optional --allow-scripts=esbuild,workerd,sharp,fsevents machine-bridge-mcp@latest && machine-mcp
1231
1327
  npx machine-bridge-mcp@latest # no global install; autostart may rely on npm cache
1232
1328
  ./mbm # from source checkout
1233
1329
  .\\mbm.cmd # from source checkout on Windows cmd
@@ -1274,6 +1370,7 @@ Start options:
1274
1370
  --quiet Alias for --log-level error
1275
1371
  --allow-insecure-permissions
1276
1372
  Permit resource registration when a file is group/other-readable
1373
+ --show-paths Include local absolute paths in resource command output
1277
1374
 
1278
1375
  Uninstall options:
1279
1376
  --keep-worker Do not delete deployed Worker(s) during uninstall