machine-bridge-mcp 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/local/cli.mjs CHANGED
@@ -1,12 +1,13 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
3
- import path, { resolve } from "node:path";
3
+ import path, { join, resolve } from "node:path";
4
4
  import process from "node:process";
5
5
  import readline from "node:readline/promises";
6
6
  import { LocalDaemon } from "./daemon.mjs";
7
7
  import { runStdioServer } from "./stdio.mjs";
8
- import { normalizePolicy } from "./tools.mjs";
9
- import { createLogger, sanitizeLogText } from "./log.mjs";
8
+ import { DEFAULT_POLICY_PROFILE, DEFAULT_POLICY_REVISION, POLICY_PROFILES, normalizePolicy, policyProfile } from "./tools.mjs";
9
+ import { classifyOperationalError, createLogger, normalizeLogLevel, sanitizeLogText } from "./log.mjs";
10
+ import { activeManagedJobs, inspectResourceFile, loadManagedJobPlan, ManagedJobManager, publicResourceRegistry, validateResourceName } from "./managed-jobs.mjs";
10
11
  import { runWrangler } from "./shell.mjs";
11
12
  import {
12
13
  acquireDaemonLock,
@@ -37,10 +38,10 @@ const BOOLEAN_OPTIONS = new Set([
37
38
  "help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
38
39
  "daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
39
40
  "printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
40
- "yes", "keepWorker", "noApi", "api", "rotateApiKey",
41
+ "yes", "keepWorker", "allowInsecurePermissions",
41
42
  ]);
42
43
  const VALUE_OPTIONS = new Set([
43
- "workspace", "stateDir", "workerName", "profile", "execMode", "client", "apiPort", "apiHost", "apiKey", "apiModel", "port",
44
+ "workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
44
45
  ]);
45
46
 
46
47
  export async function main(argv = process.argv.slice(2)) {
@@ -51,6 +52,7 @@ export async function main(argv = process.argv.slice(2)) {
51
52
  if (command === "api") throw removedLocalApiError();
52
53
  validateCommandOptions(command, args);
53
54
  validatePositionals(command, args);
55
+ validateLoggingOptions(args);
54
56
 
55
57
  switch (command) {
56
58
  case "start": return startCommand(args);
@@ -62,6 +64,8 @@ export async function main(argv = process.argv.slice(2)) {
62
64
  case "service":
63
65
  case "autostart": return serviceCommand(args);
64
66
  case "rotate-secrets": return rotateSecretsCommand(args);
67
+ case "resource": return resourceCommand(args);
68
+ case "job": return jobCommand(args);
65
69
  case "uninstall": return uninstallCommand(args);
66
70
  default:
67
71
  console.error(`Unknown command: ${command}`);
@@ -72,19 +76,20 @@ export async function main(argv = process.argv.slice(2)) {
72
76
 
73
77
  const COMMAND_OPTIONS = {
74
78
  start: new Set([
75
- "workspace", "stateDir", "workerName", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
79
+ "workspace", "stateDir", "workerName", "quiet", "json", "verbose", "logLevel", "rotateSecrets", "forceWorker",
76
80
  "daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials", "printCredentials",
77
81
  "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
78
- "noApi", "api", "apiPort", "apiHost", "apiKey", "rotateApiKey", "apiModel", "port",
79
82
  ]),
80
- stdio: new Set(["workspace", "stateDir", "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths", "verbose"]),
83
+ stdio: new Set(["workspace", "stateDir", "profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths", "verbose", "quiet", "logLevel"]),
81
84
  "client-config": new Set(["workspace", "stateDir", "profile", "client", "json"]),
82
85
  status: new Set(["workspace", "stateDir"]),
83
86
  doctor: new Set(["workspace", "stateDir"]),
84
- "rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "quiet"]),
87
+ "rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "printMcpCredentials", "printCredentials", "quiet"]),
85
88
  workspace: new Set(["workspace", "stateDir"]),
86
89
  service: new Set(["workspace", "stateDir", "quiet"]),
87
90
  autostart: new Set(["workspace", "stateDir", "quiet"]),
91
+ resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "json"]),
92
+ job: new Set(["workspace", "stateDir", "json", "yes"]),
88
93
  uninstall: new Set(["stateDir", "keepWorker", "yes"]),
89
94
  };
90
95
 
@@ -97,14 +102,27 @@ export function validateCommandOptions(command, args) {
97
102
  }
98
103
  }
99
104
 
105
+ export function validateLoggingOptions(args = {}) {
106
+ if (args.quiet && args.verbose) throw new Error("--quiet and --verbose cannot be used together");
107
+ if (args.logLevel !== undefined && (args.quiet || args.verbose)) {
108
+ throw new Error("--log-level cannot be combined with --quiet or --verbose");
109
+ }
110
+ if (args.logLevel !== undefined) normalizeLogLevel(args.logLevel);
111
+ }
112
+
113
+ function effectiveLogLevel(args = {}) {
114
+ if (args.logLevel !== undefined) return normalizeLogLevel(args.logLevel);
115
+ if (args.quiet) return "error";
116
+ if (args.verbose) return "debug";
117
+ return "info";
118
+ }
119
+
100
120
  function toKebab(value) {
101
121
  return String(value).replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
102
122
  }
103
123
 
104
124
  export function validatePositionals(command, args) {
105
125
  const count = args._.length;
106
- const conflict = Boolean(args.workspace) && count > (command === "workspace" || command === "service" || command === "autostart" ? 1 : 0);
107
- if (conflict) throw new Error("workspace path was provided both positionally and with --workspace");
108
126
  if (["start", "stdio", "status", "doctor", "rotate-secrets"].includes(command)) {
109
127
  if (count > 1) throw new Error(`${command} accepts at most one positional workspace path`);
110
128
  if (args.workspace && count) throw new Error("workspace path was provided both positionally and with --workspace");
@@ -128,6 +146,18 @@ export function validatePositionals(command, args) {
128
146
  if (count > 1) throw new Error("client-config accepts at most one positional client name");
129
147
  return;
130
148
  }
149
+ if (command === "resource") {
150
+ const action = String(args._[0] || "list");
151
+ const max = action === "add" ? 3 : action === "remove" || action === "check" ? 2 : 1;
152
+ if (count > max) throw new Error(`resource ${action} received too many positional arguments`);
153
+ return;
154
+ }
155
+ if (command === "job") {
156
+ const action = String(args._[0] || "list");
157
+ const max = action === "read" || action === "inspect" || action === "cancel" || action === "approve" || action === "submit" ? 2 : 1;
158
+ if (count > max) throw new Error(`job ${action} received too many positional arguments`);
159
+ return;
160
+ }
131
161
  if (command === "uninstall" && count) throw new Error("uninstall does not accept positional arguments");
132
162
  }
133
163
 
@@ -256,8 +286,7 @@ async function confirm(prompt, assumeYes = false) {
256
286
 
257
287
  async function startCommand(args) {
258
288
  assertNodeVersion();
259
- assertNoRemovedLocalApiOptions(args);
260
- const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "cli" });
289
+ const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), component: "cli" });
261
290
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: true, save: true, allowPositional: true });
262
291
  const state = loadState(workspace, { stateDir: args.stateDir });
