machine-bridge-mcp 0.7.1 → 0.8.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/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";
@@ -46,6 +46,22 @@ 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 = {
@@ -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");
165
+ }
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
+ };
164
175
  }
165
- if (command === "uninstall" && count) throw new Error("uninstall does not accept positional arguments");
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
+ }
451
+
452
+ const POLICY_OVERRIDE_KEYS = Object.freeze(["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"]);
401
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,137 +518,147 @@ 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 includePaths = args.showPaths === true;
482
- const resources = publicResourceRegistry(state.resources, { includePaths });
483
- if (args.json) console.log(JSON.stringify({
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({
484
544
  workspace: includePaths ? workspace : "<local-workspace>",
485
545
  paths_exposed: includePaths,
486
546
  resources,
487
547
  }, null, 2));
488
- else if (!Object.keys(resources).length) console.log("No local resources registered.");
489
- else for (const [name, value] of Object.entries(resources)) {
490
- const fields = [name, value.mode || "n/a", `${value.size ?? "n/a"} bytes`];
491
- if (includePaths) fields.splice(1, 0, value.path);
492
- console.log(fields.join("\t"));
493
- }
494
548
  return;
495
549
  }
496
-
497
- if (action === "add") {
498
- const name = validateResourceName(args._[1]);
499
- const inputPath = args._[2];
500
- if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
501
- const lock = acquireStartupLock(state);
502
- if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
503
- try {
504
- const latest = loadState(workspace, { stateDir: args.stateDir });
505
- latest.resources ||= {};
506
- const inspected = inspectResourceFile(expandHome(inputPath), { allowInsecurePermissions: args.allowInsecurePermissions === true });
507
- if (!Object.prototype.hasOwnProperty.call(latest.resources, name) && Object.keys(latest.resources).length >= 64) {
508
- throw new Error("local resource registry limit reached (64)");
509
- }
510
- latest.resources[name] = inspected;
511
- saveState(latest);
512
- const result = publicResourceInspection(name, inspected, {
513
- includePath: args.showPaths === true,
514
- available_to_new_jobs_immediately: true,
515
- });
516
- if (args.json) console.log(JSON.stringify(result, null, 2));
517
- else {
518
- console.log(`Registered local resource: ${name}`);
519
- if (args.showPaths === true) console.log(`Path: ${inspected.path}`);
520
- console.log(`Mode: ${inspected.mode || "n/a"}; size: ${inspected.size} bytes`);
521
- console.log("The resource is available to newly submitted managed jobs immediately.");
522
- }
523
- } finally {
524
- lock.release();
525
- }
550
+ if (!Object.keys(resources).length) {
551
+ console.log("No local resources registered.");
526
552
  return;
527
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
+ }
528
560
 
529
- if (action === "generate-ssh-key") {
530
- const name = validateResourceName(args._[1]);
531
- const home = process.env.HOME || process.env.USERPROFILE;
532
- if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
533
- const requestedPath = args._[2] ? expandHome(args._[2]) : join(home, ".ssh", `machine-mcp-${name}-ed25519`);
534
- const key = await generateRegisteredSshKey({
535
- workspace,
536
- stateDir: args.stateDir,
537
- name,
538
- targetPath: requestedPath,
539
- 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,
540
579
  });
541
- const includePaths = args.showPaths === true;
542
- const result = {
543
- name: key.name,
544
- created: key.created,
545
- fingerprint: key.fingerprint,
546
- key_type: key.keyType,
547
- private_mode: key.privateMode,
548
- public_mode: key.publicMode,
549
- private_key_content_exposed: key.privateKeyContentExposed,
550
- registered: key.registered,
551
- available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
552
- paths_exposed: includePaths,
553
- ...(includePaths ? { private_key_path: key.privateKeyPath, public_key_path: key.publicKeyPath } : {}),
554
- };
555
580
  if (args.json) console.log(JSON.stringify(result, null, 2));
556
581
  else {
557
- console.log(`${key.created ? "Generated and registered" : "Reused and registered"} SSH key resource: ${name}`);
558
- if (includePaths) {
559
- console.log(`Private key: ${key.privateKeyPath}`);
560
- console.log(`Public key: ${key.publicKeyPath}`);
561
- }
562
- console.log(`Fingerprint: ${key.fingerprint}`);
563
- console.log("Private key content was not printed or sent through MCP.");
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.");
564
586
  }
565
- return;
587
+ } finally {
588
+ lock.release();
566
589
  }
590
+ }
567
591
 
568
- if (action === "remove") {
569
- const name = validateResourceName(args._[1]);
570
- const lock = acquireStartupLock(state);
571
- if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
572
- try {
573
- const latest = loadState(workspace, { stateDir: args.stateDir });
574
- latest.resources ||= {};
575
- const existed = Object.prototype.hasOwnProperty.call(latest.resources, name);
576
- delete latest.resources[name];
577
- saveState(latest);
578
- const result = { name, removed: existed, affects_new_jobs_immediately: true };
579
- if (args.json) console.log(JSON.stringify(result, null, 2));
580
- else {
581
- console.log(existed ? `Removed local resource: ${name}` : `Local resource was not registered: ${name}`);
582
- console.log("The change applies to newly submitted managed jobs immediately.");
583
- }
584
- } finally {
585
- lock.release();
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) {
622
+ console.log(`Private key: ${key.privateKeyPath}`);
623
+ console.log(`Public key: ${key.publicKeyPath}`);
586
624
  }
587
- return;
625
+ console.log(`Fingerprint: ${key.fingerprint}`);
626
+ console.log("Private key content was not printed or sent through MCP.");
588
627
  }
628
+ }
589
629
 
