machine-bridge-mcp 0.6.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 +41 -0
- package/README.md +21 -4
- package/SECURITY.md +10 -4
- package/docs/ARCHITECTURE.md +10 -2
- package/docs/CLIENTS.md +11 -1
- package/docs/LOGGING.md +9 -18
- package/docs/MANAGED_JOBS.md +8 -0
- package/docs/OPERATIONS.md +24 -6
- package/docs/TESTING.md +5 -1
- package/package.json +12 -6
- package/src/local/atomic-fs.mjs +37 -0
- package/src/local/cli.mjs +63 -3
- package/src/local/daemon.mjs +67 -5
- package/src/local/full-access-test.mjs +206 -0
- package/src/local/job-runner.mjs +3 -2
- package/src/local/managed-jobs.mjs +3 -2
- package/src/local/resource-operations.mjs +66 -0
- package/src/local/ssh-key.mjs +125 -0
- package/src/local/state.mjs +5 -4
- package/src/local/stdio.mjs +2 -4
- package/src/local/tools.mjs +37 -3
- package/src/shared/server-metadata.json +3 -1
- package/src/shared/tool-catalog.json +33 -0
- package/src/worker/index.ts +7 -1
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;
|
|
@@ -85,6 +88,11 @@ export class LocalDaemon {
|
|
|
85
88
|
workspace: this.displayPath(this.workspace),
|
|
86
89
|
workspace_name: basename(this.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,
|
|
@@ -92,6 +100,11 @@ export class LocalDaemon {
|
|
|
92
100
|
host_policy_is_independent: true,
|
|
93
101
|
},
|
|
94
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
|
+
},
|
|
95
108
|
runtime: {
|
|
96
109
|
environment: this.policy.minimalEnv ? "isolated-minimal" : "full-parent",
|
|
97
110
|
runtime_dir: this.policy.exposeAbsolutePaths ? this.runtimeDir : "<private-runtime-dir>",
|
|
@@ -251,14 +264,12 @@ export class LocalDaemon {
|
|
|
251
264
|
if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
|
|
252
265
|
this.send({ type: "tool_result", id, ok: true, result });
|
|
253
266
|
const durationMs = Date.now() - started;
|
|
254
|
-
|
|
255
|
-
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 });
|
|
256
268
|
} catch (error) {
|
|
257
269
|
const safeError = this.safeErrorMessage(error);
|
|
258
270
|
this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
|
|
259
271
|
const durationMs = Date.now() - started;
|
|
260
|
-
this.logger.
|
|
261
|
-
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) });
|
|
262
273
|
} finally {
|
|
263
274
|
clearTimeout(deadline);
|
|
264
275
|
this.activeToolCalls -= 1;
|
|
@@ -306,6 +317,7 @@ export class LocalDaemon {
|
|
|
306
317
|
case "git_show": return this.gitShow(args, context);
|
|
307
318
|
case "diagnose_runtime": return this.diagnoseRuntime(context);
|
|
308
319
|
case "list_local_resources": return this.managedJobManager.listResources();
|
|
320
|
+
case "generate_ssh_key_resource": return this.generateSshKeyResource(args, context);
|
|
309
321
|
case "stage_job": return this.managedJobManager.stage(args);
|
|
310
322
|
case "start_job": return this.managedJobManager.start(args);
|
|
311
323
|
case "list_jobs": return this.managedJobManager.list(args);
|
|
@@ -729,6 +741,37 @@ export class LocalDaemon {
|
|
|
729
741
|
};
|
|
730
742
|
}
|
|
731
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
|
+
|
|
732
775
|
async runDirectProcess(args, context = {}) {
|
|
733
776
|
if (this.policy.execMode !== "direct" && this.policy.execMode !== "shell") throw new Error("run_process is disabled by daemon policy");
|
|
734
777
|
const argv = validateArgv(args.argv);
|
|
@@ -931,6 +974,25 @@ export class LocalDaemon {
|
|
|
931
974
|
}
|
|
932
975
|
}
|
|
933
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
|
+
|
|
934
996
|
function normalizeWorkerUrl(value) {
|
|
935
997
|
let url;
|
|
936
998
|
try { url = new URL(String(value || "")); } catch { throw new Error("invalid Worker URL"); }
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { chmod, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { LocalDaemon } from "./daemon.mjs";
|
|
6
|
+
import { generateSshKeyPair } from "./ssh-key.mjs";
|
|
7
|
+
import { run } from "./shell.mjs";
|
|
8
|
+
import { allToolNames, assertCanonicalFullPolicy, policyProfile } from "./tools.mjs";
|
|
9
|
+
|
|
10
|
+
const TERMINAL_JOB_STATES = new Set([
|
|
11
|
+
"succeeded", "failed", "cancelled", "runner_failed", "runner_launch_failed",
|
|
12
|
+
"recovery_failed", "recovery_exhausted", "succeeded_cleanup_failed",
|
|
13
|
+
"failed_cleanup_failed", "cancelled_cleanup_failed",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
export async function runFullAccessTest({ workspace, policy = policyProfile("full", "explicit") } = {}) {
|
|
17
|
+
const canonicalPolicy = assertCanonicalFullPolicy(policy);
|
|
18
|
+
const root = await mkdtemp(join(tmpdir(), "machine-mcp-full-test-"));
|
|
19
|
+
const jobRoot = join(root, "jobs");
|
|
20
|
+
const outsideDir = join(root, "outside-workspace");
|
|
21
|
+
const keyPath = join(root, "ssh", "operator-ed25519");
|
|
22
|
+
const authorizedKeysPath = join(root, "authorized_keys");
|
|
23
|
+
const mainMarker = join(root, "managed-main.txt");
|
|
24
|
+
const cleanupMarker = join(root, "managed-cleanup.txt");
|
|
25
|
+
const checks = [];
|
|
26
|
+
const sentinelKey = `MBM_FULL_TEST_${Date.now()}`;
|
|
27
|
+
const previousSentinel = process.env[sentinelKey];
|
|
28
|
+
process.env[sentinelKey] = "visible";
|
|
29
|
+
let runtime;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
runtime = new LocalDaemon({
|
|
33
|
+
workspace: resolve(workspace),
|
|
34
|
+
policy: canonicalPolicy,
|
|
35
|
+
jobRoot,
|
|
36
|
+
resources: {},
|
|
37
|
+
logger: silentLogger(),
|
|
38
|
+
recoverJobs: false,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const toolNames = runtime.tools();
|
|
42
|
+
checks.push(check("full-policy-invariant", toolNames.length === allToolNames().length - 1, {
|
|
43
|
+
profile: canonicalPolicy.profile,
|
|
44
|
+
exposed_tools: toolNames.length + 1,
|
|
45
|
+
catalog_tools: allToolNames().length,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
const outsideFile = join(outsideDir, "outside.txt");
|
|
49
|
+
const written = await runtime.writeFile({ path: outsideFile, content: "full-outside-write\n", create_only: true });
|
|
50
|
+
const read = await runtime.readFile(outsideFile, 1024);
|
|
51
|
+
const canonicalOutsideFile = await realpath(outsideFile);
|
|
52
|
+
checks.push(check("unrestricted-filesystem", written.ok === true && read.content === "full-outside-write\n", {
|
|
53
|
+
absolute_path_returned: await equivalentPath(read.path, canonicalOutsideFile),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const direct = await runtime.runDirectProcess({
|
|
57
|
+
argv: [process.execPath, "-e", "process.stdout.write(process.cwd())"],
|
|
58
|
+
cwd: outsideDir,
|
|
59
|
+
timeout_seconds: 10,
|
|
60
|
+
});
|
|
61
|
+
checks.push(check("direct-process-outside-workspace", direct.code === 0 && await equivalentPath(direct.stdout.trim(), outsideDir)));
|
|
62
|
+
|
|
63
|
+
const inherited = await runtime.runDirectProcess({
|
|
64
|
+
argv: [process.execPath, "-e", `process.stdout.write(process.env[${JSON.stringify(sentinelKey)}] || '')`],
|
|
65
|
+
timeout_seconds: 10,
|
|
66
|
+
});
|
|
67
|
+
checks.push(check("full-parent-environment", inherited.code === 0 && inherited.stdout === "visible"));
|
|
68
|
+
|
|
69
|
+
const shellCommand = process.platform === "win32" ? "[Console]::Out.Write('full-shell')" : "printf full-shell";
|
|
70
|
+
const shell = await runtime.execCommand(shellCommand, 10);
|
|
71
|
+
checks.push(check("shell-execution", shell.code === 0 && shell.stdout.trim() === "full-shell"));
|
|
72
|
+
|
|
73
|
+
const key = await generateSshKeyPair({
|
|
74
|
+
privateKeyPath: keyPath,
|
|
75
|
+
type: "ed25519",
|
|
76
|
+
comment: "machine-mcp-full-test",
|
|
77
|
+
});
|
|
78
|
+
const publicLine = (await readFile(key.publicKeyPath, "utf8")).trim();
|
|
79
|
+
await runtime.writeFile({ path: authorizedKeysPath, content: `${publicLine}\n`, create_only: true });
|
|
80
|
+
if (process.platform !== "win32") await chmod(authorizedKeysPath, 0o600);
|
|
81
|
+
const authorized = await readFile(authorizedKeysPath, "utf8");
|
|
82
|
+
checks.push(check("ssh-key-generation", key.created && key.publicKeyType === "ssh-ed25519", {
|
|
83
|
+
private_mode: key.privateMode,
|
|
84
|
+
public_mode: key.publicMode,
|
|
85
|
+
fingerprint_available: Boolean(key.fingerprint),
|
|
86
|
+
private_key_content_exposed: false,
|
|
87
|
+
}));
|
|
88
|
+
checks.push(check("authorized-keys-sandbox-write", authorized === `${publicLine}\n`, {
|
|
89
|
+
target_is_temporary_sandbox: true,
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
const nullConfig = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
93
|
+
const sshConfig = await run("ssh", ["-F", nullConfig, "-G", "localhost"], {
|
|
94
|
+
capture: true,
|
|
95
|
+
allowFailure: true,
|
|
96
|
+
timeoutMs: 15_000,
|
|
97
|
+
maxOutputBytes: 256 * 1024,
|
|
98
|
+
});
|
|
99
|
+
checks.push(check("ssh-client", sshConfig.code === 0));
|
|
100
|
+
|
|
101
|
+
const gcloud = await run("gcloud", ["--version"], {
|
|
102
|
+
capture: true,
|
|
103
|
+
allowFailure: true,
|
|
104
|
+
timeoutMs: 30_000,
|
|
105
|
+
maxOutputBytes: 64 * 1024,
|
|
106
|
+
});
|
|
107
|
+
const osLoginHelp = gcloud.code === 0
|
|
108
|
+
? await run("gcloud", ["help", "compute", "os-login", "ssh-keys", "add"], {
|
|
109
|
+
capture: true,
|
|
110
|
+
allowFailure: true,
|
|
111
|
+
timeoutMs: 30_000,
|
|
112
|
+
maxOutputBytes: 128 * 1024,
|
|
113
|
+
})
|
|
114
|
+
: { code: 127 };
|
|
115
|
+
checks.push(check("google-cloud-cli", gcloud.code === 0, {
|
|
116
|
+
os_login_key_command_available: osLoginHelp.code === 0,
|
|
117
|
+
external_changes_made: false,
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const sudo = process.platform === "win32"
|
|
121
|
+
? { code: 0, skipped: true }
|
|
122
|
+
: await run("sudo", ["-n", "true"], {
|
|
123
|
+
capture: true,
|
|
124
|
+
allowFailure: true,
|
|
125
|
+
timeoutMs: 10_000,
|
|
126
|
+
maxOutputBytes: 16 * 1024,
|
|
127
|
+
});
|
|
128
|
+
checks.push({
|
|
129
|
+
name: "noninteractive-sudo-probe",
|
|
130
|
+
ok: true,
|
|
131
|
+
available: process.platform === "win32" ? null : sudo.code === 0,
|
|
132
|
+
password_or_policy_required: process.platform === "win32" ? null : sudo.code !== 0,
|
|
133
|
+
skipped: process.platform === "win32",
|
|
134
|
+
state_changed: false,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const accepted = runtime.managedJobManager.start({
|
|
138
|
+
name: "full access lifecycle test",
|
|
139
|
+
temporary_files: [{ name: "main.js", content: "require('node:fs').writeFileSync(process.argv[2],'main')" }],
|
|
140
|
+
steps: [{ argv: [process.execPath, "{{temp:main.js}}", mainMarker], timeout_seconds: 10 }],
|
|
141
|
+
finally_steps: [{ argv: [process.execPath, "-e", "require('node:fs').writeFileSync(process.argv[1],'cleanup')", cleanupMarker], timeout_seconds: 10 }],
|
|
142
|
+
});
|
|
143
|
+
const job = await waitForJob(runtime.managedJobManager, accepted.job_id, 15_000);
|
|
144
|
+
checks.push(check("detached-managed-job", job.status === "succeeded"
|
|
145
|
+
&& await fileEquals(mainMarker, "main")
|
|
146
|
+
&& await fileEquals(cleanupMarker, "cleanup"), {
|
|
147
|
+
status: job.status,
|
|
148
|
+
error_class: job.error_class ?? job.result?.error_class ?? null,
|
|
149
|
+
cleanup_error_class: job.result?.cleanup_error_class ?? null,
|
|
150
|
+
cleanup_attempted: true,
|
|
151
|
+
}));
|
|
152
|
+
|
|
153
|
+
const coreChecks = checks.filter((item) => !["google-cloud-cli", "noninteractive-sudo-probe"].includes(item.name));
|
|
154
|
+
const operatorChecks = checks.filter((item) => ["ssh-key-generation", "authorized-keys-sandbox-write", "ssh-client", "google-cloud-cli"].includes(item.name));
|
|
155
|
+
return {
|
|
156
|
+
ok: coreChecks.every((item) => item.ok === true),
|
|
157
|
+
operator_workflow_ready: operatorChecks.every((item) => item.ok === true),
|
|
158
|
+
machine: {
|
|
159
|
+
platform: process.platform,
|
|
160
|
+
architecture: process.arch,
|
|
161
|
+
node: process.version,
|
|
162
|
+
},
|
|
163
|
+
policy: canonicalPolicy,
|
|
164
|
+
checks,
|
|
165
|
+
guarantees: {
|
|
166
|
+
machine_bridge_internal_policy_denials_under_full: false,
|
|
167
|
+
host_or_connector_policy_overridden: false,
|
|
168
|
+
operating_system_policy_overridden: false,
|
|
169
|
+
external_cloud_or_remote_state_changed: false,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
} finally {
|
|
173
|
+
runtime?.stop();
|
|
174
|
+
if (previousSentinel === undefined) delete process.env[sentinelKey];
|
|
175
|
+
else process.env[sentinelKey] = previousSentinel;
|
|
176
|
+
await rm(root, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function waitForJob(manager, jobId, timeoutMs) {
|
|
181
|
+
const deadline = Date.now() + timeoutMs;
|
|
182
|
+
while (Date.now() < deadline) {
|
|
183
|
+
const value = manager.read({ job_id: jobId });
|
|
184
|
+
if (TERMINAL_JOB_STATES.has(value.status)) return value;
|
|
185
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 50));
|
|
186
|
+
}
|
|
187
|
+
throw new Error("full access managed-job test timed out");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function equivalentPath(left, right) {
|
|
191
|
+
try { return await realpath(resolve(left)) === await realpath(resolve(right)); } catch { return false; }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function fileEquals(path, expected) {
|
|
195
|
+
try { return await readFile(path, "utf8") === expected; } catch { return false; }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function check(name, ok, detail = {}) {
|
|
199
|
+
return { name, ok: Boolean(ok), ...detail };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function silentLogger() {
|
|
203
|
+
return {
|
|
204
|
+
info() {}, success() {}, warn() {}, error() {}, debug() {},
|
|
205
|
+
};
|
|
206
|
+
}
|
package/src/local/job-runner.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readSync,
|
|
3
|
+
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { executionEnv } from "./shell.mjs";
|
|
6
6
|
import { terminateProcessTree } from "./process-sessions.mjs";
|
|
7
|
+
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
7
8
|
|
|
8
9
|
const RESOURCE_TOKEN = /\{\{resource:([a-z][a-z0-9._-]{0,63})\}\}/g;
|
|
9
10
|
const TEMP_TOKEN = /\{\{temp:([a-z][a-z0-9._-]{0,63})\}\}/g;
|
|
@@ -419,7 +420,7 @@ function writeJson(file, value, maxBytes) {
|
|
|
419
420
|
if (Buffer.byteLength(text) > maxBytes) throw new Error(`job JSON exceeds ${maxBytes} bytes`);
|
|
420
421
|
const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
421
422
|
writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
|
|
422
|
-
|
|
423
|
+
replaceFileSync(temp, file);
|
|
423
424
|
chmodSync(file, 0o600);
|
|
424
425
|
}
|
|
425
426
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
-
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync,
|
|
3
|
+
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { ensureOwnerOnlyDir, ownerOnlyFile } from "./state.mjs";
|
|
7
|
+
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
7
8
|
|
|
8
9
|
const RESOURCE_NAME = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
9
10
|
const JOB_ID = /^job_[A-Za-z0-9_-]{24,}$/;
|
|
@@ -747,7 +748,7 @@ function atomicWriteJson(file, value, maxBytes) {
|
|
|
747
748
|
if (Buffer.byteLength(text) > maxBytes) throw new Error(`JSON exceeds ${maxBytes} bytes`);
|
|
748
749
|
const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
749
750
|
writeFileSync(temp, text, { mode: 0o600, flag: "wx" });
|
|
750
|
-
|
|
751
|
+
replaceFileSync(temp, file);
|
|
751
752
|
ownerOnlyFile(file);
|
|
752
753
|
}
|
|
753
754
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { inspectResourceFile, validateResourceName } from "./managed-jobs.mjs";
|
|
5
|
+
import { generateSshKeyPair } from "./ssh-key.mjs";
|
|
6
|
+
import { acquireStartupLock, loadState, saveState } from "./state.mjs";
|
|
7
|
+
|
|
8
|
+
export async function generateRegisteredSshKey({ workspace, stateDir, name: rawName, targetPath, comment = "" }) {
|
|
9
|
+
const name = validateResourceName(rawName);
|
|
10
|
+
if (typeof targetPath !== "string" || !targetPath.trim()) throw new Error("SSH private key target path is required");
|
|
11
|
+
const target = resolve(targetPath);
|
|
12
|
+
const state = loadState(workspace, { stateDir });
|
|
13
|
+
const lock = acquireStartupLock(state);
|
|
14
|
+
if (!lock.acquired) throw new Error("another state-changing operation is already running for this workspace");
|
|
15
|
+
let key = null;
|
|
16
|
+
try {
|
|
17
|
+
state.resources ||= {};
|
|
18
|
+
const existing = state.resources[name];
|
|
19
|
+
if (existing?.path && !samePathIdentity(existing.path, target)) {
|
|
20
|
+
throw new Error(`local resource ${name} is already registered to a different file; remove it first`);
|
|
21
|
+
}
|
|
22
|
+
if (!existing && Object.keys(state.resources).length >= 64) throw new Error("local resource registry limit reached (64)");
|
|
23
|
+
key = await generateSshKeyPair({
|
|
24
|
+
privateKeyPath: target,
|
|
25
|
+
type: "ed25519",
|
|
26
|
+
comment: comment || `machine-mcp:${name}`,
|
|
27
|
+
});
|
|
28
|
+
const inspected = inspectResourceFile(key.privateKeyPath);
|
|
29
|
+
state.resources[name] = inspected;
|
|
30
|
+
try {
|
|
31
|
+
saveState(state);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (key.created) {
|
|
34
|
+
await rm(key.privateKeyPath, { force: true }).catch(() => {});
|
|
35
|
+
await rm(key.publicKeyPath, { force: true }).catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
created: key.created,
|
|
42
|
+
registered: true,
|
|
43
|
+
privateKeyPath: key.privateKeyPath,
|
|
44
|
+
publicKeyPath: key.publicKeyPath,
|
|
45
|
+
fingerprint: key.fingerprint,
|
|
46
|
+
keyType: key.publicKeyType,
|
|
47
|
+
privateMode: key.privateMode,
|
|
48
|
+
publicMode: key.publicMode,
|
|
49
|
+
privateKeyContentExposed: false,
|
|
50
|
+
availableToNewJobsImmediately: true,
|
|
51
|
+
};
|
|
52
|
+
} finally {
|
|
53
|
+
lock.release();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function samePathIdentity(left, right) {
|
|
58
|
+
const a = canonicalIfExisting(left);
|
|
59
|
+
const b = canonicalIfExisting(right);
|
|
60
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function canonicalIfExisting(value) {
|
|
64
|
+
const absolute = resolve(String(value));
|
|
65
|
+
try { return realpathSync.native ? realpathSync.native(absolute) : realpathSync(absolute); } catch { return absolute; }
|
|
66
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { constants as fsConstants } from "node:fs";
|
|
2
|
+
import { chmod, copyFile, link, lstat, mkdir, readFile, rm, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, resolve } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { run } from "./shell.mjs";
|
|
6
|
+
|
|
7
|
+
const KEY_TYPES = new Set(["ed25519", "rsa"]);
|
|
8
|
+
|
|
9
|
+
export async function generateSshKeyPair(options = {}) {
|
|
10
|
+
const privateKeyPath = resolve(String(options.privateKeyPath || ""));
|
|
11
|
+
if (!privateKeyPath || privateKeyPath === resolve(".")) throw new Error("private key path is required");
|
|
12
|
+
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
13
|
+
const type = String(options.type || "ed25519").toLowerCase();
|
|
14
|
+
if (!KEY_TYPES.has(type)) throw new Error("SSH key type must be ed25519 or rsa");
|
|
15
|
+
const comment = boundedComment(options.comment || `machine-mcp:${basename(privateKeyPath)}`);
|
|
16
|
+
const parent = dirname(privateKeyPath);
|
|
17
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
18
|
+
if (process.platform !== "win32") await chmod(parent, 0o700);
|
|
19
|
+
|
|
20
|
+
const privateInfo = await safeLstat(privateKeyPath);
|
|
21
|
+
const publicInfo = await safeLstat(publicKeyPath);
|
|
22
|
+
if (privateInfo?.isSymbolicLink() || publicInfo?.isSymbolicLink()) throw new Error("SSH key path must not be a symbolic link");
|
|
23
|
+
if (privateInfo || publicInfo) {
|
|
24
|
+
if (!privateInfo?.isFile() || !publicInfo?.isFile()) throw new Error("SSH key pair is incomplete or not a pair of regular files");
|
|
25
|
+
await secureKeyModes(privateKeyPath, publicKeyPath);
|
|
26
|
+
return inspectSshKeyPair(privateKeyPath, publicKeyPath, false);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
30
|
+
const tempPrivate = resolve(parent, `.${basename(privateKeyPath)}.mbm-${suffix}`);
|
|
31
|
+
const tempPublic = `${tempPrivate}.pub`;
|
|
32
|
+
try {
|
|
33
|
+
const args = ["-q", "-t", type];
|
|
34
|
+
if (type === "rsa") args.push("-b", String(normalizeRsaBits(options.bits)));
|
|
35
|
+
args.push("-N", "", "-f", tempPrivate, "-C", comment);
|
|
36
|
+
const generated = await run("ssh-keygen", args, { capture: true, timeoutMs: 30_000, maxOutputBytes: 64 * 1024 });
|
|
37
|
+
if (generated.code !== 0) throw new Error("ssh-keygen failed");
|
|
38
|
+
await secureKeyModes(tempPrivate, tempPublic);
|
|
39
|
+
await installNoReplace(tempPrivate, privateKeyPath);
|
|
40
|
+
try {
|
|
41
|
+
await installNoReplace(tempPublic, publicKeyPath);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
await rm(privateKeyPath, { force: true });
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
await secureKeyModes(privateKeyPath, publicKeyPath);
|
|
47
|
+
return inspectSshKeyPair(privateKeyPath, publicKeyPath, true);
|
|
48
|
+
} finally {
|
|
49
|
+
await rm(tempPrivate, { force: true });
|
|
50
|
+
await rm(tempPublic, { force: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function inspectSshKeyPair(privateKeyPath, publicKeyPath = `${privateKeyPath}.pub`, created = false) {
|
|
55
|
+
const privateInfo = await stat(privateKeyPath);
|
|
56
|
+
const publicInfo = await stat(publicKeyPath);
|
|
57
|
+
if (!privateInfo.isFile() || !publicInfo.isFile()) throw new Error("SSH key pair is not composed of regular files");
|
|
58
|
+
const derived = await run("ssh-keygen", ["-y", "-P", "", "-f", privateKeyPath], {
|
|
59
|
+
capture: true,
|
|
60
|
+
allowFailure: true,
|
|
61
|
+
timeoutMs: 15_000,
|
|
62
|
+
maxOutputBytes: 64 * 1024,
|
|
63
|
+
});
|
|
64
|
+
if (derived.code !== 0) throw new Error("SSH private key cannot be used non-interactively or is invalid");
|
|
65
|
+
const publicLine = (await readFile(publicKeyPath, "utf8")).trim();
|
|
66
|
+
if (!/^(ssh-ed25519|ssh-rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(publicLine)) throw new Error("generated SSH public key is invalid");
|
|
67
|
+
const expectedFields = publicLine.split(/\s+/).slice(0, 2).join(" ");
|
|
68
|
+
const derivedFields = derived.stdout.trim().split(/\s+/).slice(0, 2).join(" ");
|
|
69
|
+
if (expectedFields !== derivedFields) throw new Error("SSH public key does not match the private key");
|
|
70
|
+
const fingerprint = await run("ssh-keygen", ["-lf", publicKeyPath, "-E", "sha256"], {
|
|
71
|
+
capture: true,
|
|
72
|
+
timeoutMs: 15_000,
|
|
73
|
+
maxOutputBytes: 64 * 1024,
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
created,
|
|
77
|
+
privateKeyPath: resolve(privateKeyPath),
|
|
78
|
+
publicKeyPath: resolve(publicKeyPath),
|
|
79
|
+
privateMode: process.platform === "win32" ? null : `0${(privateInfo.mode & 0o777).toString(8)}`,
|
|
80
|
+
publicMode: process.platform === "win32" ? null : `0${(publicInfo.mode & 0o777).toString(8)}`,
|
|
81
|
+
fingerprint: fingerprint.stdout.trim(),
|
|
82
|
+
publicKeyType: publicLine.split(/\s+/, 1)[0],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function installNoReplace(source, target) {
|
|
87
|
+
try {
|
|
88
|
+
await link(source, target);
|
|
89
|
+
await unlink(source);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error?.code === "EXDEV") {
|
|
92
|
+
await copyFile(source, target, fsConstants.COPYFILE_EXCL);
|
|
93
|
+
await unlink(source);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (error?.code === "EEXIST") throw new Error(`refusing to replace existing SSH key file: ${target}`);
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function secureKeyModes(privateKeyPath, publicKeyPath) {
|
|
102
|
+
if (process.platform === "win32") return;
|
|
103
|
+
await chmod(privateKeyPath, 0o600);
|
|
104
|
+
await chmod(publicKeyPath, 0o644);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function safeLstat(path) {
|
|
108
|
+
try { return await lstat(path); } catch (error) {
|
|
109
|
+
if (error?.code === "ENOENT") return null;
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function boundedComment(value) {
|
|
115
|
+
const comment = String(value || "").replace(/[\r\n\0]/g, " ").trim();
|
|
116
|
+
if (!comment) return "machine-mcp";
|
|
117
|
+
if (Buffer.byteLength(comment) > 256) throw new Error("SSH key comment exceeds 256 bytes");
|
|
118
|
+
return comment;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizeRsaBits(value) {
|
|
122
|
+
const bits = Number.parseInt(String(value || "3072"), 10);
|
|
123
|
+
if (![2048, 3072, 4096].includes(bits)) throw new Error("RSA bits must be 2048, 3072, or 4096");
|
|
124
|
+
return bits;
|
|
125
|
+
}
|
package/src/local/state.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync,
|
|
2
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readSync, writeFileSync, chmodSync, realpathSync, rmSync, unlinkSync, readdirSync, statSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
|
|
7
|
+
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
7
8
|
|
|
8
9
|
export const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
9
10
|
export const appName = String(serverMetadata.name);
|
|
@@ -274,9 +275,9 @@ function readJsonObjectOrBackup(filePath) {
|
|
|
274
275
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("JSON root must be an object");
|
|
275
276
|
return parsed;
|
|
276
277
|
} catch {
|
|
277
|
-
const backupPath = `${filePath}.corrupt-${Date.now()}`;
|
|
278
|
+
const backupPath = `${filePath}.corrupt-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
278
279
|
try {
|
|
279
|
-
|
|
280
|
+
replaceFileSync(filePath, backupPath);
|
|
280
281
|
ownerOnlyFile(backupPath);
|
|
281
282
|
pruneBackups(filePath, 3);
|
|
282
283
|
} catch {}
|
|
@@ -425,7 +426,7 @@ function atomicWriteJson(filePath, value) {
|
|
|
425
426
|
fsyncSync(fd);
|
|
426
427
|
closeSync(fd);
|
|
427
428
|
fd = undefined;
|
|
428
|
-
|
|
429
|
+
replaceFileSync(tempPath, filePath);
|
|
429
430
|
ownerOnlyFile(filePath);
|
|
430
431
|
} catch (error) {
|
|
431
432
|
if (fd !== undefined) {
|
package/src/local/stdio.mjs
CHANGED
|
@@ -131,14 +131,12 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
131
131
|
const result = await runtime.executeTool(name, args, { callId });
|
|
132
132
|
send(rpcResult(message.id, toolResult(result)));
|
|
133
133
|
const durationMs = Date.now() - started;
|
|
134
|
-
|
|
135
|
-
else logger.debug("tool call completed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs });
|
|
134
|
+
logger.debug(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs });
|
|
136
135
|
} catch (error) {
|
|
137
136
|
const safeError = runtime.safeErrorMessage(error);
|
|
138
137
|
send(rpcResult(message.id, toolResult({ error: safeError }, true)));
|
|
139
138
|
const durationMs = Date.now() - started;
|
|
140
|
-
logger.
|
|
141
|
-
logger.debug("tool call failure correlation", { call_id: callId.slice(0, 20) });
|
|
139
|
+
logger.debug("tool call failed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
142
140
|
} finally {
|
|
143
141
|
if (key) pending.delete(key);
|
|
144
142
|
runtime.finishCall(callId);
|