263
292
  const startupLock = acquireStartupLock(state);
@@ -273,7 +302,7 @@ async function startCommand(args) {
273
302
  } else {
274
303
  // Stop an installed service before acquiring the runtime lock. If a
275
304
  // foreground daemon owns the lock, no new policy or secret state is saved.
276
- await stopAutostartBestEffort(Boolean(args.quiet));
305
+ await stopAutostartBestEffort(logger);
277
306
  }
278
307
 
279
308
  const lock = acquireDaemonLock(state);
@@ -281,7 +310,7 @@ async function startCommand(args) {
281
310
  const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
282
311
  logger.warn(`local daemon already running for this workspace (${pid}); requested changes were not applied`);
283
312
  if (args.json) printStartJson(state, {
284
- noPrintCredentials: Boolean(args.noPrintCredentials),
313
+ showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
285
314
  requestedChangesApplied: false,
286
315
  notice: "local daemon already running; requested changes were not applied",
287
316
  });
@@ -289,6 +318,7 @@ async function startCommand(args) {
289
318
  noPrintCredentials: Boolean(args.noPrintCredentials),
290
319
  includeCredentials: Boolean(args.printMcpCredentials || args.printCredentials),
291
320
  quiet: Boolean(args.quiet),
321
+ verbose: Boolean(args.verbose),
292
322
  });
293
323
  return;
294
324
  }
@@ -299,7 +329,9 @@ async function startCommand(args) {
299
329
  const firstMcpConnection = !previousMcpServerUrl || !state.worker?.oauthPassword;
300
330
  const workerName = validateWorkerName(args.workerName);
301
331
  ensureWorkerSecrets(state, { rotateSecrets: Boolean(args.rotateSecrets), workerName });
332
+ const previousPolicyOrigin = state.policy?.origin;
302
333
  state.policy = resolvePolicy(args, state.policy);
334
+ const policyMigrated = !previousPolicyOrigin && state.policy.origin === "migrated";
303
335
  state.policy.updatedAt = new Date().toISOString();
304
336
  saveState(state);
305
337
 
@@ -307,10 +339,10 @@ async function startCommand(args) {
307
339
  else if (!state.worker.url) throw new Error("--daemon-only requires an existing worker URL in state; run start once without --daemon-only");
308
340
 
309
341
  const mcpConnectionChanged = previousMcpServerUrl && previousMcpServerUrl !== state.worker.mcpServerUrl;
310
- const shouldPrintMcpCredentials = Boolean(args.json || args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
342
+ const shouldPrintMcpCredentials = Boolean(args.printMcpCredentials || args.printCredentials || firstMcpConnection || args.rotateSecrets || mcpConnectionChanged);
311
343
 
312
344
  if (!args.daemonOnly && !args.noAutostart) {
313
- await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1] });
345
+ await installAutostartBestEffort({ workspace, stateRoot: state.paths.stateRoot, entryScript: process.argv[1], logger });
314
346
  }
315
347
 
