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.
@@ -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
- if (durationMs >= SLOW_TOOL_CALL_MS) this.logger.info?.("slow tool call completed", { tool, duration_ms: durationMs });
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.warn?.("tool call failed", { tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
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
+ }
@@ -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, renameSync, rmSync, writeFileSync } from "node:fs";
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
- renameSync(temp, file);
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, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
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
- renameSync(temp, file);
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
+ }
@@ -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, renameSync, writeFileSync, chmodSync, realpathSync, rmSync, unlinkSync, readdirSync, statSync } from "node:fs";
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
- renameSync(filePath, backupPath);
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
- renameSync(tempPath, filePath);
429
+ replaceFileSync(tempPath, filePath);
429
430
  ownerOnlyFile(filePath);
430
431
  } catch (error) {
431
432
  if (fd !== undefined) {
@@ -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
- if (durationMs >= SLOW_TOOL_CALL_MS) logger.info("slow tool call completed", { tool: name, duration_ms: durationMs });
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.warn("tool call failed", { tool: name, duration_ms: durationMs, error_class: classifyOperationalError(error) });
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);