machine-bridge-mcp 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +32 -12
- package/SECURITY.md +20 -10
- package/docs/ARCHITECTURE.md +16 -8
- package/docs/CLIENTS.md +11 -1
- package/docs/LOGGING.md +13 -18
- package/docs/MANAGED_JOBS.md +20 -11
- package/docs/OPERATIONS.md +31 -10
- package/docs/PRIVACY.md +37 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +22 -2
- package/package.json +39 -10
- package/scripts/network-retry.mjs +47 -0
- package/scripts/privacy-check.mjs +177 -0
- package/scripts/release-impact-check.mjs +78 -0
- package/src/local/atomic-fs.mjs +37 -0
- package/src/local/cli.mjs +113 -15
- package/src/local/daemon.mjs +114 -15
- package/src/local/full-access-test.mjs +206 -0
- package/src/local/job-runner.mjs +73 -15
- package/src/local/log.mjs +26 -2
- package/src/local/managed-jobs.mjs +165 -75
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/resource-operations.mjs +66 -0
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +127 -0
- package/src/local/state.mjs +12 -9
- package/src/local/stdio.mjs +78 -20
- package/src/local/tools.mjs +37 -3
- package/src/shared/server-metadata.json +4 -1
- package/src/shared/tool-catalog.json +37 -0
- package/src/worker/index.ts +41 -18
- package/wrangler.jsonc +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { renameSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const TRANSIENT_REPLACE_ERRORS = new Set(["EACCES", "EBUSY", "EPERM", "ENOTEMPTY"]);
|
|
4
|
+
const WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
5
|
+
|
|
6
|
+
export function replaceFileSync(source, target, options = {}) {
|
|
7
|
+
const rename = typeof options.rename === "function" ? options.rename : renameSync;
|
|
8
|
+
const attempts = clampInteger(options.attempts, 8, 1, 32);
|
|
9
|
+
const baseDelayMs = clampInteger(options.baseDelayMs, 15, 0, 1000);
|
|
10
|
+
let lastError;
|
|
11
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
12
|
+
try {
|
|
13
|
+
rename(source, target);
|
|
14
|
+
return { attempts: attempt };
|
|
15
|
+
} catch (error) {
|
|
16
|
+
lastError = error;
|
|
17
|
+
if (!isTransientReplaceError(error) || attempt === attempts) throw error;
|
|
18
|
+
sleepSync(Math.min(baseDelayMs * attempt, 250));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
throw lastError || new Error("atomic file replacement failed");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isTransientReplaceError(error) {
|
|
25
|
+
return TRANSIENT_REPLACE_ERRORS.has(String(error?.code || ""));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sleepSync(milliseconds) {
|
|
29
|
+
if (milliseconds <= 0) return;
|
|
30
|
+
Atomics.wait(WAIT_BUFFER, 0, 0, milliseconds);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function clampInteger(value, fallback, minimum, maximum) {
|
|
34
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
35
|
+
const number = Number.isFinite(parsed) ? parsed : fallback;
|
|
36
|
+
return Math.min(Math.max(number, minimum), maximum);
|
|
37
|
+
}
|
package/src/local/cli.mjs
CHANGED
|
@@ -5,10 +5,12 @@ 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
10
|
import { activeManagedJobs, inspectResourceFile, loadManagedJobPlan, ManagedJobManager, publicResourceRegistry, validateResourceName } from "./managed-jobs.mjs";
|
|
11
11
|
import { runWrangler } from "./shell.mjs";
|
|
12
|
+
import { generateRegisteredSshKey } from "./resource-operations.mjs";
|
|
13
|
+
import { runFullAccessTest } from "./full-access-test.mjs";
|
|
12
14
|
import {
|
|
13
15
|
acquireDaemonLock,
|
|
14
16
|
acquireStartupLock,
|
|
@@ -38,7 +40,7 @@ const BOOLEAN_OPTIONS = new Set([
|
|
|
38
40
|
"help", "version", "quiet", "json", "verbose", "rotateSecrets", "forceWorker",
|
|
39
41
|
"daemonOnly", "noAutostart", "noPrintCredentials", "printMcpCredentials",
|
|
40
42
|
"printCredentials", "noWrite", "noExec", "fullEnv", "unrestrictedPaths", "absolutePaths",
|
|
41
|
-
"yes", "keepWorker", "allowInsecurePermissions",
|
|
43
|
+
"yes", "keepWorker", "allowInsecurePermissions", "showPaths",
|
|
42
44
|
]);
|
|
43
45
|
const VALUE_OPTIONS = new Set([
|
|
44
46
|
"workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
|
|
@@ -60,6 +62,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
60
62
|
case "client-config": return clientConfigCommand(args);
|
|
61
63
|
case "status": return statusCommand(args);
|
|
62
64
|
case "doctor": return doctorCommand(args);
|
|
65
|
+
case "full-test": return fullTestCommand(args);
|
|
63
66
|
case "workspace": return workspaceCommand(args);
|
|
64
67
|
case "service":
|
|
65
68
|
case "autostart": return serviceCommand(args);
|
|
@@ -84,11 +87,12 @@ const COMMAND_OPTIONS = {
|
|
|
84
87
|
"client-config": new Set(["workspace", "stateDir", "profile", "client", "json"]),
|
|
85
88
|
status: new Set(["workspace", "stateDir"]),
|
|
86
89
|
doctor: new Set(["workspace", "stateDir"]),
|
|
90
|
+
"full-test": new Set(["workspace", "stateDir", "json"]),
|
|
87
91
|
"rotate-secrets": new Set(["workspace", "stateDir", "workerName", "noPrintCredentials", "printMcpCredentials", "printCredentials", "quiet"]),
|
|
88
92
|
workspace: new Set(["workspace", "stateDir"]),
|
|
89
93
|
service: new Set(["workspace", "stateDir", "quiet"]),
|
|
90
94
|
autostart: new Set(["workspace", "stateDir", "quiet"]),
|
|
91
|
-
resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "json"]),
|
|
95
|
+
resource: new Set(["workspace", "stateDir", "allowInsecurePermissions", "showPaths", "json"]),
|
|
92
96
|
job: new Set(["workspace", "stateDir", "json", "yes"]),
|
|
93
97
|
uninstall: new Set(["stateDir", "keepWorker", "yes"]),
|
|
94
98
|
};
|
|
@@ -123,7 +127,7 @@ function toKebab(value) {
|
|
|
123
127
|
|
|
124
128
|
export function validatePositionals(command, args) {
|
|
125
129
|
const count = args._.length;
|
|
126
|
-
if (["start", "stdio", "status", "doctor", "rotate-secrets"].includes(command)) {
|
|
130
|
+
if (["start", "stdio", "status", "doctor", "full-test", "rotate-secrets"].includes(command)) {
|
|
127
131
|
if (count > 1) throw new Error(`${command} accepts at most one positional workspace path`);
|
|
128
132
|
if (args.workspace && count) throw new Error("workspace path was provided both positionally and with --workspace");
|
|
129
133
|
return;
|
|
@@ -148,7 +152,7 @@ export function validatePositionals(command, args) {
|
|
|
148
152
|
}
|
|
149
153
|
if (command === "resource") {
|
|
150
154
|
const action = String(args._[0] || "list");
|
|
151
|
-
const max = action === "add" ? 3 : action === "remove" || action === "check" ? 2 : 1;
|
|
155
|
+
const max = action === "add" || action === "generate-ssh-key" ? 3 : action === "remove" || action === "check" ? 2 : 1;
|
|
152
156
|
if (count > max) throw new Error(`resource ${action} received too many positional arguments`);
|
|
153
157
|
return;
|
|
154
158
|
}
|
|
@@ -474,10 +478,19 @@ async function resourceCommand(args) {
|
|
|
474
478
|
state.resources ||= {};
|
|
475
479
|
|
|
476
480
|
if (action === "list") {
|
|
477
|
-
const
|
|
478
|
-
|
|
481
|
+
const includePaths = args.showPaths === true;
|
|
482
|
+
const resources = publicResourceRegistry(state.resources, { includePaths });
|
|
483
|
+
if (args.json) console.log(JSON.stringify({
|
|
484
|
+
workspace: includePaths ? workspace : "<local-workspace>",
|
|
485
|
+
paths_exposed: includePaths,
|
|
486
|
+
resources,
|
|
487
|
+
}, null, 2));
|
|
479
488
|
else if (!Object.keys(resources).length) console.log("No local resources registered.");
|
|
480
|
-
else for (const [name, value] of Object.entries(resources))
|
|
489
|
+
else for (const [name, value] of Object.entries(resources)) {
|
|
490
|
+
const fields = [name, value.mode || "n/a", `${value.size ?? "n/a"} bytes`];
|
|
491
|
+
if (includePaths) fields.splice(1, 0, value.path);
|
|
492
|
+
console.log(fields.join("\t"));
|
|
493
|
+
}
|
|
481
494
|
return;
|
|
482
495
|
}
|
|
483
496
|
|
|
@@ -496,11 +509,14 @@ async function resourceCommand(args) {
|
|
|
496
509
|
}
|
|
497
510
|
latest.resources[name] = inspected;
|
|
498
511
|
saveState(latest);
|
|
499
|
-
const result =
|
|
512
|
+
const result = publicResourceInspection(name, inspected, {
|
|
513
|
+
includePath: args.showPaths === true,
|
|
514
|
+
available_to_new_jobs_immediately: true,
|
|
515
|
+
});
|
|
500
516
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
501
517
|
else {
|
|
502
518
|
console.log(`Registered local resource: ${name}`);
|
|
503
|
-
console.log(`Path: ${inspected.path}`);
|
|
519
|
+
if (args.showPaths === true) console.log(`Path: ${inspected.path}`);
|
|
504
520
|
console.log(`Mode: ${inspected.mode || "n/a"}; size: ${inspected.size} bytes`);
|
|
505
521
|
console.log("The resource is available to newly submitted managed jobs immediately.");
|
|
506
522
|
}
|
|
@@ -510,6 +526,45 @@ async function resourceCommand(args) {
|
|
|
510
526
|
return;
|
|
511
527
|
}
|
|
512
528
|
|
|
529
|
+
if (action === "generate-ssh-key") {
|
|
530
|
+
const name = validateResourceName(args._[1]);
|
|
531
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
532
|
+
if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
|
|
533
|
+
const requestedPath = args._[2] ? expandHome(args._[2]) : join(home, ".ssh", `machine-mcp-${name}-ed25519`);
|
|
534
|
+
const key = await generateRegisteredSshKey({
|
|
535
|
+
workspace,
|
|
536
|
+
stateDir: args.stateDir,
|
|
537
|
+
name,
|
|
538
|
+
targetPath: requestedPath,
|
|
539
|
+
comment: `machine-mcp:${name}`,
|
|
540
|
+
});
|
|
541
|
+
const includePaths = args.showPaths === true;
|
|
542
|
+
const result = {
|
|
543
|
+
name: key.name,
|
|
544
|
+
created: key.created,
|
|
545
|
+
fingerprint: key.fingerprint,
|
|
546
|
+
key_type: key.keyType,
|
|
547
|
+
private_mode: key.privateMode,
|
|
548
|
+
public_mode: key.publicMode,
|
|
549
|
+
private_key_content_exposed: key.privateKeyContentExposed,
|
|
550
|
+
registered: key.registered,
|
|
551
|
+
available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
|
|
552
|
+
paths_exposed: includePaths,
|
|
553
|
+
...(includePaths ? { private_key_path: key.privateKeyPath, public_key_path: key.publicKeyPath } : {}),
|
|
554
|
+
};
|
|
555
|
+
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
556
|
+
else {
|
|
557
|
+
console.log(`${key.created ? "Generated and registered" : "Reused and registered"} SSH key resource: ${name}`);
|
|
558
|
+
if (includePaths) {
|
|
559
|
+
console.log(`Private key: ${key.privateKeyPath}`);
|
|
560
|
+
console.log(`Public key: ${key.publicKeyPath}`);
|
|
561
|
+
}
|
|
562
|
+
console.log(`Fingerprint: ${key.fingerprint}`);
|
|
563
|
+
console.log("Private key content was not printed or sent through MCP.");
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
513
568
|
if (action === "remove") {
|
|
514
569
|
const name = validateResourceName(args._[1]);
|
|
515
570
|
const lock = acquireStartupLock(state);
|
|
@@ -537,15 +592,33 @@ async function resourceCommand(args) {
|
|
|
537
592
|
const resource = state.resources[name];
|
|
538
593
|
if (!resource) throw new Error(`local resource is not registered: ${name}`);
|
|
539
594
|
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
|
|
540
|
-
const result =
|
|
595
|
+
const result = publicResourceInspection(name, inspected, { includePath: args.showPaths === true });
|
|
541
596
|
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
542
|
-
else
|
|
597
|
+
else {
|
|
598
|
+
const pathDetail = args.showPaths === true ? ` at ${inspected.path}` : "";
|
|
599
|
+
console.log(`${name}: available${pathDetail} (${inspected.mode || "n/a"}, ${inspected.size} bytes)`);
|
|
600
|
+
}
|
|
543
601
|
return;
|
|
544
602
|
}
|
|
545
603
|
|
|
546
604
|
throw new Error(`Unknown resource action: ${action}`);
|
|
547
605
|
}
|
|
548
606
|
|
|
607
|
+
function publicResourceInspection(name, inspected, { includePath = false, ...extra } = {}) {
|
|
608
|
+
return {
|
|
609
|
+
name,
|
|
610
|
+
kind: inspected.kind,
|
|
611
|
+
size: inspected.size ?? null,
|
|
612
|
+
mode: inspected.mode ?? null,
|
|
613
|
+
updated_at: inspected.updatedAt ?? null,
|
|
614
|
+
allow_insecure_permissions: inspected.allowInsecurePermissions === true,
|
|
615
|
+
...extra,
|
|
616
|
+
paths_exposed: includePath,
|
|
617
|
+
contents_exposed: false,
|
|
618
|
+
...(includePath ? { path: inspected.path } : {}),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
549
622
|
async function jobCommand(args) {
|
|
550
623
|
const action = String(args._[0] || "list").toLowerCase();
|
|
551
624
|
const workspace = await chooseWorkspace({ ...args, _: [] }, { promptOnFirstRun: false, save: false, allowPositional: false });
|
|
@@ -875,7 +948,7 @@ async function statusCommand(args) {
|
|
|
875
948
|
async function doctorCommand(args) {
|
|
876
949
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
877
950
|
const checks = [];
|
|
878
|
-
checks.push({ name: "node", ok:
|
|
951
|
+
checks.push({ name: "node", ok: isSupportedNodeVersion(), detail: process.version });
|
|
879
952
|
const wrangler = await runWrangler(["--version"], { capture: true, allowFailure: true });
|
|
880
953
|
checks.push({ name: "wrangler", ok: wrangler.code === 0, detail: (wrangler.stdout || wrangler.stderr).trim() });
|
|
881
954
|
const whoami = await runWrangler(["whoami"], { capture: true, allowFailure: true });
|
|
@@ -884,6 +957,14 @@ async function doctorCommand(args) {
|
|
|
884
957
|
const storedPolicyOrigin = state.policy?.origin;
|
|
885
958
|
state.policy = resolvePolicy({}, state.policy);
|
|
886
959
|
checks.push({ name: "policy", ok: true, detail: formatPolicySummary(state.policy) });
|
|
960
|
+
if (state.policy.profile === "full") {
|
|
961
|
+
try {
|
|
962
|
+
assertCanonicalFullPolicy(state.policy);
|
|
963
|
+
checks.push({ name: "full-policy-contract", ok: true, detail: `${toolsForPolicy(state.policy).length} tools exposed` });
|
|
964
|
+
} catch (error) {
|
|
965
|
+
checks.push({ name: "full-policy-contract", ok: false, detail: sanitizeLines(error?.message || error) });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
887
968
|
const health = state.worker?.url ? await workerHealth(state.worker.url) : { ok: false, error: "no worker url" };
|
|
888
969
|
checks.push({ name: "worker-health", ok: health.ok, detail: health.ok ? state.worker.url : health.error });
|
|
889
970
|
const diagnosticRuntime = new LocalDaemon({
|
|
@@ -917,6 +998,15 @@ async function doctorCommand(args) {
|
|
|
917
998
|
}, null, 2));
|
|
918
999
|
}
|
|
919
1000
|
|
|
1001
|
+
async function fullTestCommand(args) {
|
|
1002
|
+
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
1003
|
+
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
1004
|
+
const policy = resolvePolicy({}, state.policy);
|
|
1005
|
+
const result = await runFullAccessTest({ workspace, policy });
|
|
1006
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1007
|
+
if (!result.ok) process.exitCode = 1;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
920
1010
|
async function rotateSecretsCommand(args) {
|
|
921
1011
|
const workspace = await chooseWorkspace(args, { promptOnFirstRun: false, save: false, allowPositional: true });
|
|
922
1012
|
const state = loadState(workspace, { stateDir: args.stateDir });
|
|
@@ -1161,9 +1251,13 @@ function validateWorkerName(value) {
|
|
|
1161
1251
|
return name;
|
|
1162
1252
|
}
|
|
1163
1253
|
|
|
1254
|
+
export function isSupportedNodeVersion(version = process.versions.node) {
|
|
1255
|
+
const major = Number(String(version || "").split(".")[0]);
|
|
1256
|
+
return Number.isInteger(major) && major >= 26;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1164
1259
|
function assertNodeVersion() {
|
|
1165
|
-
|
|
1166
|
-
if (major < 22) throw new Error(`Node.js >=22 is required; current ${process.version}`);
|
|
1260
|
+
if (!isSupportedNodeVersion()) throw new Error(`Node.js >=26 is required; current ${process.version}`);
|
|
1167
1261
|
}
|
|
1168
1262
|
|
|
1169
1263
|
function usage() {
|
|
@@ -1188,7 +1282,10 @@ Commands:
|
|
|
1188
1282
|
service uninstall Remove only the autostart entry
|
|
1189
1283
|
status Print redacted local profile state and Worker health
|
|
1190
1284
|
doctor Check Node, Wrangler, Cloudflare login, Worker health
|
|
1285
|
+
full-test Run real local full-profile capability tests in a temporary sandbox
|
|
1191
1286
|
rotate-secrets Rotate MCP password and daemon secret in local state
|
|
1287
|
+
resource generate-ssh-key NAME [PATH]
|
|
1288
|
+
Generate/reuse an Ed25519 key locally and register its private file by alias
|
|
1192
1289
|
uninstall Delete known Worker(s), remove autostart and local state
|
|
1193
1290
|
|
|
1194
1291
|
Start options:
|
|
@@ -1214,6 +1311,7 @@ Start options:
|
|
|
1214
1311
|
--quiet Alias for --log-level error
|
|
1215
1312
|
--allow-insecure-permissions
|
|
1216
1313
|
Permit resource registration when a file is group/other-readable
|
|
1314
|
+
--show-paths Include local absolute paths in resource command output
|
|
1217
1315
|
|
|
1218
1316
|
Uninstall options:
|
|
1219
1317
|
--keep-worker Do not delete deployed Worker(s) during uninstall
|
package/src/local/daemon.mjs
CHANGED
|
@@ -9,9 +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
14
|
import { ManagedJobManager } from "./managed-jobs.mjs";
|
|
15
|
+
import { generateRegisteredSshKey } from "./resource-operations.mjs";
|
|
16
|
+
import { expandHome } from "./state.mjs";
|
|
15
17
|
|
|
16
18
|
export const MAX_WRITE_BYTES = 5 * 1024 * 1024;
|
|
17
19
|
const MAX_WS_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
@@ -33,6 +35,7 @@ export class LocalDaemon {
|
|
|
33
35
|
this.policy = normalizePolicy(policy);
|
|
34
36
|
this.logger = logger;
|
|
35
37
|
this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
|
|
38
|
+
this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
|
|
36
39
|
this.closed = false;
|
|
37
40
|
this.ws = null;
|
|
38
41
|
this.heartbeat = null;
|
|
@@ -83,15 +86,31 @@ export class LocalDaemon {
|
|
|
83
86
|
protocol_version: MCP_PROTOCOL_VERSION,
|
|
84
87
|
supported_protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
85
88
|
workspace: this.displayPath(this.workspace),
|
|
86
|
-
workspace_name: basename(this.workspace),
|
|
89
|
+
workspace_name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace",
|
|
87
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
|
+
},
|
|
88
96
|
enforcement: {
|
|
89
97
|
filesystem_scope: this.policy.unrestrictedPaths ? "local-user-accessible" : "workspace",
|
|
90
98
|
sensitive_filename_filter: false,
|
|
91
99
|
operating_system_permissions_apply: true,
|
|
92
100
|
host_policy_is_independent: true,
|
|
93
101
|
},
|
|
102
|
+
tool_delivery: {
|
|
103
|
+
full_profile_scope: "local-daemon-and-relay-advertisement",
|
|
104
|
+
daemon_advertised_tool_count: this.tools().length + 1,
|
|
105
|
+
host_exposed_tools_known_to_server: false,
|
|
106
|
+
host_may_expose_subset: true,
|
|
107
|
+
},
|
|
94
108
|
tools: ["server_info", ...this.tools()],
|
|
109
|
+
observability: {
|
|
110
|
+
per_tool_events: "debug-only",
|
|
111
|
+
default_logs_include_tool_failures: false,
|
|
112
|
+
tool_arguments_or_results_logged: false,
|
|
113
|
+
},
|
|
95
114
|
runtime: {
|
|
96
115
|
environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
|
|
97
116
|
runtime_dir: this.policy.exposeAbsolutePaths ? this.runtimeDir : "<private-runtime-dir>",
|
|
@@ -131,7 +150,7 @@ export class LocalDaemon {
|
|
|
131
150
|
if (this.closed) return;
|
|
132
151
|
const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
|
|
133
152
|
this.logger.debug?.("connecting to remote relay", { endpoint: redactUrl(wsUrl) });
|
|
134
|
-
const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret } });
|
|
153
|
+
const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret }, maxPayload: MAX_WS_MESSAGE_BYTES });
|
|
135
154
|
this.ws = socket;
|
|
136
155
|
|
|
137
156
|
socket.on("open", () => {
|
|
@@ -251,14 +270,12 @@ export class LocalDaemon {
|
|
|
251
270
|
if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
|
|
252
271
|
this.send({ type: "tool_result", id, ok: true, result });
|
|
253
272
|
const durationMs = Date.now() - started;
|
|
254
|
-
|
|
255
|
-
else this.logger.debug?.("tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
|
|
273
|
+
this.logger.debug?.(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
|
|
256
274
|
} catch (error) {
|
|
257
|
-
const safeError = this.safeErrorMessage(error);
|
|
275
|
+
const safeError = this.safeErrorMessage(error, argumentsValue);
|
|
258
276
|
this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
|
|
259
277
|
const durationMs = Date.now() - started;
|
|
260
|
-
this.logger.
|
|
261
|
-
this.logger.debug?.("tool call failure correlation", { call_id: shortCallId(id) });
|
|
278
|
+
this.logger.debug?.("tool call failed", { call_id: shortCallId(id), tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
262
279
|
} finally {
|
|
263
280
|
clearTimeout(deadline);
|
|
264
281
|
this.activeToolCalls -= 1;
|
|
@@ -306,6 +323,7 @@ export class LocalDaemon {
|
|
|
306
323
|
case "git_show": return this.gitShow(args, context);
|
|
307
324
|
case "diagnose_runtime": return this.diagnoseRuntime(context);
|
|
308
325
|
case "list_local_resources": return this.managedJobManager.listResources();
|
|
326
|
+
case "generate_ssh_key_resource": return this.generateSshKeyResource(args, context);
|
|
309
327
|
case "stage_job": return this.managedJobManager.stage(args);
|
|
310
328
|
case "start_job": return this.managedJobManager.start(args);
|
|
311
329
|
case "list_jobs": return this.managedJobManager.list(args);
|
|
@@ -327,7 +345,7 @@ export class LocalDaemon {
|
|
|
327
345
|
const git = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", this.workspace, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
|
|
328
346
|
return {
|
|
329
347
|
workspace: this.displayPath(this.workspace),
|
|
330
|
-
workspaceName: basename(this.workspace),
|
|
348
|
+
workspaceName: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace",
|
|
331
349
|
gitRoot: git.code === 0 ? this.displayPath(git.stdout.trim()) : "",
|
|
332
350
|
policy: this.policy,
|
|
333
351
|
tools: ["server_info", ...this.tools()],
|
|
@@ -336,7 +354,7 @@ export class LocalDaemon {
|
|
|
336
354
|
}
|
|
337
355
|
|
|
338
356
|
listRoots() {
|
|
339
|
-
const roots = [{ name: basename(this.workspace), path: this.displayPath(this.workspace), default: true }];
|
|
357
|
+
const roots = [{ name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace", path: this.displayPath(this.workspace), default: true }];
|
|
340
358
|
if (this.policy.unrestrictedPaths) {
|
|
341
359
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
342
360
|
if (home && home !== this.workspace) roots.push({ name: "home", path: this.displayPath(resolve(home)), default: false });
|
|
@@ -729,6 +747,41 @@ export class LocalDaemon {
|
|
|
729
747
|
};
|
|
730
748
|
}
|
|
731
749
|
|
|
750
|
+
async generateSshKeyResource(args = {}, context = {}) {
|
|
751
|
+
this.throwIfCancelled(context);
|
|
752
|
+
assertCanonicalFullPolicy(this.policy);
|
|
753
|
+
if (!this.resourceStatePath) throw new Error("local resource state is unavailable in this runtime");
|
|
754
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
755
|
+
if (!home) throw new Error("HOME or USERPROFILE is required to choose a default SSH key path");
|
|
756
|
+
const target = args.path
|
|
757
|
+
? resolve(expandHome(String(args.path)))
|
|
758
|
+
: resolve(home, ".ssh", `machine-mcp-${args.name}-ed25519`);
|
|
759
|
+
const key = await generateRegisteredSshKey({
|
|
760
|
+
workspace: this.workspace,
|
|
761
|
+
stateDir: stateRootFromProfileStatePath(this.resourceStatePath),
|
|
762
|
+
name: args.name,
|
|
763
|
+
targetPath: target,
|
|
764
|
+
comment: args.comment || `machine-mcp:${args.name}`,
|
|
765
|
+
});
|
|
766
|
+
const exposePaths = args.expose_paths === true;
|
|
767
|
+
return {
|
|
768
|
+
name: key.name,
|
|
769
|
+
created: key.created,
|
|
770
|
+
registered: key.registered,
|
|
771
|
+
fingerprint: key.fingerprint,
|
|
772
|
+
key_type: key.keyType,
|
|
773
|
+
private_mode: key.privateMode,
|
|
774
|
+
public_mode: key.publicMode,
|
|
775
|
+
private_key_content_exposed: key.privateKeyContentExposed,
|
|
776
|
+
available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
|
|
777
|
+
paths_exposed: exposePaths,
|
|
778
|
+
...(exposePaths ? {
|
|
779
|
+
private_key_path: resolve(key.privateKeyPath),
|
|
780
|
+
public_key_path: resolve(key.publicKeyPath),
|
|
781
|
+
} : {}),
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
732
785
|
async runDirectProcess(args, context = {}) {
|
|
733
786
|
if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error("run_process is disabled by daemon policy");
|
|
734
787
|
const argv = validateArgv(args.argv);
|
|
@@ -788,7 +841,7 @@ export class LocalDaemon {
|
|
|
788
841
|
timer.unref?.();
|
|
789
842
|
const cleanup = () => {
|
|
790
843
|
clearTimeout(timer);
|
|
791
|
-
if (killTimer
|
|
844
|
+
if (killTimer) clearTimeout(killTimer);
|
|
792
845
|
this.activeProcesses.delete(child);
|
|
793
846
|
if (context.callId) {
|
|
794
847
|
const set = this.callProcesses.get(context.callId);
|
|
@@ -901,19 +954,25 @@ export class LocalDaemon {
|
|
|
901
954
|
|
|
902
955
|
displayPath(fullPath) {
|
|
903
956
|
const absolute = resolve(fullPath);
|
|
904
|
-
if (this.policy.exposeAbsolutePaths
|
|
905
|
-
assertContainedPath(this.workspace, absolute);
|
|
957
|
+
if (this.policy.exposeAbsolutePaths) return absolute;
|
|
906
958
|
const shown = relative(this.workspace, absolute);
|
|
907
|
-
|
|
959
|
+
const insideWorkspace = shown === "" || (!shown.startsWith(`..${sep}`) && shown !== ".." && !isAbsolute(shown));
|
|
960
|
+
if (insideWorkspace) return shown ? shown.split(sep).join("/") : ".";
|
|
961
|
+
return `<external-path:${sha256(absolute).slice(0, 12)}>`;
|
|
908
962
|
}
|
|
909
963
|
|
|
910
|
-
safeErrorMessage(error) {
|
|
964
|
+
safeErrorMessage(error, toolArgs = {}) {
|
|
911
965
|
let message = boundedErrorMessage(error);
|
|
912
966
|
if (!this.policy.exposeAbsolutePaths) {
|
|
913
967
|
for (const prefix of equivalentPathPrefixes(this.workspace, this.workspaceInput)) message = replacePathPrefix(message, prefix, ".");
|
|
914
968
|
for (const prefix of equivalentPathPrefixes(this.runtimeDir)) message = replacePathPrefix(message, prefix, "<runtime>");
|
|
915
969
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
916
970
|
if (home) message = replacePathPrefix(message, resolve(home), "<home>");
|
|
971
|
+
for (const candidate of collectToolPathCandidates(error, toolArgs, this.workspaceInput)) {
|
|
972
|
+
const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(this.workspaceInput, candidate);
|
|
973
|
+
const replacement = this.displayPath(absolute);
|
|
974
|
+
for (const prefix of equivalentPathPrefixes(candidate, absolute)) message = replacePathPrefix(message, prefix, replacement);
|
|
975
|
+
}
|
|
917
976
|
}
|
|
918
977
|
return message;
|
|
919
978
|
}
|
|
@@ -931,6 +990,25 @@ export class LocalDaemon {
|
|
|
931
990
|
}
|
|
932
991
|
}
|
|
933
992
|
|
|
993
|
+
function policyMatchesNamedProfile(policy) {
|
|
994
|
+
const named = POLICY_PROFILES[policy.profile];
|
|
995
|
+
if (!named) return false;
|
|
996
|
+
return policy.allowWrite === named.allowWrite
|
|
997
|
+
&& policy.execMode === named.execMode
|
|
998
|
+
&& policy.unrestrictedPaths === named.unrestrictedPaths
|
|
999
|
+
&& policy.minimalEnv === named.minimalEnv
|
|
1000
|
+
&& policy.exposeAbsolutePaths === named.exposeAbsolutePaths;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function stateRootFromProfileStatePath(statePath) {
|
|
1004
|
+
const absolute = resolve(statePath);
|
|
1005
|
+
if (basename(absolute) !== "state.json") throw new Error("local resource state path is invalid");
|
|
1006
|
+
const profileDir = dirname(absolute);
|
|
1007
|
+
const profilesDir = dirname(profileDir);
|
|
1008
|
+
if (basename(profilesDir) !== "profiles") throw new Error("local resource state path is outside the expected profile layout");
|
|
1009
|
+
return dirname(profilesDir);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
934
1012
|
function normalizeWorkerUrl(value) {
|
|
935
1013
|
let url;
|
|
936
1014
|
try { url = new URL(String(value || "")); } catch { throw new Error("invalid Worker URL"); }
|
|
@@ -1140,6 +1218,27 @@ function isPlainRecord(value) {
|
|
|
1140
1218
|
}
|
|
1141
1219
|
|
|
1142
1220
|
|
|
1221
|
+
function collectToolPathCandidates(error, toolArgs, workspace) {
|
|
1222
|
+
const candidates = new Set();
|
|
1223
|
+
for (const value of [error?.path, error?.dest]) if (typeof value === "string" && value) candidates.add(value);
|
|
1224
|
+
const visit = (value, key = "", depth = 0) => {
|
|
1225
|
+
if (depth > 5 || value === null || value === undefined) return;
|
|
1226
|
+
if (typeof value === "string") {
|
|
1227
|
+
if (/(?:^|[_-])(?:path|cwd|workspace|root|directory|dir)(?:$|[_-])/i.test(key) && value && !value.includes("\0")) candidates.add(value);
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
if (Array.isArray(value)) {
|
|
1231
|
+
for (const item of value.slice(0, 64)) visit(item, key, depth + 1);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (typeof value !== "object") return;
|
|
1235
|
+
for (const [childKey, child] of Object.entries(value).slice(0, 128)) visit(child, childKey, depth + 1);
|
|
1236
|
+
};
|
|
1237
|
+
visit(toolArgs);
|
|
1238
|
+
candidates.delete(workspace);
|
|
1239
|
+
return [...candidates].sort((left, right) => right.length - left.length);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1143
1242
|
function equivalentPathPrefixes(...values) {
|
|
1144
1243
|
const prefixes = new Set(values.filter(Boolean).map((value) => String(value)));
|
|
1145
1244
|
for (const value of [...prefixes]) {
|