316
348
  daemon = new LocalDaemon({
@@ -318,7 +350,10 @@ async function startCommand(args) {
318
350
  secret: state.worker.daemonSecret,
319
351
  workspace,
320
352
  policy: state.policy,
321
- logger: createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "daemon" }),
353
+ logger: createLogger({ level: args.json ? "error" : effectiveLogLevel(args), component: "daemon" }),
354
+ jobRoot: join(state.paths.profileDir, "jobs"),
355
+ resources: state.resources,
356
+ resourceStatePath: state.paths.statePath,
322
357
  onSuperseded: () => {
323
358
  logger.warn("this daemon was replaced by a newer authenticated instance; exiting without reconnecting");
324
359
  lock.release();
@@ -327,13 +362,18 @@ async function startCommand(args) {
327
362
  });
328
363
 
329
364
  const waitForConnect = daemon.start();
330
- await waitForConnectWithNotice(waitForConnect, 20_000, Boolean(args.quiet || args.json));
331
- if (args.json) printStartJson(state, { noPrintCredentials: Boolean(args.noPrintCredentials) });
365
+ await waitForConnectWithNotice(waitForConnect, 20_000, logger);
366
+ if (args.json) printStartJson(state, {
367
+ showCredentials: Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials),
368
+ notice: policyMigrated ? "legacy implicit policy migrated to full access" : "",
369
+ });
332
370
  else {
333
371
  printMcpConnection(state, {
334
372
  noPrintCredentials: Boolean(args.noPrintCredentials),
335
373
  includeCredentials: shouldPrintMcpCredentials,
336
374
  quiet: Boolean(args.quiet),
375
+ verbose: Boolean(args.verbose),
376
+ policyMigrated,
337
377
  });
338
378
  }
339
379
  keepProcessAlive({ daemon, lock, logger });
@@ -347,13 +387,6 @@ async function startCommand(args) {
347
387
  }
348
388
  }
349
389
 
