machine-bridge-mcp 0.5.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +113 -8
- package/SECURITY.md +42 -6
- package/docs/ARCHITECTURE.md +50 -9
- package/docs/CLIENTS.md +24 -3
- package/docs/LOGGING.md +12 -19
- package/docs/MANAGED_JOBS.md +282 -0
- package/docs/OPERATIONS.md +79 -10
- package/docs/TESTING.md +11 -2
- package/package.json +13 -6
- package/src/local/atomic-fs.mjs +37 -0
- package/src/local/cli.mjs +254 -7
- package/src/local/daemon.mjs +168 -6
- package/src/local/full-access-test.mjs +206 -0
- package/src/local/job-runner.mjs +471 -0
- package/src/local/managed-jobs.mjs +855 -0
- package/src/local/resource-operations.mjs +66 -0
- package/src/local/ssh-key.mjs +125 -0
- package/src/local/state.mjs +56 -12
- package/src/local/stdio.mjs +4 -6
- package/src/local/tools.mjs +37 -3
- package/src/shared/server-metadata.json +10 -1
- package/src/shared/tool-catalog.json +531 -0
- package/src/worker/index.ts +7 -1
package/src/local/cli.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
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
|
-
import { DEFAULT_POLICY_PROFILE, DEFAULT_POLICY_REVISION, POLICY_PROFILES, normalizePolicy, policyProfile } from "./tools.mjs";
|
|
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";
|
|
10
|
+
import { activeManagedJobs, inspectResourceFile, loadManagedJobPlan, ManagedJobManager, publicResourceRegistry, validateResourceName } from "./managed-jobs.mjs";
|
|
10
11
|
import { runWrangler } from "./shell.mjs";
|
|
12
|
+
import { generateRegisteredSshKey } from "./resource-operations.mjs";
|
|
13
|
+
import { runFullAccessTest } from "./full-access-test.mjs";
|
|
11
14
|
import {
|
|
12
15
|
acquireDaemonLock,
|
|
13
16
|
acquireStartupLock,
|
|
@@ -37,7 +40,7 @@ const BOOLEAN_OPTIONS = new Set([
|
|
|
37
40
|
"help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
|
|
38
41
|
"daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
|
|
39
42
|
"printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
40
|
-
"yes", "keepWorker",
|
|
43
|
+
"yes", "keepWorker", "allowInsecurePermissions",
|
|
41
44
|
]);
|
|
42
45
|
const VALUE_OPTIONS = new Set([
|
|
43
46
|
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
|
|
@@ -59,10 +62,13 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
59
62
|
case "client-config": return clientConfigCommand(args);
|
|
60
63
|
case "status": return statusCommand(args);
|
|
61
64
|
case "doctor": return doctorCommand(args);
|
|
65
|
+
case "full-test": return fullTestCommand(args);
|
|
62
66
|
case "workspace": return workspaceCommand(args);
|
|
63
67
|
case "service":
|
|
64
68
|
case "autostart": return serviceCommand(args);
|
|
65
69
|
case "rotate-secrets": return rotateSecretsCommand(args);
|
|
70
|
+
case "resource": return resourceCommand(args);
|
|
71
|
+
case "job": return jobCommand(args);
|
|
66
72
|
case "uninstall": return uninstallCommand(args);
|
|
67
73
|
default:
|
|
68
74
|
console.error(`Unknown command: ${command}`);
|
|
@@ -81,10 +87,13 @@ const COMMAND_OPTIONS = {
|
|
|
81
87
|
"client-config": new Set(["workspace", "stateDir", "profile", "client", "json"]),
|
|
82
88
|
status: new Set(["workspace", "stateDir"]),
|
|
83
89
|
doctor: new Set(["workspace", "stateDir"]),
|
|
90
|
+
"full-test": new Set(["workspace", "stateDir", "json"]),
|
|
84
91
|
"rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "printMcpCredentials", "printCredentials", "quiet"]),
|
|
85
92
|
workspace: new Set(["workspace", "stateDir"]),
|
|
86
93
|
service: new Set(["workspace", "stateDir", "quiet"]),
|
|
87
94
|
autostart: new Set(["workspace", "stateDir", "quiet"]),
|
|
95
|
+
resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "json"]),
|
|
96
|
+
job: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
88
97
|
uninstall: new Set(["stateDir", "keepWorker", "yes"]),
|
|
89
98
|
};
|
|
90
99
|
|
|
@@ -118,9 +127,7 @@ function toKebab(value) {
|
|
|
118
127
|
|
|
119
128
|
export function validatePositionals(command, args) {
|
|
120
129
|
const count = args._.length;
|
|
121
|
-
|
|
122
|
-
if (conflict) throw new Error("workspace path was provided both positionally and with --workspace");
|
|
123
|
-
if (["start", "stdio", "status", "doctor", "rotate-secrets"].includes(command)) {
|
|
130
|
+
if (["start", "stdio", "status", "doctor", "full-test", "rotate-secrets"].includes(command)) {
|
|
124
131
|
if (count > 1) throw new Error(`${command} accepts at most one positional workspace path`);
|
|
125
132
|
if (args.workspace && count) throw new Error("workspace path was provided both positionally and with --workspace");
|
|
126
133
|
return;
|
|
@@ -143,6 +150,18 @@ export function validatePositionals(command, args) {
|
|
|
143
150
|
if (count > 1) throw new Error("client-config accepts at most one positional client name");
|
|
144
151
|
return;
|
|
145
152
|
}
|
|
153
|
+
if (command === "resource") {
|
|
154
|
+
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") {
|
|
160
|
+
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;
|
|
164
|
+
}
|
|
146
165
|
if (command === "uninstall" && count) throw new Error("uninstall does not accept positional arguments");
|
|
147
166
|
}
|
|
148
167
|
|
|
@@ -336,6 +355,9 @@ async function startCommand(args) {
|
|
|
336
355
|
workspace,
|
|
337
356
|
policy: state.policy,
|
|
338
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,
|
|
339
361
|
onSuperseded: () => {
|
|
340
362
|
logger.warn("this daemon was replaced by a newer authenticated instance; exiting without reconnecting");
|
|
341
363
|
lock.release();
|
|
@@ -439,7 +461,168 @@ async function stdioCommand(args) {
|
|
|
439
461
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
440
462
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
441
463
|
const policy = resolvePolicy(args, state.policy);
|
|
442
|
-
await runStdioServer({
|
|
464
|
+
await runStdioServer({
|
|
465
|
+
workspace,
|
|
466
|
+
policy,
|
|
467
|
+
logLevel: effectiveLogLevel(args),
|
|
468
|
+
jobRoot: join(state.paths.profileDir, "jobs"),
|
|
469
|
+
resources: state.resources,
|
|
470
|
+
resourceStatePath: state.paths.statePath,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function resourceCommand(args) {
|
|
475
|
+
const action = String(args._[0] || "list").toLowerCase();
|
|
476
|
+
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
477
|
+
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
478
|
+
state.resources ||= {};
|
|
479
|
+
|
|
480
|
+
if (action === "list") {
|
|
481
|
+
const resources = publicResourceRegistry(state.resources);
|
|
482
|
+
if (args.json) console.log(JSON.stringify({ workspace, resources }, null, 2));
|
|
483
|
+
else if (!Object.keys(resources).length) console.log("No local resources registered.");
|
|
484
|
+
else for (const [name, value] of Object.entries(resources)) console.log(`${name} ${value.path} ${value.mode || "n/a"} ${value.size ?? "n/a"} bytes`);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (action === "add") {
|
|
489
|
+
const name = validateResourceName(args._[1]);
|
|
490
|
+
const inputPath = args._[2];
|
|
491
|
+
if (!inputPath) throw new Error("resource add requires NAME and FILE_PATH");
|
|
492
|
+
const lock = acquireStartupLock(state);
|
|
493
|
+
if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
|
|
494
|
+
try {
|
|
495
|
+
const latest = loadState(workspace, { stateDir: args.stateDir });
|
|
496
|
+
latest.resources ||= {};
|
|
497
|
+
const inspected = inspectResourceFile(expandHome(inputPath), { allowInsecurePermissions: args.allowInsecurePermissions === true });
|
|
498
|
+
if (!Object.prototype.hasOwnProperty.call(latest.resources, name) && Object.keys(latest.resources).length >= 64) {
|
|
499
|
+
throw new Error("local resource registry limit reached (64)");
|
|
500
|
+
}
|
|
501
|
+
latest.resources[name] = inspected;
|
|
502
|
+
saveState(latest);
|
|
503
|
+
const result = { name, ...inspected, contents_exposed: false, available_to_new_jobs_immediately: true };
|
|
504
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
505
|
+
else {
|
|
506
|
+
console.log(`Registered local resource: ${name}`);
|
|
507
|
+
console.log(`Path: ${inspected.path}`);
|
|
508
|
+
console.log(`Mode: ${inspected.mode || "n/a"}; size: ${inspected.size} bytes`);
|
|
509
|
+
console.log("The resource is available to newly submitted managed jobs immediately.");
|
|
510
|
+
}
|
|
511
|
+
} finally {
|
|
512
|
+
lock.release();
|
|
513
|
+
}
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (action === "generate-ssh-key") {
|
|
518
|
+
const name = validateResourceName(args._[1]);
|
|
519
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
520
|
+
if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
|
|
521
|
+
const requestedPath = args._[2] ? expandHome(args._[2]) : join(home, ".ssh", `machine-mcp-${name}-ed25519`);
|
|
522
|
+
const key = await generateRegisteredSshKey({
|
|
523
|
+
workspace,
|
|
524
|
+
stateDir: args.stateDir,
|
|
525
|
+
name,
|
|
526
|
+
targetPath: requestedPath,
|
|
527
|
+
comment: `machine-mcp:${name}`,
|
|
528
|
+
});
|
|
529
|
+
const result = {
|
|
530
|
+
name: key.name,
|
|
531
|
+
created: key.created,
|
|
532
|
+
private_key_path: key.privateKeyPath,
|
|
533
|
+
public_key_path: key.publicKeyPath,
|
|
534
|
+
fingerprint: key.fingerprint,
|
|
535
|
+
key_type: key.keyType,
|
|
536
|
+
private_mode: key.privateMode,
|
|
537
|
+
public_mode: key.publicMode,
|
|
538
|
+
private_key_content_exposed: key.privateKeyContentExposed,
|
|
539
|
+
registered: key.registered,
|
|
540
|
+
available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
|
|
541
|
+
};
|
|
542
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
543
|
+
else {
|
|
544
|
+
console.log(`${key.created ? "Generated and registered" : "Reused and registered"} SSH key resource: ${name}`);
|
|
545
|
+
console.log(`Private key: ${key.privateKeyPath}`);
|
|
546
|
+
console.log(`Public key: ${key.publicKeyPath}`);
|
|
547
|
+
console.log(`Fingerprint: ${key.fingerprint}`);
|
|
548
|
+
console.log("Private key content was not printed or sent through MCP.");
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (action === "remove") {
|
|
554
|
+
const name = validateResourceName(args._[1]);
|
|
555
|
+
const lock = acquireStartupLock(state);
|
|
556
|
+
if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
|
|
557
|
+
try {
|
|
558
|
+
const latest = loadState(workspace, { stateDir: args.stateDir });
|
|
559
|
+
latest.resources ||= {};
|
|
560
|
+
const existed = Object.prototype.hasOwnProperty.call(latest.resources, name);
|
|
561
|
+
delete latest.resources[name];
|
|
562
|
+
saveState(latest);
|
|
563
|
+
const result = { name, removed: existed, affects_new_jobs_immediately: true };
|
|
564
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
565
|
+
else {
|
|
566
|
+
console.log(existed ? `Removed local resource: ${name}` : `Local resource was not registered: ${name}`);
|
|
567
|
+
console.log("The change applies to newly submitted managed jobs immediately.");
|
|
568
|
+
}
|
|
569
|
+
} finally {
|
|
570
|
+
lock.release();
|
|
571
|
+
}
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (action === "check") {
|
|
576
|
+
const name = validateResourceName(args._[1]);
|
|
577
|
+
const resource = state.resources[name];
|
|
578
|
+
if (!resource) throw new Error(`local resource is not registered: ${name}`);
|
|
579
|
+
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
|
|
580
|
+
const result = { name, ...inspected, contents_exposed: false };
|
|
581
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
582
|
+
else console.log(`${name}: available (${inspected.mode || "n/a"}, ${inspected.size} bytes)`);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
throw new Error(`Unknown resource action: ${action}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
async function jobCommand(args) {
|
|
590
|
+
const action = String(args._[0] || "list").toLowerCase();
|
|
591
|
+
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
592
|
+
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
593
|
+
const manager = new ManagedJobManager({
|
|
594
|
+
jobRoot: join(state.paths.profileDir, "jobs"),
|
|
595
|
+
workspace,
|
|
596
|
+
policy: resolvePolicy({}, state.policy),
|
|
597
|
+
resources: state.resources,
|
|
598
|
+
resourceStatePath: state.paths.statePath,
|
|
599
|
+
logger: createLogger({ level: "warn", component: "job" }),
|
|
600
|
+
});
|
|
601
|
+
let result;
|
|
602
|
+
if (action === "list") result = manager.list({ limit: 50 });
|
|
603
|
+
else if (action === "read") result = manager.read({ job_id: args._[1] });
|
|
604
|
+
else if (action === "inspect") result = manager.inspectLocal({ job_id: args._[1] });
|
|
605
|
+
else if (action === "cancel") result = manager.cancel({ job_id: args._[1] });
|
|
606
|
+
else if (action === "approve") {
|
|
607
|
+
if (args.json && !args.yes) throw new Error("job approve --json requires --yes");
|
|
608
|
+
const inspection = manager.inspectLocal({ job_id: args._[1] });
|
|
609
|
+
if (!args.yes) {
|
|
610
|
+
console.log(JSON.stringify(inspection, null, 2));
|
|
611
|
+
const approved = await confirm(`Approve and execute managed job ${args._[1]}?`, false);
|
|
612
|
+
if (!approved) {
|
|
613
|
+
console.log("Managed job approval cancelled. Re-run with --yes after review to skip confirmation.");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
result = manager.approve({ job_id: args._[1] }, { localOperator: true });
|
|
618
|
+
}
|
|
619
|
+
else if (action === "submit") {
|
|
620
|
+
const planPath = args._[1];
|
|
621
|
+
if (!planPath) throw new Error("job submit requires a JSON plan file");
|
|
622
|
+
const plan = loadManagedJobPlan(expandHome(planPath));
|
|
623
|
+
result = manager.start(plan);
|
|
624
|
+
} else throw new Error(`Unknown job action: ${action}`);
|
|
625
|
+
console.log(JSON.stringify(result, null, 2));
|
|
443
626
|
}
|
|
444
627
|
|
|
445
628
|
async function clientConfigCommand(args) {
|
|
@@ -741,16 +924,56 @@ async function doctorCommand(args) {
|
|
|
741
924
|
const storedPolicyOrigin = state.policy?.origin;
|
|
742
925
|
state.policy = resolvePolicy({}, state.policy);
|
|
743
926
|
checks.push({ name: "policy", ok: true, detail: formatPolicySummary(state.policy) });
|
|
927
|
+
if (state.policy.profile === "full") {
|
|
928
|
+
try {
|
|
929
|
+
assertCanonicalFullPolicy(state.policy);
|
|
930
|
+
checks.push({ name: "full-policy-contract", ok: true, detail: `${toolsForPolicy(state.policy).length} tools exposed` });
|
|
931
|
+
} catch (error) {
|
|
932
|
+
checks.push({ name: "full-policy-contract", ok: false, detail: sanitizeLines(error?.message || error) });
|
|
933
|
+
}
|
|
934
|
+
}
|
|
744
935
|
const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
|
|
745
936
|
checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
|
|
937
|
+
const diagnosticRuntime = new LocalDaemon({
|
|
938
|
+
workspace,
|
|
939
|
+
policy: state.policy,
|
|
940
|
+
logger: createLogger({ level: "error", component: "doctor" }),
|
|
941
|
+
jobRoot: join(state.paths.profileDir, "jobs"),
|
|
942
|
+
resources: state.resources,
|
|
943
|
+
resourceStatePath: state.paths.statePath,
|
|
944
|
+
recoverJobs: false,
|
|
945
|
+
});
|
|
946
|
+
let runtimeDiagnostics;
|
|
947
|
+
try {
|
|
948
|
+
runtimeDiagnostics = await diagnosticRuntime.diagnoseRuntime();
|
|
949
|
+
} finally {
|
|
950
|
+
diagnosticRuntime.stop();
|
|
951
|
+
}
|
|
952
|
+
for (const check of runtimeDiagnostics.checks) {
|
|
953
|
+
checks.push({
|
|
954
|
+
name: `runtime:${check.layer}`,
|
|
955
|
+
ok: check.skipped === true || check.ok === true,
|
|
956
|
+
detail: check.skipped ? `skipped (${check.error_class || "not applicable"})` : check.ok ? "ok" : check.error_class || "failed",
|
|
957
|
+
});
|
|
958
|
+
}
|
|
746
959
|
console.log(JSON.stringify({
|
|
747
960
|
ok: checks.every(check => check.ok),
|
|
748
961
|
checks,
|
|
962
|
+
runtimeDiagnostics,
|
|
749
963
|
policyMigrationPending: !storedPolicyOrigin && state.policy.origin === "migrated",
|
|
750
964
|
state: redactState(state),
|
|
751
965
|
}, null, 2));
|
|
752
966
|
}
|
|
753
967
|
|
|
968
|
+
async function fullTestCommand(args) {
|
|
969
|
+
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
970
|
+
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
971
|
+
const policy = resolvePolicy({}, state.policy);
|
|
972
|
+
const result = await runFullAccessTest({ workspace, policy });
|
|
973
|
+
console.log(JSON.stringify(result, null, 2));
|
|
974
|
+
if (!result.ok) process.exitCode = 1;
|
|
975
|
+
}
|
|
976
|
+
|
|
754
977
|
async function rotateSecretsCommand(args) {
|
|
755
978
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
756
979
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
@@ -847,6 +1070,12 @@ async function uninstallCommand(args) {
|
|
|
847
1070
|
console.log("Uninstall cancelled. Re-run with `machine-mcp uninstall --yes` to skip confirmation.");
|
|
848
1071
|
return;
|
|
849
1072
|
}
|
|
1073
|
+
const activeJobs = activeStateJobs(stateRoot);
|
|
1074
|
+
if (activeJobs.length) {
|
|
1075
|
+
const detail = activeJobs.slice(0, 5).map((item) => `${item.job_id}:${item.status}`).join(", ");
|
|
1076
|
+
const suffix = activeJobs.length > 5 ? `, and ${activeJobs.length - 5} more` : "";
|
|
1077
|
+
throw new Error(`refusing to uninstall while managed jobs are active (${detail}${suffix}); inspect or cancel them with machine-mcp job list/cancel`);
|
|
1078
|
+
}
|
|
850
1079
|
const autostartRemoved = await removeAutostartBestEffort(stateRoot);
|
|
851
1080
|
if (!autostartRemoved) throw new Error("autostart removal failed; state and Worker were kept so the uninstall can be retried safely");
|
|
852
1081
|
await sleep(500);
|
|
@@ -918,6 +1147,19 @@ async function removeAutostartBestEffort(stateRoot) {
|
|
|
918
1147
|
}
|
|
919
1148
|
}
|
|
920
1149
|
|
|
1150
|
+
function activeStateJobs(stateRoot) {
|
|
1151
|
+
const profiles = resolve(expandHome(stateRoot), "profiles");
|
|
1152
|
+
if (!existsSync(profiles)) return [];
|
|
1153
|
+
const active = [];
|
|
1154
|
+
for (const profile of readdirSync(profiles, { withFileTypes: true })) {
|
|
1155
|
+
if (!profile.isDirectory()) continue;
|
|
1156
|
+
for (const job of activeManagedJobs(resolve(profiles, profile.name, "jobs"))) {
|
|
1157
|
+
active.push({ profile: profile.name, ...job });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
return active;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
921
1163
|
function activeStateLocks(stateRoot) {
|
|
922
1164
|
const profiles = resolve(expandHome(stateRoot), "profiles");
|
|
923
1165
|
if (!existsSync(profiles)) return [];
|
|
@@ -1003,7 +1245,10 @@ Commands:
|
|
|
1003
1245
|
service uninstall Remove only the autostart entry
|
|
1004
1246
|
status Print redacted local profile state and Worker health
|
|
1005
1247
|
doctor Check Node, Wrangler, Cloudflare login, Worker health
|
|
1248
|
+
full-test Run real local full-profile capability tests in a temporary sandbox
|
|
1006
1249
|
rotate-secrets Rotate MCP password and daemon secret in local state
|
|
1250
|
+
resource generate-ssh-key NAME [PATH]
|
|
1251
|
+
Generate/reuse an Ed25519 key locally and register its private file by alias
|
|
1007
1252
|
uninstall Delete known Worker(s), remove autostart and local state
|
|
1008
1253
|
|
|
1009
1254
|
Start options:
|
|
@@ -1027,6 +1272,8 @@ Start options:
|
|
|
1027
1272
|
--log-level LEVEL error, warn, info (default), or debug
|
|
1028
1273
|
--verbose Alias for --log-level debug; includes per-tool success/correlation logs
|
|
1029
1274
|
--quiet Alias for --log-level error
|
|
1275
|
+
--allow-insecure-permissions
|
|
1276
|
+
Permit resource registration when a file is group/other-readable
|
|
1030
1277
|
|
|
1031
1278
|
Uninstall options:
|
|
1032
1279
|
--keep-worker Do not delete deployed Worker(s) during uninstall
|
package/src/local/daemon.mjs
CHANGED
|
@@ -9,8 +9,11 @@ import { applyUpdateHunks, parsePatchEnvelope } from "./patch.mjs";
|
|
|
9
9
|
import { executionEnv, workspaceShellCommand } from "./shell.mjs";
|
|
10
10
|
import { MAX_COMMAND_BYTES, ProcessSessionManager, terminateProcessTree, validateArgv } from "./process-sessions.mjs";
|
|
11
11
|
export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
|
|
12
|
-
import { MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, SERVER_NAME, toolNamesForPolicy } from "./tools.mjs";
|
|
12
|
+
import { allToolNames, assertCanonicalFullPolicy, isCanonicalFullPolicy, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, POLICY_PROFILES, SERVER_NAME, toolNamesForPolicy } from "./tools.mjs";
|
|
13
13
|
import { classifyOperationalError } from "./log.mjs";
|
|
14
|
+
import { ManagedJobManager } from "./managed-jobs.mjs";
|
|
15
|
+
import { generateRegisteredSshKey } from "./resource-operations.mjs";
|
|
16
|
+
import { expandHome } from "./state.mjs";
|
|
14
17
|
|
|
15
18
|
export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
|
|
16
19
|
const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
@@ -22,7 +25,7 @@ const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
|
22
25
|
const SLOW_TOOL_CALL_MS = 30_000;
|
|
23
26
|
|
|
24
27
|
export class LocalDaemon {
|
|
25
|
-
constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null }) {
|
|
28
|
+
constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null, jobRoot = "", resources = {}, resourceStatePath = "", recoverJobs = true }) {
|
|
26
29
|
this.workerUrl = workerUrl ? normalizeWorkerUrl(workerUrl) : "";
|
|
27
30
|
if (this.workerUrl && (typeof secret !== "string" || secret.length < 16)) throw new Error("daemon secret is missing or too short");
|
|
28
31
|
this.secret = secret || "";
|
|
@@ -32,6 +35,7 @@ export class LocalDaemon {
|
|
|
32
35
|
this.policy = normalizePolicy(policy);
|
|
33
36
|
this.logger = logger;
|
|
34
37
|
this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
|
|
38
|
+
this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
|
|
35
39
|
this.closed = false;
|
|
36
40
|
this.ws = null;
|
|
37
41
|
this.heartbeat = null;
|
|
@@ -46,6 +50,16 @@ export class LocalDaemon {
|
|
|
46
50
|
this.reconnectAttempt = 0;
|
|
47
51
|
this.mutationQueue = Promise.resolve();
|
|
48
52
|
this.runtimeDir = createRuntimeDir();
|
|
53
|
+
if (typeof jobRoot !== "string" || !jobRoot.trim()) throw new Error("persistent managed-job root is required");
|
|
54
|
+
this.managedJobManager = new ManagedJobManager({
|
|
55
|
+
jobRoot,
|
|
56
|
+
workspace: this.workspace,
|
|
57
|
+
policy: this.policy,
|
|
58
|
+
resources,
|
|
59
|
+
resourceStatePath,
|
|
60
|
+
logger: this.logger,
|
|
61
|
+
recover: recoverJobs,
|
|
62
|
+
});
|
|
49
63
|
this.processSessionManager = new ProcessSessionManager({
|
|
50
64
|
workspace: this.workspace,
|
|
51
65
|
policy: this.policy,
|
|
@@ -74,6 +88,11 @@ export class LocalDaemon {
|
|
|
74
88
|
workspace: this.displayPath(this.workspace),
|
|
75
89
|
workspace_name: basename(this.workspace),
|
|
76
90
|
policy: this.policy,
|
|
91
|
+
policy_contract: {
|
|
92
|
+
named_profile_is_canonical: this.policy.profile === "custom" || policyMatchesNamedProfile(this.policy),
|
|
93
|
+
full_catalog_complete: this.policy.profile === "full" ? isCanonicalFullPolicy(this.policy) && this.tools().length + 1 === allToolNames().length : null,
|
|
94
|
+
machine_bridge_internal_denials_under_full: this.policy.profile === "full" && isCanonicalFullPolicy(this.policy) ? false : null,
|
|
95
|
+
},
|
|
77
96
|
enforcement: {
|
|
78
97
|
filesystem_scope: this.policy.unrestrictedPaths ? "local-user-accessible" : "workspace",
|
|
79
98
|
sensitive_filename_filter: false,
|
|
@@ -81,10 +100,17 @@ export class LocalDaemon {
|
|
|
81
100
|
host_policy_is_independent: true,
|
|
82
101
|
},
|
|
83
102
|
tools: ["server_info", ...this.tools()],
|
|
103
|
+
observability: {
|
|
104
|
+
per_tool_events: "debug-only",
|
|
105
|
+
default_logs_include_tool_failures: false,
|
|
106
|
+
tool_arguments_or_results_logged: false,
|
|
107
|
+
},
|
|
84
108
|
runtime: {
|
|
85
109
|
environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
|
|
86
110
|
runtime_dir: this.policy.exposeAbsolutePaths ? this.runtimeDir : "<private-runtime-dir>",
|
|
87
111
|
process_sessions: this.processSessionManager.status(),
|
|
112
|
+
managed_jobs: this.managedJobManager.status(),
|
|
113
|
+
local_resources: this.managedJobManager.resourceInfo(),
|
|
88
114
|
},
|
|
89
115
|
};
|
|
90
116
|
}
|
|
@@ -238,14 +264,12 @@ export class LocalDaemon {
|
|
|
238
264
|
if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
|
|
239
265
|
this.send({ type: "tool_result", id, ok: true, result });
|
|
240
266
|
const durationMs = Date.now() - started;
|
|
241
|
-
|
|
242
|
-
else this.logger.debug?.("tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
|
|
267
|
+
this.logger.debug?.(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
|
|
243
268
|
} catch (error) {
|
|
244
269
|
const safeError = this.safeErrorMessage(error);
|
|
245
270
|
this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
|
|
246
271
|
const durationMs = Date.now() - started;
|
|
247
|
-
this.logger.
|
|
248
|
-
this.logger.debug?.("tool call failure correlation", { call_id: shortCallId(id) });
|
|
272
|
+
this.logger.debug?.("tool call failed", { call_id: shortCallId(id), tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
249
273
|
} finally {
|
|
250
274
|
clearTimeout(deadline);
|
|
251
275
|
this.activeToolCalls -= 1;
|
|
@@ -291,6 +315,14 @@ export class LocalDaemon {
|
|
|
291
315
|
case "git_diff": return this.gitDiff(args, context);
|
|
292
316
|
case "git_log": return this.gitLog(args, context);
|
|
293
317
|
case "git_show": return this.gitShow(args, context);
|
|
318
|
+
case "diagnose_runtime": return this.diagnoseRuntime(context);
|
|
319
|
+
case "list_local_resources": return this.managedJobManager.listResources();
|
|
320
|
+
case "generate_ssh_key_resource": return this.generateSshKeyResource(args, context);
|
|
321
|
+
case "stage_job": return this.managedJobManager.stage(args);
|
|
322
|
+
case "start_job": return this.managedJobManager.start(args);
|
|
323
|
+
case "list_jobs": return this.managedJobManager.list(args);
|
|
324
|
+
case "read_job": return this.managedJobManager.read(args);
|
|
325
|
+
case "cancel_job": return this.managedJobManager.cancel(args);
|
|
294
326
|
case "run_process": return this.runDirectProcess(args, context);
|
|
295
327
|
case "start_process": return this.processSessionManager.start(args, context);
|
|
296
328
|
case "read_process": return this.processSessionManager.read(args, context);
|
|
@@ -629,6 +661,117 @@ export class LocalDaemon {
|
|
|
629
661
|
return { ok: true, target, root, pathspec: repoRelative || "" };
|
|
630
662
|
}
|
|
631
663
|
|
|
664
|
+
async diagnoseRuntime(context = {}) {
|
|
665
|
+
this.throwIfCancelled(context);
|
|
666
|
+
const checks = [];
|
|
667
|
+
checks.push({
|
|
668
|
+
layer: "mcp-host-to-daemon",
|
|
669
|
+
ok: true,
|
|
670
|
+
detail: "This diagnostic request reached the local Machine Bridge runtime.",
|
|
671
|
+
});
|
|
672
|
+
checks.push({
|
|
673
|
+
layer: "machine-bridge-policy",
|
|
674
|
+
ok: this.policy.execMode === "direct" || this.policy.execMode === "shell",
|
|
675
|
+
detail: `profile=${this.policy.profile}; exec_mode=${this.policy.execMode}; unrestricted_paths=${this.policy.unrestrictedPaths}`,
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
const probe = join(this.runtimeDir, `.diagnostic-${process.pid}-${randomBytes(6).toString("hex")}`);
|
|
679
|
+
try {
|
|
680
|
+
await writeFile(probe, "ok\n", { mode: 0o600, flag: "wx" });
|
|
681
|
+
const { buffer } = await readBoundedFile(probe, 64, "diagnostic file");
|
|
682
|
+
checks.push({ layer: "local-filesystem", ok: buffer.toString("utf8") === "ok\n", error_class: null });
|
|
683
|
+
} catch (error) {
|
|
684
|
+
checks.push({ layer: "local-filesystem", ok: false, error_class: classifyOperationalError(error) });
|
|
685
|
+
} finally {
|
|
686
|
+
await rm(probe, { force: true }).catch(() => {});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (this.policy.execMode === "direct" || this.policy.execMode === "shell") {
|
|
690
|
+
const direct = await this.runProcess(
|
|
691
|
+
process.execPath,
|
|
692
|
+
["-e", "process.stdout.write('ok')"],
|
|
693
|
+
5000,
|
|
694
|
+
true,
|
|
695
|
+
1024,
|
|
696
|
+
context,
|
|
697
|
+
this.workspace,
|
|
698
|
+
).catch((error) => ({ code: 127, stdout: "", stderr: "", error_class: classifyOperationalError(error) }));
|
|
699
|
+
checks.push({
|
|
700
|
+
layer: "local-process-spawn",
|
|
701
|
+
ok: direct.code === 0 && direct.stdout === "ok",
|
|
702
|
+
error_class: direct.error_class || (direct.code === 0 ? null : classifyOperationalError(direct.stderr || direct.stdout || "execution failed")),
|
|
703
|
+
});
|
|
704
|
+
} else {
|
|
705
|
+
checks.push({ layer: "local-process-spawn", ok: false, skipped: true, error_class: "policy_denied" });
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (this.policy.execMode === "shell") {
|
|
709
|
+
const shell = workspaceShellCommand(process.platform === "win32" ? "cd" : "pwd");
|
|
710
|
+
const result = await this.runProcess(shell.cmd, shell.args, 5000, true, 4096, context, this.workspace)
|
|
711
|
+
.catch((error) => ({ code: 127, error_class: classifyOperationalError(error) }));
|
|
712
|
+
checks.push({
|
|
713
|
+
layer: "local-shell",
|
|
714
|
+
ok: result.code === 0,
|
|
715
|
+
error_class: result.error_class || (result.code === 0 ? null : classifyOperationalError(result.stderr || result.stdout || "execution failed")),
|
|
716
|
+
});
|
|
717
|
+
} else {
|
|
718
|
+
checks.push({ layer: "local-shell", ok: false, skipped: true, error_class: "policy_denied" });
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const storage = this.managedJobManager.diagnoseStorage();
|
|
722
|
+
checks.push({ layer: "managed-job-storage", ...storage });
|
|
723
|
+
const resources = this.managedJobManager.listResources();
|
|
724
|
+
checks.push({
|
|
725
|
+
layer: "local-resource-registry",
|
|
726
|
+
ok: resources.resources.every((resource) => resource.available),
|
|
727
|
+
registered: resources.count,
|
|
728
|
+
unavailable: resources.resources.filter((resource) => !resource.available).map((resource) => ({ name: resource.name, error_class: resource.error_class })),
|
|
729
|
+
});
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
request_reached_local_runtime: true,
|
|
733
|
+
interpretation: {
|
|
734
|
+
tool_call_blocked_before_response: "host/platform or connector gateway",
|
|
735
|
+
diagnostic_reached_daemon_but_spawn_failed: "local OS, endpoint security, shell configuration, or Machine Bridge policy",
|
|
736
|
+
managed_job_accepted_then_later_tools_blocked: "job continues independently; inspect with local CLI or a later read_job call",
|
|
737
|
+
},
|
|
738
|
+
policy: this.policy,
|
|
739
|
+
checks,
|
|
740
|
+
ok: checks.filter((check) => !check.skipped).every((check) => check.ok),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async generateSshKeyResource(args = {}, context = {}) {
|
|
745
|
+
this.throwIfCancelled(context);
|
|
746
|
+
assertCanonicalFullPolicy(this.policy);
|
|
747
|
+
if (!this.resourceStatePath) throw new Error("local resource state is unavailable in this runtime");
|
|
748
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
749
|
+
if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
|
|
750
|
+
const target = args.path
|
|
751
|
+
? resolve(expandHome(String(args.path)))
|
|
752
|
+
: resolve(home, ".ssh", `machine-mcp-${args.name}-ed25519`);
|
|
753
|
+
const key = await generateRegisteredSshKey({
|
|
754
|
+
workspace: this.workspace,
|
|
755
|
+
stateDir: stateRootFromProfileStatePath(this.resourceStatePath),
|
|
756
|
+
name: args.name,
|
|
757
|
+
targetPath: target,
|
|
758
|
+
comment: args.comment || `machine-mcp:${args.name}`,
|
|
759
|
+
});
|
|
760
|
+
return {
|
|
761
|
+
name: key.name,
|
|
762
|
+
created: key.created,
|
|
763
|
+
registered: key.registered,
|
|
764
|
+
private_key_path: this.displayPath(key.privateKeyPath),
|
|
765
|
+
public_key_path: this.displayPath(key.publicKeyPath),
|
|
766
|
+
fingerprint: key.fingerprint,
|
|
767
|
+
key_type: key.keyType,
|
|
768
|
+
private_mode: key.privateMode,
|
|
769
|
+
public_mode: key.publicMode,
|
|
770
|
+
private_key_content_exposed: key.privateKeyContentExposed,
|
|
771
|
+
available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
632
775
|
async runDirectProcess(args, context = {}) {
|
|
633
776
|
if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error("run_process is disabled by daemon policy");
|
|
634
777
|
const argv = validateArgv(args.argv);
|
|
@@ -831,6 +974,25 @@ export class LocalDaemon {
|
|
|
831
974
|
}
|
|
832
975
|
}
|
|
833
976
|
|
|
977
|
+
function policyMatchesNamedProfile(policy) {
|
|
978
|
+
const named = POLICY_PROFILES[policy.profile];
|
|
979
|
+
if (!named) return false;
|
|
980
|
+
return policy.allowWrite === named.allowWrite
|
|
981
|
+
&& policy.execMode === named.execMode
|
|
982
|
+
&& policy.unrestrictedPaths === named.unrestrictedPaths
|
|
983
|
+
&& policy.minimalEnv === named.minimalEnv
|
|
984
|
+
&& policy.exposeAbsolutePaths === named.exposeAbsolutePaths;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function stateRootFromProfileStatePath(statePath) {
|
|
988
|
+
const absolute = resolve(statePath);
|
|
989
|
+
if (basename(absolute) !== "state.json") throw new Error("local resource state path is invalid");
|
|
990
|
+
const profileDir = dirname(absolute);
|
|
991
|
+
const profilesDir = dirname(profileDir);
|
|
992
|
+
if (basename(profilesDir) !== "profiles") throw new Error("local resource state path is outside the expected profile layout");
|
|
993
|
+
return dirname(profilesDir);
|
|
994
|
+
}
|
|
995
|
+
|
|
834
996
|
function normalizeWorkerUrl(value) {
|
|
835
997
|
let url;
|
|
836
998
|
try { url = new URL(String(value || "")); } catch { throw new Error("invalid Worker URL"); }
|