machine-bridge-mcp 0.5.0 → 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
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
8
  import { DEFAULT_POLICY_PROFILE, DEFAULT_POLICY_REVISION, POLICY_PROFILES, normalizePolicy, policyProfile } from "./tools.mjs";
9
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,7 +38,7 @@ 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",
41
+ "yes", "keepWorker", "allowInsecurePermissions",
41
42
  ]);
42
43
  const VALUE_OPTIONS = new Set([
43
44
  "workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
@@ -63,6 +64,8 @@ export async function main(argv = process.argv.slice(2)) {
63
64
  case "service":
64
65
  case "autostart": return serviceCommand(args);
65
66
  case "rotate-secrets": return rotateSecretsCommand(args);
67
+ case "resource": return resourceCommand(args);
68
+ case "job": return jobCommand(args);
66
69
  case "uninstall": return uninstallCommand(args);
67
70
  default:
68
71
  console.error(`Unknown command: ${command}`);
@@ -85,6 +88,8 @@ const COMMAND_OPTIONS = {
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
 
@@ -118,8 +123,6 @@ function toKebab(value) {
118
123
 
119
124
  export function validatePositionals(command, args) {
120
125
  const count = args._.length;
121
- const conflict = Boolean(args.workspace) && count > (command === "workspace" || command === "service" || command === "autostart" ? 1 : 0);
122
- if (conflict) throw new Error("workspace path was provided both positionally and with --workspace");
123
126
  if (["start", "stdio", "status", "doctor", "rotate-secrets"].includes(command)) {
124
127
  if (count > 1) throw new Error(`${command} accepts at most one positional workspace path`);
125
128
  if (args.workspace && count) throw new Error("workspace path was provided both positionally and with --workspace");
@@ -143,6 +146,18 @@ export function validatePositionals(command, args) {
143
146
  if (count > 1) throw new Error("client-config accepts at most one positional client name");
144
147
  return;
145
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
+ }
146
161
  if (command === "uninstall" && count) throw new Error("uninstall does not accept positional arguments");
147
162
  }
148
163
 
@@ -336,6 +351,9 @@ async function startCommand(args) {
336
351
  workspace,
337
352
  policy: state.policy,
338
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,
339
357
  onSuperseded: () => {
340
358
  logger.warn("this daemon was replaced by a newer authenticated instance; exiting without reconnecting");
341
359
  lock.release();
@@ -439,7 +457,132 @@ async function stdioCommand(args) {
439
457
  const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
440
458
  const state = loadState(workspace, { stateDir: args.stateDir });
441
459
  const policy = resolvePolicy(args, state.policy);
442
- await runStdioServer({ workspace, policy, logLevel: effectiveLogLevel(args) });
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));
443
586
  }
444
587
 
445
588
  async function clientConfigCommand(args) {
@@ -743,9 +886,32 @@ async function doctorCommand(args) {
743
886
  checks.push({ name: "policy", ok: true, detail: formatPolicySummary(state.policy) });
744
887
  const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
745
888
  checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
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
+ }
746
911
  console.log(JSON.stringify({
747
912
  ok: checks.every(check => check.ok),
748
913
  checks,
914
+ runtimeDiagnostics,
749
915
  policyMigrationPending: !storedPolicyOrigin && state.policy.origin === "migrated",
750
916
  state: redactState(state),
751
917
  }, null, 2));
@@ -847,6 +1013,12 @@ async function uninstallCommand(args) {
847
1013
  console.log("Uninstall cancelled. Re-run with `machine-mcp uninstall --yes` to skip confirmation.");
848
1014
  return;
849
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
+ }
850
1022
  const autostartRemoved = await removeAutostartBestEffort(stateRoot);
851
1023
  if (!autostartRemoved) throw new Error("autostart removal failed; state and Worker were kept so the uninstall can be retried safely");
852
1024
  await sleep(500);
@@ -918,6 +1090,19 @@ async function removeAutostartBestEffort(stateRoot) {
918
1090
  }
919
1091
  }
920
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
+
921
1106
  function activeStateLocks(stateRoot) {
922
1107
  const profiles = resolve(expandHome(stateRoot), "profiles");
923
1108
  if (!existsSync(profiles)) return [];
@@ -1027,6 +1212,8 @@ Start options:
1027
1212
  --log-level LEVEL error, warn, info (default), or debug
1028
1213
  --verbose Alias for --log-level debug; includes per-tool success/correlation logs
1029
1214
  --quiet Alias for --log-level error
1215
+ --allow-insecure-permissions
1216
+ Permit resource registration when a file is group/other-readable
1030
1217
 
1031
1218
  Uninstall options:
1032
1219
  --keep-worker Do not delete deployed Worker(s) during uninstall
@@ -11,6 +11,7 @@ import { MAX_COMMAND_BYTES, ProcessSessionManager, terminateProcessTree, validat
11
11
  export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
12
12
  import { MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, SERVER_NAME, toolNamesForPolicy } from "./tools.mjs";
13
13
  import { classifyOperationalError } from "./log.mjs";
14
+ import { ManagedJobManager } from "./managed-jobs.mjs";
14
15
 
15
16
  export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
16
17
  const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
@@ -22,7 +23,7 @@ const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
22
23
  const SLOW_TOOL_CALL_MS = 30_000;
23
24
 
24
25
  export class LocalDaemon {
25
- constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null }) {
26
+ constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null, jobRoot = "", resources = {}, resourceStatePath = "", recoverJobs = true }) {
26
27
  this.workerUrl = workerUrl ? normalizeWorkerUrl(workerUrl) : "";
27
28
  if (this.workerUrl && (typeof secret !== "string" || secret.length < 16)) throw new Error("daemon secret is missing or too short");
28
29
  this.secret = secret || "";
@@ -46,6 +47,16 @@ export class LocalDaemon {
46
47
  this.reconnectAttempt = 0;
47
48
  this.mutationQueue = Promise.resolve();
48
49
  this.runtimeDir = createRuntimeDir();
50
+ if (typeof jobRoot !== "string" || !jobRoot.trim()) throw new Error("persistent managed-job root is required");
51
+ this.managedJobManager = new ManagedJobManager({
52
+ jobRoot,
53
+ workspace: this.workspace,
54
+ policy: this.policy,
55
+ resources,
56
+ resourceStatePath,
57
+ logger: this.logger,
58
+ recover: recoverJobs,
59
+ });
49
60
  this.processSessionManager = new ProcessSessionManager({
50
61
  workspace: this.workspace,
51
62
  policy: this.policy,
@@ -85,6 +96,8 @@ export class LocalDaemon {
85
96
  environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
86
97
  runtime_dir: this.policy.exposeAbsolutePaths ? this.runtimeDir : "<private-runtime-dir>",
87
98
  process_sessions: this.processSessionManager.status(),
99
+ managed_jobs: this.managedJobManager.status(),
100
+ local_resources: this.managedJobManager.resourceInfo(),
88
101
  },
89
102
  };
90
103
  }
@@ -291,6 +304,13 @@ export class LocalDaemon {
291
304
  case "git_diff": return this.gitDiff(args, context);
292
305
  case "git_log": return this.gitLog(args, context);
293
306
  case "git_show": return this.gitShow(args, context);
307
+ case "diagnose_runtime": return this.diagnoseRuntime(context);
308
+ case "list_local_resources": return this.managedJobManager.listResources();
309
+ case "stage_job": return this.managedJobManager.stage(args);
310
+ case "start_job": return this.managedJobManager.start(args);
311
+ case "list_jobs": return this.managedJobManager.list(args);
312
+ case "read_job": return this.managedJobManager.read(args);
313
+ case "cancel_job": return this.managedJobManager.cancel(args);
294
314
  case "run_process": return this.runDirectProcess(args, context);
295
315
  case "start_process": return this.processSessionManager.start(args, context);
296
316
  case "read_process": return this.processSessionManager.read(args, context);
@@ -629,6 +649,86 @@ export class LocalDaemon {
629
649
  return { ok: true, target, root, pathspec: repoRelative || "" };
630
650
  }
631
651
 
652
+ async diagnoseRuntime(context = {}) {
653
+ this.throwIfCancelled(context);
654
+ const checks = [];
655
+ checks.push({
656
+ layer: "mcp-host-to-daemon",
657
+ ok: true,
658
+ detail: "This diagnostic request reached the local Machine Bridge runtime.",
659
+ });
660
+ checks.push({
661
+ layer: "machine-bridge-policy",
662
+ ok: this.policy.execMode === "direct" || this.policy.execMode === "shell",
663
+ detail: `profile=${this.policy.profile}; exec_mode=${this.policy.execMode}; unrestricted_paths=${this.policy.unrestrictedPaths}`,
664
+ });
665
+
666
+ const probe = join(this.runtimeDir, `.diagnostic-${process.pid}-${randomBytes(6).toString("hex")}`);
667
+ try {
668
+ await writeFile(probe, "ok\n", { mode: 0o600, flag: "wx" });
669
+ const { buffer } = await readBoundedFile(probe, 64, "diagnostic file");
670
+ checks.push({ layer: "local-filesystem", ok: buffer.toString("utf8") === "ok\n", error_class: null });
671
+ } catch (error) {
672
+ checks.push({ layer: "local-filesystem", ok: false, error_class: classifyOperationalError(error) });
673
+ } finally {
674
+ await rm(probe, { force: true }).catch(() => {});
675
+ }
676
+
677
+ if (this.policy.execMode === "direct" || this.policy.execMode === "shell") {
678
+ const direct = await this.runProcess(
679
+ process.execPath,
680
+ ["-e", "process.stdout.write('ok')"],
681
+ 5000,
682
+ true,
683
+ 1024,
684
+ context,
685
+ this.workspace,
686
+ ).catch((error) => ({ code: 127, stdout: "", stderr: "", error_class: classifyOperationalError(error) }));
687
+ checks.push({
688
+ layer: "local-process-spawn",
689
+ ok: direct.code === 0 && direct.stdout === "ok",
690
+ error_class: direct.error_class || (direct.code === 0 ? null : classifyOperationalError(direct.stderr || direct.stdout || "execution failed")),
691
+ });
692
+ } else {
693
+ checks.push({ layer: "local-process-spawn", ok: false, skipped: true, error_class: "policy_denied" });
694
+ }
695
+
696
+ if (this.policy.execMode === "shell") {
697
+ const shell = workspaceShellCommand(process.platform === "win32" ? "cd" : "pwd");
698
+ const result = await this.runProcess(shell.cmd, shell.args, 5000, true, 4096, context, this.workspace)
699
+ .catch((error) => ({ code: 127, error_class: classifyOperationalError(error) }));
700
+ checks.push({
701
+ layer: "local-shell",
702
+ ok: result.code === 0,
703
+ error_class: result.error_class || (result.code === 0 ? null : classifyOperationalError(result.stderr || result.stdout || "execution failed")),
704
+ });
705
+ } else {
706
+ checks.push({ layer: "local-shell", ok: false, skipped: true, error_class: "policy_denied" });
707
+ }
708
+
709
+ const storage = this.managedJobManager.diagnoseStorage();
710
+ checks.push({ layer: "managed-job-storage", ...storage });
711
+ const resources = this.managedJobManager.listResources();
712
+ checks.push({
713
+ layer: "local-resource-registry",
714
+ ok: resources.resources.every((resource) => resource.available),
715
+ registered: resources.count,
716
+ unavailable: resources.resources.filter((resource) => !resource.available).map((resource) => ({ name: resource.name, error_class: resource.error_class })),
717
+ });
718
+
719
+ return {
720
+ request_reached_local_runtime: true,
721
+ interpretation: {
722
+ tool_call_blocked_before_response: "host/platform or connector gateway",
723
+ diagnostic_reached_daemon_but_spawn_failed: "local OS, endpoint security, shell configuration, or Machine Bridge policy",
724
+ managed_job_accepted_then_later_tools_blocked: "job continues independently; inspect with local CLI or a later read_job call",
725
+ },
726
+ policy: this.policy,
727
+ checks,
728
+ ok: checks.filter((check) => !check.skipped).every((check) => check.ok),
729
+ };
730
+ }
731
+
632
732
  async runDirectProcess(args, context = {}) {
633
733
  if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error("run_process is disabled by daemon policy");
634
734
  const argv = validateArgv(args.argv);