590
- if (action === "check") {
591
- const name = validateResourceName(args._[1]);
592
- const resource = state.resources[name];
593
- if (!resource) throw new Error(`local resource is not registered: ${name}`);
594
- const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
595
- const result = publicResourceInspection(name, inspected, { includePath: args.showPaths === true });
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 };
596
641
  if (args.json) console.log(JSON.stringify(result, null, 2));
597
642
  else {
598
- const pathDetail = args.showPaths === true ? ` at ${inspected.path}` : "";
599
- console.log(`${name}: available${pathDetail} (${inspected.mode || "n/a"}, ${inspected.size} bytes)`);
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.");
600
645
  }
601
- return;
646
+ } finally {
647
+ lock.release();
602
648
  }
649
+ }
603
650
 
604
- throw new Error(`Unknown resource action: ${action}`);
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)`);
661
+ }
605
662
  }
606
663
 
607
664
  function publicResourceInspection(name, inspected, { includePath = false, ...extra } = {}) {
@@ -700,7 +757,8 @@ async function ensureWorker(state, args) {
700
757
  logger.success("Worker unchanged and healthy", { url: state.worker.url });
701
758
  return state.worker;
702
759
  }
703
- 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 });
704
762
  }
705
763
 
706
764
  logger.info("Checking Cloudflare Wrangler login");
@@ -824,6 +882,18 @@ async function workerHealth(workerUrl, expectedVersion = currentPackageVersion()
824
882
  }
825
883
  }
826
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
+
827
897
  function workerHealthError(error) {
828
898
  const message = String(error?.message || error || "");
829
899
  if (/timeout|aborted/i.test(message)) return "timeout";
@@ -849,17 +919,6 @@ function extractWorkerUrl(text = "") {
849
919
  return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
850
920
  }
851
921
 
852
- async function waitForConnectWithNotice(promise, timeoutMs, logger) {
853
- let timeout;
854
- const timed = new Promise(resolvePromise => {
855
- timeout = setTimeout(() => resolvePromise("timeout"), timeoutMs);
856
- });
857
- const result = await Promise.race([promise.then(() => "connected"), timed]);
858
- clearTimeout(timeout);
859
- if (result === "timeout") logger.warn("Still connecting; the process will keep retrying");
860
- }
861
-
862
-
863
922
  function printStartJson(state, { showCredentials = false, requestedChangesApplied = true, notice = "" } = {}) {
864
923
  createLogger({ component: "ready" }).json({
865
924
  mcp: {
@@ -967,7 +1026,7 @@ async function doctorCommand(args) {
967
1026
  }
968
1027
  const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
969
1028
  checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
970
- const diagnosticRuntime = new LocalDaemon({
1029
+ const diagnosticRuntime = new LocalRuntime({
971
1030
  workspace,
972
1031
  policy: state.policy,
973
1032
  logger: createLogger({ level: "error", component: "doctor" }),
@@ -1264,7 +1323,7 @@ function usage() {
1264
1323
  console.log(`machine-bridge-mcp
1265
1324
 
1266
1325
  Usage:
1267
- 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
1268
1327
  npx machine-bridge-mcp@latest # no global install; autostart may rely on npm cache
1269
1328
  ./mbm # from source checkout
1270
1329
  .\\mbm.cmd # from source checkout on Windows cmd