350
- const POLICY_PROFILES = Object.freeze({
351
- review: Object.freeze({ profile: "review", allowWrite: false, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
352
- edit: Object.freeze({ profile: "edit", allowWrite: true, execMode: "off", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
353
- agent: Object.freeze({ profile: "agent", allowWrite: true, execMode: "direct", unrestrictedPaths: false, minimalEnv: true, exposeAbsolutePaths: false }),
354
- full: Object.freeze({ profile: "full", allowWrite: true, execMode: "shell", unrestrictedPaths: true, minimalEnv: false, exposeAbsolutePaths: true }),
355
- });
356
-
357
390
  export function resolvePolicy(args = {}, stored = {}) {
358
391
  const hasStored = stored && typeof stored === "object" && (
359
392
  typeof stored.allowWrite === "boolean" || typeof stored.allowExec === "boolean" || typeof stored.execMode === "string"
@@ -361,14 +394,15 @@ export function resolvePolicy(args = {}, stored = {}) {
361
394
  const explicitKeys = ["profile", "execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
362
395
  const hasExplicit = explicitKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key));
363
396
  let base;
397
+
364
398
  if (args.profile !== undefined) {
365
399
  const profile = String(args.profile).trim().toLowerCase();
366
400
  if (!POLICY_PROFILES[profile]) throw new Error(`--profile must be one of: ${Object.keys(POLICY_PROFILES).join(", ")}`);
367
- base = { ...POLICY_PROFILES[profile] };
401
+ base = policyProfile(profile, "explicit");
368
402
  } else if (hasStored) {
369
- base = normalizePolicy(stored);
403
+ base = migrateLegacyPolicy(stored);
370
404
  } else {
371
- base = { ...POLICY_PROFILES.full };
405
+ base = policyProfile(DEFAULT_POLICY_PROFILE, "default");
372
406
  }
373
407
 
374
408
  if (!hasExplicit) return normalizePolicy(base);
@@ -388,16 +422,167 @@ export function resolvePolicy(args = {}, stored = {}) {
388
422
  if (args.absolutePaths === true) base.exposeAbsolutePaths = true;
389
423
  if (args.absolutePaths === false) base.exposeAbsolutePaths = false;
390
424
  const overrideKeys = ["execMode", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths"];
391
- if (args.profile === undefined || overrideKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key))) base.profile = "custom";
425
+ if (args.profile === undefined || overrideKeys.some((key) => Object.prototype.hasOwnProperty.call(args, key))) {
426
+ base.profile = "custom";
427
+ base.origin = "custom";
428
+ base.revision = DEFAULT_POLICY_REVISION;
429
+ }
392
430
  return normalizePolicy(base);
393
431
  }
394
432
 
433
+ function migrateLegacyPolicy(stored = {}) {
434
+ if (stored.origin === "default" && Number(stored.revision || 0) < DEFAULT_POLICY_REVISION) {
435
+ return policyProfile(DEFAULT_POLICY_PROFILE, "default");
436
+ }
437
+ if (stored.origin === "migrated" && Number(stored.revision || 0) < DEFAULT_POLICY_REVISION) {
438
+ return policyProfile(DEFAULT_POLICY_PROFILE, "migrated");
439
+ }
440
+ if (stored.origin) return normalizePolicy(stored);
441
+ const normalized = normalizePolicy(stored);
442
+ const looksLikeLegacyImplicitDefault = (
443
+ normalized.profile === "custom" &&
444
+ normalized.allowWrite === true &&
445
+ normalized.execMode === "shell" &&
446
+ normalized.unrestrictedPaths === false &&
447
+ normalized.minimalEnv === true &&
448
+ normalized.exposeAbsolutePaths === false
449
+ );
450
+ if (looksLikeLegacyImplicitDefault) return policyProfile("full", "migrated");
451
+ return normalizePolicy({ ...normalized, origin: "legacy-preserved", revision: DEFAULT_POLICY_REVISION });
452
+ }
453
+
454
+
395
455
  async function stdioCommand(args) {
396
456
  assertNodeVersion();
397
457
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
398
458
  const state = loadState(workspace, { stateDir: args.stateDir });
399
459
  const policy = resolvePolicy(args, state.policy);
400
- await runStdioServer({ workspace, policy, verbose: Boolean(args.verbose) });
460
+ await runStdioServer({
461
+ workspace,
462
+ policy,
463
+ logLevel: effectiveLogLevel(args),
464
+ jobRoot: join(state.paths.profileDir, "jobs"),
465
+ resources: state.resources,
466
+ resourceStatePath: state.paths.statePath,
467
+ });
468
+ }
469
+
470
+ async function resourceCommand(args) {
471
+ const action = String(args._[0] || "list").toLowerCase();
472
+ const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
473
+ const state = loadState(workspace, { stateDir: args.stateDir });
474
+ state.resources ||= {};
475
+
476
+ if (action === "list") {
477
+ const resources = publicResourceRegistry(state.resources);
478
+ if (args.json) console.log(JSON.stringify({ workspace, resources }, null, 2));
479
+ else if (!Object.keys(resources).length) console.log("No local resources registered.");
480
+ else for (const [name, value] of Object.entries(resources)) console.log(`${name} ${value.path} ${value.mode || "n/a"} ${value.size ?? "n/a"} bytes`);
481
+ return;
482
+ }
483
+
484
+ if (action === "add") {
485
+ const name = validateResourceName(args._[1]);
486
+ const inputPath = args._[2];
487
+ if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
488
+ const lock = acquireStartupLock(state);
489
+ if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
490
+ try {
491
+ const latest = loadState(workspace, { stateDir: args.stateDir });
492
+ latest.resources ||= {};
493
+ const inspected = inspectResourceFile(expandHome(inputPath), { allowInsecurePermissions: args.allowInsecurePermissions === true });
494
+ if (!Object.prototype.hasOwnProperty.call(latest.resources, name) && Object.keys(latest.resources).length >= 64) {
495
+ throw new Error("local resource registry limit reached (64)");
496
+ }
497
+ latest.resources[name] = inspected;
498
+ saveState(latest);
499
+ const result = { name, ...inspected, contents_exposed: false, available_to_new_jobs_immediately: true };
500
+ if (args.json) console.log(JSON.stringify(result, null, 2));
501
+ else {
502
+ console.log(`Registered local resource: ${name}`);
503
+ console.log(`Path: ${inspected.path}`);
504
+ console.log(`Mode: ${inspected.mode || "n/a"}; size: ${inspected.size} bytes`);
505
+ console.log("The resource is available to newly submitted managed jobs immediately.");
506
+ }
507
+ } finally {
508
+ lock.release();
509
+ }
510
+ return;
511
+ }
512
+
513
+ if (action === "remove") {
514
+ const name = validateResourceName(args._[1]);
515
+ const lock = acquireStartupLock(state);
516
+ if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
517
+ try {
518
+ const latest = loadState(workspace, { stateDir: args.stateDir });
519
+ latest.resources ||= {};
520
+ const existed = Object.prototype.hasOwnProperty.call(latest.resources, name);
521
+ delete latest.resources[name];
522
+ saveState(latest);
523
+ const result = { name, removed: existed, affects_new_jobs_immediately: true };
524
+ if (args.json) console.log(JSON.stringify(result, null, 2));
525
+ else {
526
+ console.log(existed ? `Removed local resource: ${name}` : `Local resource was not registered: ${name}`);
527
+ console.log("The change applies to newly submitted managed jobs immediately.");
528
+ }
529
+ } finally {
530
+ lock.release();
531
+ }
532
+ return;
533
+ }
534
+
535
+ if (action === "check") {
536
+ const name = validateResourceName(args._[1]);
537
+ const resource = state.resources[name];
538
+ if (!resource) throw new Error(`local resource is not registered: ${name}`);
539
+ const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
540
+ const result = { name, ...inspected, contents_exposed: false };
541
+ if (args.json) console.log(JSON.stringify(result, null, 2));
542
+ else console.log(`${name}: available (${inspected.mode || "n/a"}, ${inspected.size} bytes)`);
543
+ return;
544
+ }
545
+
546
+ throw new Error(`Unknown resource action: ${action}`);
547
+ }
548
+
549
+ async function jobCommand(args) {
550
+ const action = String(args._[0] || "list").toLowerCase();
551
+ const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
552
+ const state = loadState(workspace, { stateDir: args.stateDir });
553
+ const manager = new ManagedJobManager({
554
+ jobRoot: join(state.paths.profileDir, "jobs"),
555
+ workspace,
556
+ policy: resolvePolicy({}, state.policy),
557
+ resources: state.resources,
558
+ resourceStatePath: state.paths.statePath,
559
+ logger: createLogger({ level: "warn", component: "job" }),
560
+ });
561
+ let result;
562
+ if (action === "list") result = manager.list({ limit: 50 });
563
+ else if (action === "read") result = manager.read({ job_id: args._[1] });
564
+ else if (action === "inspect") result = manager.inspectLocal({ job_id: args._[1] });
565
+ else if (action === "cancel") result = manager.cancel({ job_id: args._[1] });
566
+ else if (action === "approve") {
567
+ if (args.json && !args.yes) throw new Error("job approve --json requires --yes");
568
+ const inspection = manager.inspectLocal({ job_id: args._[1] });
569
+ if (!args.yes) {
570
+ console.log(JSON.stringify(inspection, null, 2));
571
+ const approved = await confirm(`Approve and execute managed job ${args._[1]}?`, false);
572
+ if (!approved) {
573
+ console.log("Managed job approval cancelled. Re-run with --yes after review to skip confirmation.");
574
+ return;
575
+ }
576
+ }
577
+ result = manager.approve({ job_id: args._[1] }, { localOperator: true });
578
+ }
579
+ else if (action === "submit") {
580
+ const planPath = args._[1];
581
+ if (!planPath) throw new Error("job submit requires a JSON plan file");
582
+ const plan = loadManagedJobPlan(expandHome(planPath));
583
+ result = manager.start(plan);
584
+ } else throw new Error(`Unknown job action: ${action}`);
585
+ console.log(JSON.stringify(result, null, 2));
401
586
  }
402
587
 
403
588
  async function clientConfigCommand(args) {
@@ -426,17 +611,13 @@ async function clientConfigCommand(args) {
426
611
  }
427
612
  }
428
613
 
429
- function assertNoRemovedLocalApiOptions(args) {
430
- const removed = ["api", "noApi", "apiPort", "apiHost", "apiKey", "rotateApiKey", "apiModel", "port"].filter((key) => args[key] !== undefined);
431
- if (removed.length) throw removedLocalApiError();
432
- }
433
-
434
614
  function removedLocalApiError() {
435
615
  return new Error("Local /v1 API support has been removed. Use the printed Remote MCP Server URL/password with an MCP client instead.");
436
616
  }
437
617
 
618
+
438
619
  async function ensureWorker(state, args) {
439
- const logger = createLogger({ quiet: Boolean(args.quiet || args.json), verbose: Boolean(args.verbose), component: "worker" });
620
+ const logger = createLogger({ level: args.json ? "error" : effectiveLogLevel(args), component: "worker" });
440
621
  const desiredHash = workerDeployHash(state);
441
622
  const expectedVersion = currentPackageVersion();
442
623
  const complete = state.worker.url && state.worker.mcpServerUrl && state.worker.oauthPassword && state.worker.daemonSecret && state.worker.oauthTokenVersion && state.worker.name;
@@ -487,7 +668,7 @@ async function withSecretsFile(state, callback) {
487
668
  const dir = state.paths.profileDir;
488
669
  ensureOwnerOnlyDir(dir);
489
670
  cleanupStaleSecretFiles(dir);
490
- const tempPath = resolve(dir, `worker-secrets-${process.pid}-${Date.now()}.json`);
671
+ const tempPath = resolve(dir, `worker-secrets-${process.pid}-${Date.now()}-${randomBytes(6).toString("hex")}.json`);
491
672
  const payload = {
492
673
  MCP_OAUTH_PASSWORD: state.worker.oauthPassword,
493
674
  DAEMON_SHARED_SECRET: state.worker.daemonSecret,
@@ -504,10 +685,14 @@ async function withSecretsFile(state, callback) {
504
685
 
505
686
  function cleanupStaleSecretFiles(dir) {
506
687
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
507
- if (!entry.isFile() || !/^worker-secrets-.*\.json$/.test(entry.name)) continue;
688
+ if (!entry.isFile()) continue;
689
+ const match = /^worker-secrets-(\d+)-(\d+)(?:-[a-f0-9]+)?\.json$/.exec(entry.name);
690
+ if (!match) continue;
508
691
  const file = resolve(dir, entry.name);
509
692
  try {
510
- if (Date.now() - statSync(file).mtimeMs > 60 * 60 * 1000) unlinkSync(file);
693
+ const pid = Number(match[1]);
694
+ const ageMs = Date.now() - statSync(file).mtimeMs;
695
+ if (!isPidAlive(pid) || ageMs > 60 * 60 * 1000) unlinkSync(file);
511
696
  } catch {}
512
697
  }
513
698
  }
@@ -562,10 +747,18 @@ async function workerHealth(workerUrl, expectedVersion = currentPackageVersion()
562
747
  if (body?.version !== expectedVersion) return { ok: false, error: `version_mismatch:${body?.version || "unknown"}!=${expectedVersion}` };
563
748
  return { ok: true, version: body.version };
564
749
  } catch (error) {
565
- return { ok: false, error: error.message };
750
+ return { ok: false, error: workerHealthError(error) };
566
751
  }
567
752
  }
568
753
 
754
+ function workerHealthError(error) {
755
+ const message = String(error?.message || error || "");
756
+ if (/timeout|aborted/i.test(message)) return "timeout";
757
+ if (/certificate|TLS|SSL/i.test(message)) return "tls_error";
758
+ if (/fetch failed|network|ECONN|ENOTFOUND|EAI_AGAIN/i.test(message)) return "network_error";
759
+ return "request_failed";
760
+ }
761
+
569
762
  async function retryHealth(workerUrl, expectedVersion, attempts) {
570
763
  let last = { ok: false, error: "not_checked" };
571
764
  for (let i = 0; i < attempts; i += 1) {
@@ -583,23 +776,22 @@ function extractWorkerUrl(text = "") {
583
776
  return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
584
777
  }
585
778
 
586
- async function waitForConnectWithNotice(promise, timeoutMs, quiet = false) {
779
+ async function waitForConnectWithNotice(promise, timeoutMs, logger) {
587
780
  let timeout;
588
781
  const timed = new Promise(resolvePromise => {
589
782
  timeout = setTimeout(() => resolvePromise("timeout"), timeoutMs);
590
783
  });
591
784
  const result = await Promise.race([promise.then(() => "connected"), timed]);
592
785
  clearTimeout(timeout);
593
- if (result === "timeout") createLogger({ component: "daemon", quiet }).warn("Still connecting; the process will keep retrying");
786
+ if (result === "timeout") logger.warn("Still connecting; the process will keep retrying");
594
787
  }
595
788
 
596
789
 
597
- function printStartJson(state, { noPrintCredentials = false, requestedChangesApplied = true, notice = "" } = {}) {
598
- const mcpPassword = noPrintCredentials ? previewSecret(state.worker.oauthPassword) : state.worker.oauthPassword;
790
+ function printStartJson(state, { showCredentials = false, requestedChangesApplied = true, notice = "" } = {}) {
599
791
  createLogger({ component: "ready" }).json({
600
792
  mcp: {
601
793
  server_url: state.worker.mcpServerUrl,
602
- connection_password: mcpPassword,
794
+ connection_password: showCredentials ? state.worker.oauthPassword : previewSecret(state.worker.oauthPassword),
603
795
  worker_url: state.worker.url,
604
796
  worker_name: state.worker.name,
605
797
  },
@@ -611,36 +803,45 @@ function printStartJson(state, { noPrintCredentials = false, requestedChangesApp
611
803
  });
612
804
  }
613
805
 
614
- function printMcpConnection(state, { json = false, noPrintCredentials = false, includeCredentials = false, quiet = false } = {}) {
615
- const logger = createLogger({ component: "ready", quiet });
806
+ function printMcpConnection(state, {
807
+ noPrintCredentials = false,
808
+ includeCredentials = false,
809
+ quiet = false,
810
+ verbose = false,
811
+ policyMigrated = false,
812
+ } = {}) {
813
+ const logger = createLogger({ component: "ready", quiet, level: quiet ? "error" : verbose ? "debug" : "info" });
616
814
  const payload = {
617
815
  mcp_server_url: state.worker.mcpServerUrl,
618
816
  mcp_connection_password: state.worker.oauthPassword,
619
- worker_url: state.worker.url,
620
- worker_name: state.worker.name,
621
817
  workspace: state.workspace.path,
622
818
  state_path: state.paths.statePath,
623
819
  policy: state.policy,
624
820
  };
625
- if (json) {
626
- const safePayload = noPrintCredentials ? { ...payload, mcp_connection_password: previewSecret(payload.mcp_connection_password) } : payload;
627
- logger.json(safePayload);
628
- return;
629
- }
630
821
  if (includeCredentials) {
631
822
  logger.success("Remote MCP bridge is ready; save these connection details if your ChatGPT app needs to reconnect");
632
823
  logger.plain(` MCP Server URL: ${payload.mcp_server_url}`);
633
824
  if (!noPrintCredentials) logger.plain(` MCP connection password: ${payload.mcp_connection_password}`);
634
825
  else logger.plain(` MCP connection password: ${previewSecret(payload.mcp_connection_password)} (redacted)`);
635
826
  } else {
636
- logger.success("Remote MCP bridge is ready; MCP connection details unchanged");
637
- logger.plain(" Use --print-mcp-credentials only when a ChatGPT app needs to reconnect.");
827
+ logger.success("Remote MCP bridge is ready");
828
+ logger.plain(" Connection credentials unchanged; use --print-mcp-credentials only when reconnecting a client.");
829
+ }
830
+ if (policyMigrated) {
831
+ logger.warn("Legacy implicit policy migrated to full access; use --profile agent, edit, or review to narrow it.");
638
832
  }
639
- logger.plain(` Workspace cwd: ${payload.workspace}`);
640
- logger.plain(` Policy: profile=${payload.policy.profile || "custom"}, write=${payload.policy.allowWrite ? "on" : "off"}, exec_mode=${payload.policy.execMode || (payload.policy.allowExec ? "shell" : "off")}, unrestricted_paths=${payload.policy.unrestrictedPaths ? "on" : "off"}, absolute_paths=${payload.policy.exposeAbsolutePaths ? "on" : "off"}`);
641
- logger.plain(` State: ${payload.state_path}`);
833
+ logger.plain(` Workspace: ${payload.workspace}`);
834
+ logger.plain(` Policy: ${formatPolicySummary(payload.policy)}`);
835
+ if (verbose) logger.plain(` State: ${payload.state_path}`);
836
+ }
837
+
838
+ function formatPolicySummary(policy = {}) {
839
+ const scope = policy.unrestrictedPaths ? "all local paths" : "workspace only";
840
+ const environment = policy.minimalEnv ? "isolated env" : "full parent env";
841
+ return `${policy.profile || "custom"} [${policy.origin || "unknown"}; write=${policy.allowWrite ? "on" : "off"}; exec=${policy.execMode || "off"}; ${scope}; ${environment}; absolute_paths=${policy.exposeAbsolutePaths ? "on" : "off"}]`;
642
842
  }
643
843
 
844
+
644
845
  function keepProcessAlive({ daemon = null, lock = null, logger = createLogger({ component: "cli" }) } = {}) {
645
846
  let stopping = false;
646
847
  const stop = async () => {
@@ -660,8 +861,14 @@ function keepProcessAlive({ daemon = null, lock = null, logger = createLogger({
660
861
  async function statusCommand(args) {
661
862
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
662
863
  const state = loadState(workspace, { stateDir: args.stateDir });
864
+ const storedPolicyOrigin = state.policy?.origin;
865
+ state.policy = resolvePolicy({}, state.policy);
663
866
  const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
664
- const payload = { ...redactState(state), workerHealth: health };
867
+ const payload = {
868
+ ...redactState(state),
869
+ policyMigrationPending: !storedPolicyOrigin && state.policy.origin === "migrated",
870
+ workerHealth: health,
871
+ };
665
872
  console.log(JSON.stringify(payload, null, 2));
666
873
  }
667
874
 
@@ -674,9 +881,40 @@ async function doctorCommand(args) {
674
881
  const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
675
882
  checks.push({ name: "cloudflare-login", ok: whoami.code === 0, detail: whoami.code === 0 ? "authenticated" : sanitizeLines(whoami.stderr || whoami.stdout) });
676
883
  const state = loadState(workspace, { stateDir: args.stateDir });
884
+ const storedPolicyOrigin = state.policy?.origin;
885
+ state.policy = resolvePolicy({}, state.policy);
886
+ checks.push({ name: "policy", ok: true, detail: formatPolicySummary(state.policy) });
677
887
  const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
678
888
  checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
679
- console.log(JSON.stringify({ ok: checks.every(check => check.ok), checks, state: redactState(state) }, null, 2));
889
+ const diagnosticRuntime = new LocalDaemon({
890
+ workspace,
891
+ policy: state.policy,
892
+ logger: createLogger({ level: "error", component: "doctor" }),
893
+ jobRoot: join(state.paths.profileDir, "jobs"),
894
+ resources: state.resources,
895
+ resourceStatePath: state.paths.statePath,
896
+ recoverJobs: false,
897
+ });
898
+ let runtimeDiagnostics;
899
+ try {
900
+ runtimeDiagnostics = await diagnosticRuntime.diagnoseRuntime();
901
+ } finally {
902
+ diagnosticRuntime.stop();
903
+ }
904
+ for (const check of runtimeDiagnostics.checks) {
905
+ checks.push({
906
+ name: `runtime:${check.layer}`,
907
+ ok: check.skipped === true || check.ok === true,
908
+ detail: check.skipped ? `skipped (${check.error_class || "not applicable"})` : check.ok ? "ok" : check.error_class || "failed",
909
+ });
910
+ }
911
+ console.log(JSON.stringify({
912
+ ok: checks.every(check => check.ok),
913
+ checks,
914
+ runtimeDiagnostics,
915
+ policyMigrationPending: !storedPolicyOrigin && state.policy.origin === "migrated",
916
+ state: redactState(state),
917
+ }, null, 2));
680
918
  }
681
919
 
682
920
  async function rotateSecretsCommand(args) {
@@ -688,7 +926,7 @@ async function rotateSecretsCommand(args) {
688
926
  throw new Error(`another startup/deployment operation is already running for this workspace (${pid})`);
689
927
  }
690
928
  try {
691
- await stopAutostartBestEffort(Boolean(args.quiet));
929
+ await stopAutostartBestEffort(createLogger({ level: args.quiet ? "error" : "warn", component: "service" }));
692
930
  await sleep(500);
693
931
  const daemonOwner = readDaemonLockOwner(daemonLockPathForState(state));
694
932
  if (daemonOwner?.pid && isPidAlive(daemonOwner.pid)) {
@@ -696,9 +934,10 @@ async function rotateSecretsCommand(args) {
696
934
  }
697
935
  ensureWorkerSecrets(state, { rotateSecrets: true, workerName: validateWorkerName(args.workerName) });
698
936
  saveState(state);
699
- console.log(`Rotated MCP password: ${args.noPrintCredentials ? "<redacted>" : state.worker.oauthPassword}`);
700
- console.log(`Rotated daemon secret: ${args.noPrintCredentials ? "<redacted>" : previewSecret(state.worker.daemonSecret)}`);
701
- console.log("Run start to redeploy the Worker with the new secrets and revoke old OAuth access tokens.");
937
+ const showMcpPassword = Boolean((args.printMcpCredentials || args.printCredentials) && !args.noPrintCredentials);
938
+ console.log(`Rotated MCP connection password: ${showMcpPassword ? state.worker.oauthPassword : previewSecret(state.worker.oauthPassword)}`);
939
+ console.log(`Rotated daemon secret: ${previewSecret(state.worker.daemonSecret)}`);
940
+ console.log("Run `machine-mcp --print-mcp-credentials` to redeploy and display the new client connection credentials when needed.");
702
941
  } finally {
703
942
  startupLock.release();
704
943
  }
@@ -742,23 +981,23 @@ async function serviceCommand(args) {
742
981
  throw new Error(`Unknown service action: ${action}`);
743
982
  }
744
983
 
745
- async function installAutostartBestEffort({ workspace, stateRoot, entryScript }) {
984
+ async function installAutostartBestEffort({ workspace, stateRoot, entryScript, logger }) {
746
985
  try {
747
986
  const { installAutostart } = await import("./service.mjs");
748
- const result = await installAutostart({ workspace, stateRoot, entryScript, logger: structuredLogger(false) });
749
- if (result?.ok) console.log("Autostart installed for future logins. Use `machine-mcp service status` to inspect or `machine-mcp service uninstall` to remove.");
750
- else console.warn("Autostart installation returned a warning; run `machine-mcp service status` for details.");
987
+ const result = await installAutostart({ workspace, stateRoot, entryScript, logger: structuredLogger(true) });
988
+ if (result?.ok) logger.info("Autostart installed for future logins", { provider: result.provider });
989
+ else logger.warn("Autostart installation reported a problem; run `machine-mcp service status` for details");
751
990
  } catch (error) {
752
- console.warn(`Autostart installation skipped: ${error.message}`);
991
+ logger.warn("Autostart installation skipped", { error_class: classifyOperationalError(error) });
753
992
  }
754
993
  }
755
994
 
756
- async function stopAutostartBestEffort(quiet = false) {
995
+ async function stopAutostartBestEffort(logger) {
757
996
  try {
758
997
  const { stopAutostart } = await import("./service.mjs");
759
998
  await stopAutostart({ logger: structuredLogger(true) });
760
999
  } catch (error) {
761
- if (!quiet) console.warn(`Autostart stop skipped: ${error.message}`);
1000
+ logger.warn("Autostart stop skipped", { error_class: classifyOperationalError(error) });
762
1001
  }
763
1002
  }
764
1003
 
@@ -774,6 +1013,12 @@ async function uninstallCommand(args) {
774
1013
  console.log("Uninstall cancelled. Re-run with `machine-mcp uninstall --yes` to skip confirmation.");
775
1014
  return;
776
1015
  }
1016
+ const activeJobs = activeStateJobs(stateRoot);
1017
+ if (activeJobs.length) {
1018
+ const detail = activeJobs.slice(0, 5).map((item) => `${item.job_id}:${item.status}`).join(", ");
1019
+ const suffix = activeJobs.length > 5 ? `, and ${activeJobs.length - 5} more` : "";
1020
+ throw new Error(`refusing to uninstall while managed jobs are active (${detail}${suffix}); inspect or cancel them with machine-mcp job list/cancel`);
1021
+ }
777
1022
  const autostartRemoved = await removeAutostartBestEffort(stateRoot);
778
1023
  if (!autostartRemoved) throw new Error("autostart removal failed; state and Worker were kept so the uninstall can be retried safely");
779
1024
  await sleep(500);
@@ -820,6 +1065,7 @@ function knownWorkerNames(stateRoot) {
820
1065
  const stateFile = resolve(profiles, entry.name, "state.json");
821
1066
  if (!existsSync(stateFile)) continue;
822
1067
  try {
1068
+ if (statSync(stateFile).size > 2 * 1024 * 1024) continue;
823
1069
  const state = JSON.parse(readFileSync(stateFile, "utf8"));
824
1070
  const name = String(state?.worker?.name || "");
825
1071
  if (/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(name)) names.add(name);
@@ -839,11 +1085,24 @@ async function removeAutostartBestEffort(stateRoot) {
839
1085
  }
840
1086
  return true;
841
1087
  } catch (error) {
842
- console.warn(`Autostart removal skipped or failed: ${error.message}`);
1088
+ console.warn(`Autostart removal skipped or failed (${classifyOperationalError(error)}). Run machine-mcp service status for details.`);
843
1089
  return false;
844
1090
  }
845
1091
  }
846
1092
 
1093
+ function activeStateJobs(stateRoot) {
1094
+ const profiles = resolve(expandHome(stateRoot), "profiles");
1095
+ if (!existsSync(profiles)) return [];
1096
+ const active = [];
1097
+ for (const profile of readdirSync(profiles, { withFileTypes: true })) {
1098
+ if (!profile.isDirectory()) continue;
1099
+ for (const job of activeManagedJobs(resolve(profiles, profile.name, "jobs"))) {
1100
+ active.push({ profile: profile.name, ...job });
1101
+ }
1102
+ }
1103
+ return active;
1104
+ }
1105
+
847
1106
  function activeStateLocks(stateRoot) {
848
1107
  const profiles = resolve(expandHome(stateRoot), "profiles");
849
1108
  if (!existsSync(profiles)) return [];
@@ -872,9 +1131,10 @@ function isPidAlive(pid) {
872
1131
  }
873
1132
 
874
1133
  function structuredLogger(quiet) {
875
- return createLogger({ quiet, component: "daemon" });
1134
+ return createLogger({ quiet, component: "service" });
876
1135
  }
877
1136
 
1137
+
878
1138
  function sanitizeLines(text) {
879
1139
  const home = process.env.HOME || process.env.USERPROFILE || "";
880
1140
  const homePattern = home ? new RegExp(escapeRegExp(home), "g") : null;
@@ -910,7 +1170,7 @@ function usage() {
910
1170
  console.log(`machine-bridge-mcp
911
1171
 
912
1172
  Usage:
913
- npm install -g machine-bridge-mcp@latest && machine-mcp
1173
+ npm install -g --allow-scripts=esbuild,workerd,sharp machine-bridge-mcp@latest && machine-mcp
914
1174
  npx machine-bridge-mcp@latest # no global install; autostart may rely on npm cache
915
1175
  ./mbm # from source checkout
916
1176
  .\\mbm.cmd # from source checkout on Windows cmd
@@ -946,9 +1206,14 @@ Start options:
946
1206
  --no-exec Disable run_process and exec_command
947
1207
  --full-env Pass the full parent environment to local commands
948
1208
  --unrestricted-paths Allow filesystem tools outside the workspace
949
- --absolute-paths Return absolute local paths in tool results (off by default)
1209
+ --absolute-paths Return absolute local paths (enabled by the full profile)
950
1210
  --state-dir DIR Override state root
951
- --json Print MCP connection details as JSON
1211
+ --json Print connection details as JSON; credentials stay redacted unless explicitly requested
1212
+ --log-level LEVEL error, warn, info (default), or debug
1213
+ --verbose Alias for --log-level debug; includes per-tool success/correlation logs
1214
+ --quiet Alias for --log-level error
1215
+ --allow-insecure-permissions
1216
+ Permit resource registration when a file is group/other-readable
952
1217
 
953
1218
  Uninstall options:
954
1219
  --keep-worker Do not delete deployed Worker(s) during uninstall