machine-bridge-mcp 0.18.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -0
- package/SECURITY.md +8 -6
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +0 -1
- package/docs/AGENT_CONTEXT.md +2 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/AUDIT.md +23 -0
- package/docs/ENGINEERING.md +3 -1
- package/docs/GETTING_STARTED.md +3 -0
- package/docs/LOCAL_AUTOMATION.md +1 -1
- package/docs/OPERATIONS.md +21 -1
- package/docs/PROJECT_STANDARDS.md +1 -1
- package/docs/TESTING.md +6 -2
- package/package.json +6 -3
- package/scripts/privacy-check.mjs +13 -13
- package/scripts/sarif-security-gate.mjs +146 -0
- package/src/local/account-access.mjs +0 -1
- package/src/local/atomic-fs.mjs +1 -1
- package/src/local/browser-bridge.mjs +2 -2
- package/src/local/browser-extension-protocol.mjs +1 -1
- package/src/local/browser-pairing-store.mjs +3 -4
- package/src/local/cli.mjs +21 -55
- package/src/local/default-instructions.mjs +4 -3
- package/src/local/errors.mjs +1 -5
- package/src/local/exclusive-file.mjs +9 -3
- package/src/local/full-access-test.mjs +5 -5
- package/src/local/job-runner.mjs +1 -1
- package/src/local/managed-jobs.mjs +27 -51
- package/src/local/relay-connection.mjs +7 -0
- package/src/local/runtime.mjs +20 -6
- package/src/local/secure-file.mjs +89 -10
- package/src/local/service-convergence.mjs +20 -0
- package/src/local/service.mjs +30 -38
- package/src/local/shell.mjs +26 -7
- package/src/local/ssh-key.mjs +71 -45
- package/src/local/state.mjs +46 -56
- package/src/local/worker-secret-file.mjs +130 -0
- package/src/local/workspace-file-service.mjs +10 -2
- package/src/shared/server-metadata.json +2 -3
- package/src/worker/http.ts +7 -8
- package/src/worker/index.ts +31 -41
- package/src/worker/mcp-session.ts +72 -0
- package/src/worker/oauth-state.ts +1 -1
- package/src/worker/pending-calls.ts +36 -5
- package/src/worker/tool-timeout.ts +18 -0
package/src/local/runtime.mjs
CHANGED
|
@@ -2,11 +2,11 @@ import { randomBytes } from "node:crypto";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
|
|
3
3
|
import { lstat, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import
|
|
5
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { RelayConnection } from "./relay-connection.mjs";
|
|
7
|
-
import {
|
|
7
|
+
import { ProcessSessionManager } from "./process-sessions.mjs";
|
|
8
8
|
export { MAX_COMMAND_BYTES } from "./process-sessions.mjs";
|
|
9
|
-
import { allToolNames,
|
|
9
|
+
import { allToolNames, isCanonicalFullPolicy, MCP_PROTOCOL_VERSION, MCP_SUPPORTED_PROTOCOL_VERSIONS, normalizePolicy, POLICY_PROFILES, PolicyGate, SERVER_NAME } from "./tools.mjs";
|
|
10
10
|
import { publicError } from "./errors.mjs";
|
|
11
11
|
import { ProcessTracker } from "./process-tracker.mjs";
|
|
12
12
|
import { CallRegistry } from "./call-registry.mjs";
|
|
@@ -367,9 +367,15 @@ export class LocalRuntime {
|
|
|
367
367
|
const envelope = normalizeRelayToolCall(message);
|
|
368
368
|
if (!envelope.ok) {
|
|
369
369
|
this.logger.event?.("warn", "relay.tool_call.invalid", { has_call_id: Boolean(envelope.id) });
|
|
370
|
-
if (envelope.id) this.
|
|
370
|
+
if (envelope.id) this.deliverRelayToolResult({
|
|
371
|
+
type: "tool_result",
|
|
372
|
+
id: envelope.id,
|
|
373
|
+
ok: false,
|
|
374
|
+
error: { code: "invalid_request", message: "invalid tool_call envelope", retryable: false },
|
|
375
|
+
});
|
|
371
376
|
return;
|
|
372
377
|
}
|
|
378
|
+
let response;
|
|
373
379
|
try {
|
|
374
380
|
const result = await this.executeTool(envelope.tool, envelope.arguments, {
|
|
375
381
|
callId: envelope.id,
|
|
@@ -377,10 +383,18 @@ export class LocalRuntime {
|
|
|
377
383
|
timeoutMs: envelope.timeoutMs,
|
|
378
384
|
authorization: envelope.authorization,
|
|
379
385
|
});
|
|
380
|
-
|
|
386
|
+
response = { type: "tool_result", id: envelope.id, ok: true, result };
|
|
381
387
|
} catch (error) {
|
|
382
|
-
|
|
388
|
+
response = { type: "tool_result", id: envelope.id, ok: false, error: publicError(error) };
|
|
383
389
|
}
|
|
390
|
+
this.deliverRelayToolResult(response);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
deliverRelayToolResult(response) {
|
|
394
|
+
if (this.send(response)) return true;
|
|
395
|
+
this.logger.event?.("warn", "relay.tool_result.delivery_failed", { call_id: shortCallId(response?.id) });
|
|
396
|
+
this.relay?.interrupt?.("relay_transport_error");
|
|
397
|
+
return false;
|
|
384
398
|
}
|
|
385
399
|
|
|
386
400
|
finishCall(callId) {
|
|
@@ -1,17 +1,50 @@
|
|
|
1
|
-
import { closeSync, constants as fsConstants, fstatSync, openSync, readSync } from "node:fs";
|
|
1
|
+
import { closeSync, constants as fsConstants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
|
2
2
|
|
|
3
|
-
export function
|
|
4
|
-
|
|
3
|
+
export function openRegularFileSync(file, flags, options = {}) {
|
|
4
|
+
const mode = Number.isInteger(options.mode) ? options.mode : undefined;
|
|
5
|
+
const label = String(options.label || "path");
|
|
6
|
+
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
7
|
+
let fd;
|
|
8
|
+
try {
|
|
9
|
+
fd = mode === undefined
|
|
10
|
+
? openSync(file, Number(flags) | noFollow)
|
|
11
|
+
: openSync(file, Number(flags) | noFollow, mode);
|
|
12
|
+
} catch (error) {
|
|
13
|
+
if (error?.code === "ELOOP") throw new Error(`${label} must not be a symbolic link`, { cause: error });
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const info = fstatSync(fd);
|
|
18
|
+
if (!info.isFile()) throw new Error(`${label} is not a regular file`);
|
|
19
|
+
if (Number.isInteger(options.chmod)) setDescriptorMode(fd, options.chmod);
|
|
20
|
+
return { fd, info };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
closeSync(fd);
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function withRegularFileSync(file, flags, options, callback) {
|
|
28
|
+
const opened = openRegularFileSync(file, flags, options);
|
|
29
|
+
try {
|
|
30
|
+
return callback(opened.fd, opened.info);
|
|
31
|
+
} finally {
|
|
32
|
+
closeSync(opened.fd);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function chmodRegularFileSync(file, mode, label = "path") {
|
|
37
|
+
return withRegularFileSync(file, fsConstants.O_RDONLY, { label, chmod: mode }, () => undefined);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function readBoundedRegularFileSync(file, maxBytes, label = "path") {
|
|
41
|
+
return readBoundedRegularFileWithInfoSync(file, maxBytes, label).buffer;
|
|
5
42
|
}
|
|
6
43
|
|
|
7
|
-
export function readBoundedRegularFileWithInfoSync(file, maxBytes) {
|
|
44
|
+
export function readBoundedRegularFileWithInfoSync(file, maxBytes, label = "path") {
|
|
8
45
|
const limit = Number(maxBytes);
|
|
9
46
|
if (!Number.isSafeInteger(limit) || limit < 0) throw new Error("maximum file size must be a non-negative safe integer");
|
|
10
|
-
|
|
11
|
-
const fd = openSync(file, flags);
|
|
12
|
-
try {
|
|
13
|
-
const info = fstatSync(fd);
|
|
14
|
-
if (!info.isFile()) throw new Error("path is not a regular file");
|
|
47
|
+
return withRegularFileSync(file, fsConstants.O_RDONLY, { label }, (fd, info) => {
|
|
15
48
|
if (info.size > limit) throw new Error(`file exceeds ${limit} bytes`);
|
|
16
49
|
const buffer = Buffer.alloc(info.size);
|
|
17
50
|
let offset = 0;
|
|
@@ -21,7 +54,53 @@ export function readBoundedRegularFileWithInfoSync(file, maxBytes) {
|
|
|
21
54
|
offset += count;
|
|
22
55
|
}
|
|
23
56
|
return { buffer: buffer.subarray(0, offset), info };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function ensureOwnerOnlyDirectorySync(dir, options = {}) {
|
|
61
|
+
const platform = String(options.platform || process.platform);
|
|
62
|
+
const makeDirectory = options.mkdirSync || mkdirSync;
|
|
63
|
+
const inspectPath = options.lstatSync || lstatSync;
|
|
64
|
+
makeDirectory(dir, { recursive: true, mode: 0o700 });
|
|
65
|
+
if (platform === "win32") {
|
|
66
|
+
const info = inspectPath(dir);
|
|
67
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("owner-only path must be a real directory");
|
|
68
|
+
return info;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
72
|
+
const directoryOnly = Number(fsConstants.O_DIRECTORY || 0);
|
|
73
|
+
if (!noFollow || !directoryOnly) throw new Error("secure owner-only directory descriptors are unavailable on this platform");
|
|
74
|
+
const open = options.openSync || openSync;
|
|
75
|
+
const inspectDescriptor = options.fstatSync || fstatSync;
|
|
76
|
+
const restrictDescriptor = options.fchmodSync || fchmodSync;
|
|
77
|
+
const close = options.closeSync || closeSync;
|
|
78
|
+
let fd;
|
|
79
|
+
try {
|
|
80
|
+
fd = open(dir, Number(fsConstants.O_RDONLY) | noFollow | directoryOnly);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (["ELOOP", "ENOTDIR"].includes(error?.code)) throw new Error("owner-only path must be a real directory and not a symbolic link", { cause: error });
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
let info = inspectDescriptor(fd);
|
|
87
|
+
if (!info.isDirectory()) throw new Error("owner-only path must be a directory");
|
|
88
|
+
try {
|
|
89
|
+
restrictDescriptor(fd, 0o700);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw new Error("could not restrict owner-only directory permissions", { cause: error });
|
|
92
|
+
}
|
|
93
|
+
info = inspectDescriptor(fd);
|
|
94
|
+
if (!info.isDirectory()) throw new Error("owner-only directory identity changed during permission enforcement");
|
|
95
|
+
if ((info.mode & 0o077) !== 0) throw new Error("owner-only directory remains accessible to group or other users");
|
|
96
|
+
return info;
|
|
24
97
|
} finally {
|
|
25
|
-
|
|
98
|
+
if (fd !== undefined) close(fd);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function setDescriptorMode(fd, mode) {
|
|
103
|
+
try { fchmodSync(fd, mode); } catch (error) {
|
|
104
|
+
if (process.platform !== "win32") throw error;
|
|
26
105
|
}
|
|
27
106
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const DEFAULT_ATTEMPTS = 20;
|
|
2
|
+
const DEFAULT_DELAY_MS = 100;
|
|
3
|
+
|
|
4
|
+
export async function waitForInactiveStatus(
|
|
5
|
+
readStatus,
|
|
6
|
+
{ attempts = DEFAULT_ATTEMPTS, delayMs = DEFAULT_DELAY_MS, sleep = delay } = {},
|
|
7
|
+
) {
|
|
8
|
+
if (typeof readStatus !== "function") throw new TypeError("readStatus must be a function");
|
|
9
|
+
const maximum = Number.isInteger(attempts) && attempts > 0 ? attempts : DEFAULT_ATTEMPTS;
|
|
10
|
+
let status = await readStatus();
|
|
11
|
+
for (let attempt = 1; status?.active === true && attempt < maximum; attempt += 1) {
|
|
12
|
+
await sleep(delayMs);
|
|
13
|
+
status = await readStatus();
|
|
14
|
+
}
|
|
15
|
+
return status;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function delay(milliseconds) {
|
|
19
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
20
|
+
}
|
package/src/local/service.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { closeSync, constants as fsConstants, existsSync, ftruncateSync, lstatSync, mkdirSync, readSync, realpathSync, rmSync, statSync, writeSync } from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { ensureOwnerOnlyDir, expandHome
|
|
4
|
+
import { runExecutable } from "./shell.mjs";
|
|
5
|
+
import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
|
|
6
6
|
import { replaceFileAtomicallySync } from "./exclusive-file.mjs";
|
|
7
|
-
import { readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
7
|
+
import { openRegularFileSync, readBoundedRegularFileSync } from "./secure-file.mjs";
|
|
8
|
+
import { waitForInactiveStatus } from "./service-convergence.mjs";
|
|
8
9
|
|
|
9
10
|
const LABEL = "dev.machine-bridge-mcp.daemon";
|
|
10
11
|
const WINDOWS_TASK = "MachineBridgeMCP";
|
|
@@ -12,7 +13,11 @@ const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
|
|
|
12
13
|
const AUTOSTART_LOG_SCHEMA_VERSION = 3;
|
|
13
14
|
|
|
14
15
|
function serviceRun(command, args) {
|
|
15
|
-
return
|
|
16
|
+
return runServiceCommand(command, args);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runServiceCommand(command, args, execute = runExecutable) {
|
|
20
|
+
return execute(command, args, {
|
|
16
21
|
capture: true,
|
|
17
22
|
allowFailure: true,
|
|
18
23
|
maxOutputBytes: SERVICE_COMMAND_OUTPUT_BYTES,
|
|
@@ -33,9 +38,9 @@ export async function uninstallAutostart({ stateRoot, logger = console } = {}) {
|
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
export async function autostartStatus({ logger = console } = {}) {
|
|
36
|
-
if (process.platform === "darwin") return statusLaunchd(
|
|
37
|
-
if (process.platform === "win32") return statusWindowsTask(
|
|
38
|
-
return statusSystemd(
|
|
41
|
+
if (process.platform === "darwin") return statusLaunchd();
|
|
42
|
+
if (process.platform === "win32") return statusWindowsTask();
|
|
43
|
+
return statusSystemd();
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
export async function startAutostart({ logger = console } = {}) {
|
|
@@ -81,21 +86,18 @@ export function trimAutostartLogs(stateRoot, options = {}) {
|
|
|
81
86
|
const file = path.join(logs, name);
|
|
82
87
|
let fd;
|
|
83
88
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
fd =
|
|
89
|
-
const info = fstatSync(fd);
|
|
90
|
-
if (!info.isFile()) continue;
|
|
89
|
+
const opened = openRegularFileSync(file, fsConstants.O_RDWR, {
|
|
90
|
+
label: "autostart log path",
|
|
91
|
+
chmod: 0o600,
|
|
92
|
+
});
|
|
93
|
+
fd = opened.fd;
|
|
91
94
|
if (reset) {
|
|
92
95
|
ftruncateSync(fd, 0);
|
|
93
|
-
} else if (info.size > maxBytes) {
|
|
94
|
-
const tail = readLogTail(fd, info.size, keepBytes);
|
|
96
|
+
} else if (opened.info.size > maxBytes) {
|
|
97
|
+
const tail = readLogTail(fd, opened.info.size, keepBytes);
|
|
95
98
|
ftruncateSync(fd, 0);
|
|
96
99
|
if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
|
|
97
100
|
}
|
|
98
|
-
try { chmodSync(file, 0o600); } catch {}
|
|
99
101
|
} catch {
|
|
100
102
|
// Log maintenance is best effort and must not stop daemon startup.
|
|
101
103
|
} finally {
|
|
@@ -212,28 +214,17 @@ export function daemonArgs(spec) {
|
|
|
212
214
|
}
|
|
213
215
|
|
|
214
216
|
function ensurePrivateLogFile(file) {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
try {
|
|
222
|
-
if (!fstatSync(fd).isFile()) throw new Error("autostart log path is not a regular file");
|
|
223
|
-
} finally {
|
|
224
|
-
closeSync(fd);
|
|
225
|
-
}
|
|
226
|
-
ownerOnlyFile(file);
|
|
217
|
+
const opened = openRegularFileSync(
|
|
218
|
+
file,
|
|
219
|
+
Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND),
|
|
220
|
+
{ label: "autostart log path", mode: 0o600, chmod: 0o600 },
|
|
221
|
+
);
|
|
222
|
+
closeSync(opened.fd);
|
|
227
223
|
}
|
|
228
224
|
|
|
229
225
|
function writePrivateServiceFile(file, content) {
|
|
230
226
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
231
|
-
if (existsSync(file)) {
|
|
232
|
-
const info = lstatSync(file);
|
|
233
|
-
if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart configuration path must be a regular non-symbolic-link file");
|
|
234
|
-
}
|
|
235
227
|
replaceFileAtomicallySync(file, content, { mode: 0o600 });
|
|
236
|
-
ownerOnlyFile(file);
|
|
237
228
|
}
|
|
238
229
|
|
|
239
230
|
function launchdPlistPath() {
|
|
@@ -299,9 +290,10 @@ async function stopLaunchd(logger) {
|
|
|
299
290
|
const byPlist = byServiceTarget.code === 0
|
|
300
291
|
? null
|
|
301
292
|
: await serviceRun("launchctl", ["bootout", domainTarget, plistPath]);
|
|
302
|
-
const after = await statusLaunchd
|
|
293
|
+
const after = await waitForInactiveStatus(statusLaunchd);
|
|
303
294
|
const rawResult = byPlist || byServiceTarget;
|
|
304
|
-
const
|
|
295
|
+
const active = after?.active !== false;
|
|
296
|
+
const ok = !active;
|
|
305
297
|
if (ok) logger.info?.("launchd service stopped");
|
|
306
298
|
else logger.warn?.("launchd service is still active after the stop request");
|
|
307
299
|
return {
|
|
@@ -310,7 +302,7 @@ async function stopLaunchd(logger) {
|
|
|
310
302
|
provider: "launchd",
|
|
311
303
|
installed: existsSync(plistPath),
|
|
312
304
|
active_before: true,
|
|
313
|
-
active
|
|
305
|
+
active,
|
|
314
306
|
already_stopped: false,
|
|
315
307
|
code: ok ? 0 : rawResult.code,
|
|
316
308
|
bootout_service_target: byServiceTarget,
|
package/src/local/shell.mjs
CHANGED
|
@@ -4,11 +4,13 @@ import path from "node:path";
|
|
|
4
4
|
import { packageRoot } from "./state.mjs";
|
|
5
5
|
import { BoundedOutput } from "./bounded-output.mjs";
|
|
6
6
|
|
|
7
|
-
export function
|
|
7
|
+
export function runExecutable(command, args = [], options = {}) {
|
|
8
|
+
const executable = validateExecutable(command);
|
|
9
|
+
const argv = validateExecutableArgs(args);
|
|
8
10
|
return new Promise((resolve, reject) => {
|
|
9
11
|
const capture = Boolean(options.capture);
|
|
10
12
|
const maxOutputBytes = Number.isFinite(Number(options.maxOutputBytes)) ? Math.max(1024, Number(options.maxOutputBytes)) : 2 * 1024 * 1024;
|
|
11
|
-
const child = spawn(
|
|
13
|
+
const child = spawn(executable, argv, {
|
|
12
14
|
cwd: options.cwd || process.cwd(),
|
|
13
15
|
env: options.env || process.env,
|
|
14
16
|
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
@@ -54,7 +56,7 @@ export function run(command, args = [], options = {}) {
|
|
|
54
56
|
};
|
|
55
57
|
if ((!timedOut && code === 0) || options.allowFailure) resolve(result);
|
|
56
58
|
else {
|
|
57
|
-
const error = new Error((result.stderr || result.stdout || `${
|
|
59
|
+
const error = new Error((result.stderr || result.stdout || `${executable} exited ${result.code}`).trim());
|
|
58
60
|
error.result = result;
|
|
59
61
|
reject(error);
|
|
60
62
|
}
|
|
@@ -63,6 +65,25 @@ export function run(command, args = [], options = {}) {
|
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
|
|
68
|
+
function validateExecutable(value) {
|
|
69
|
+
if (typeof value !== "string" || !value || value.includes("\0")) {
|
|
70
|
+
throw new TypeError("executable must be a non-empty string without NUL bytes");
|
|
71
|
+
}
|
|
72
|
+
if (Buffer.byteLength(value) > 32 * 1024) throw new RangeError("executable path exceeds 32 KiB");
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateExecutableArgs(value) {
|
|
77
|
+
if (!Array.isArray(value) || value.length > 4096) throw new TypeError("executable arguments must be an array with at most 4096 entries");
|
|
78
|
+
let totalBytes = 0;
|
|
79
|
+
return value.map((entry) => {
|
|
80
|
+
if (typeof entry !== "string" || entry.includes("\0")) throw new TypeError("executable arguments must be strings without NUL bytes");
|
|
81
|
+
totalBytes += Buffer.byteLength(entry);
|
|
82
|
+
if (totalBytes > 1024 * 1024) throw new RangeError("executable arguments exceed 1 MiB");
|
|
83
|
+
return entry;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
66
87
|
function terminateCommandTree(child, force) {
|
|
67
88
|
if (!child?.pid) return;
|
|
68
89
|
if (process.platform === "win32") {
|
|
@@ -94,16 +115,14 @@ function capturedResult(code, stdout, stderr, extraStderr = "") {
|
|
|
94
115
|
|
|
95
116
|
function findWranglerCommand() {
|
|
96
117
|
const suffix = process.platform === "win32" ? ".cmd" : "";
|
|
97
|
-
|
|
98
|
-
if (existsSync(local)) return { cmd: local, argsPrefix: [] };
|
|
99
|
-
throw new Error("Wrangler dependency is not installed. Run `npm install` in the package/source directory and retry.");
|
|
118
|
+
return { cmd: path.join(packageRoot, "node_modules", ".bin", `wrangler${suffix}`), argsPrefix: [] };
|
|
100
119
|
}
|
|
101
120
|
|
|
102
121
|
export async function runWrangler(args, options = {}) {
|
|
103
122
|
const wrangler = findWranglerCommand();
|
|
104
123
|
const operation = String(args[0] || "");
|
|
105
124
|
const timeoutMs = options.timeoutMs ?? (operation === "login" || operation === "deploy" ? 10 * 60 * 1000 : 2 * 60 * 1000);
|
|
106
|
-
return
|
|
125
|
+
return runExecutable(wrangler.cmd, [...wrangler.argsPrefix, ...args], { cwd: packageRoot, timeoutMs, ...options });
|
|
107
126
|
}
|
|
108
127
|
|
|
109
128
|
export function workspaceShellCommand(command) {
|
package/src/local/ssh-key.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { constants as fsConstants } from "node:fs";
|
|
2
|
-
import { chmod, copyFile, link,
|
|
3
|
-
import { basename, dirname, resolve } from "node:path";
|
|
4
|
-
import { randomBytes } from "node:crypto";
|
|
5
|
-
import {
|
|
2
|
+
import { chmod, copyFile, link, mkdir, mkdtemp, rm, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { runExecutable } from "./shell.mjs";
|
|
7
|
+
import { chmodRegularFileSync, readBoundedRegularFileWithInfoSync } from "./secure-file.mjs";
|
|
6
8
|
|
|
7
9
|
const KEY_TYPES = new Set(["ed25519", "rsa"]);
|
|
8
10
|
|
|
@@ -35,11 +37,10 @@ function normalizeKeyRequest(options) {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
async function inspectExistingKeyFiles(privateKeyPath, publicKeyPath) {
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
if (!
|
|
42
|
-
if (!privateInfo?.isFile() || !publicInfo?.isFile()) throw new Error("SSH key pair is incomplete or not a pair of regular files");
|
|
40
|
+
const privateSnapshot = tryReadKeySnapshot(privateKeyPath, 1024 * 1024, "SSH private key");
|
|
41
|
+
const publicSnapshot = tryReadKeySnapshot(publicKeyPath, 64 * 1024, "SSH public key");
|
|
42
|
+
if (!privateSnapshot && !publicSnapshot) return false;
|
|
43
|
+
if (!privateSnapshot || !publicSnapshot) throw new Error("SSH key pair is incomplete or not a pair of regular files");
|
|
43
44
|
return true;
|
|
44
45
|
}
|
|
45
46
|
|
|
@@ -51,7 +52,7 @@ async function createSshKeyPair(request) {
|
|
|
51
52
|
const args = ["-q", "-t", request.type];
|
|
52
53
|
if (request.type === "rsa") args.push("-b", String(normalizeRsaBits(request.bits)));
|
|
53
54
|
args.push("-N", "", "-f", tempPrivate, "-C", request.comment);
|
|
54
|
-
const generated = await
|
|
55
|
+
const generated = await runExecutable("ssh-keygen", args, { capture: true, timeoutMs: 30_000, maxOutputBytes: 64 * 1024 });
|
|
55
56
|
if (generated.code !== 0) throw new Error("ssh-keygen failed");
|
|
56
57
|
await secureKeyModes(tempPrivate, tempPublic);
|
|
57
58
|
await installNoReplace(tempPrivate, request.privateKeyPath);
|
|
@@ -70,37 +71,47 @@ async function createSshKeyPair(request) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
export async function inspectSshKeyPair(privateKeyPath, publicKeyPath = `${privateKeyPath}.pub`, created = false) {
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
74
|
+
const privateSnapshot = readKeySnapshot(privateKeyPath, 1024 * 1024, "SSH private key");
|
|
75
|
+
const publicSnapshot = readKeySnapshot(publicKeyPath, 64 * 1024, "SSH public key");
|
|
76
|
+
const inspectionRoot = await mkdtemp(join(tmpdir(), "machine-mcp-key-inspection-"));
|
|
77
|
+
const inspectionPrivate = join(inspectionRoot, "key");
|
|
78
|
+
const inspectionPublic = `${inspectionPrivate}.pub`;
|
|
79
|
+
try {
|
|
80
|
+
await writeFile(inspectionPrivate, privateSnapshot.buffer, { mode: 0o600, flag: "wx" });
|
|
81
|
+
await writeFile(inspectionPublic, publicSnapshot.buffer, { mode: 0o600, flag: "wx" });
|
|
82
|
+
const derived = await runExecutable("ssh-keygen", ["-y", "-P", "", "-f", inspectionPrivate], {
|
|
83
|
+
capture: true,
|
|
84
|
+
allowFailure: true,
|
|
85
|
+
timeoutMs: 15_000,
|
|
86
|
+
maxOutputBytes: 64 * 1024,
|
|
87
|
+
});
|
|
88
|
+
if (derived.code !== 0) throw new Error("SSH private key cannot be used non-interactively or is invalid");
|
|
89
|
+
const publicLine = new TextDecoder("utf-8", { fatal: true }).decode(publicSnapshot.buffer).trim();
|
|
90
|
+
if (!/^(ssh-ed25519|ssh-rsa)\s+[A-Za-z0-9+/=]+(?:\s+.*)?$/.test(publicLine)) throw new Error("generated SSH public key is invalid");
|
|
91
|
+
const expectedFields = publicLine.split(/\s+/).slice(0, 2).join(" ");
|
|
92
|
+
const derivedFields = derived.stdout.trim().split(/\s+/).slice(0, 2).join(" ");
|
|
93
|
+
if (expectedFields !== derivedFields) throw new Error("SSH public key does not match the private key");
|
|
94
|
+
const fingerprint = await runExecutable("ssh-keygen", ["-lf", inspectionPublic, "-E", "sha256"], {
|
|
95
|
+
capture: true,
|
|
96
|
+
timeoutMs: 15_000,
|
|
97
|
+
maxOutputBytes: 64 * 1024,
|
|
98
|
+
});
|
|
99
|
+
const fingerprintValue = fingerprint.stdout.trim().split(/\s+/)[1] || "";
|
|
100
|
+
if (!/^SHA256:[A-Za-z0-9+/=]+$/.test(fingerprintValue)) throw new Error("SSH key fingerprint output is invalid");
|
|
101
|
+
assertKeySnapshotCurrent(privateKeyPath, privateSnapshot, 1024 * 1024, "SSH private key");
|
|
102
|
+
assertKeySnapshotCurrent(publicKeyPath, publicSnapshot, 64 * 1024, "SSH public key");
|
|
103
|
+
return {
|
|
104
|
+
created,
|
|
105
|
+
privateKeyPath: resolve(privateKeyPath),
|
|
106
|
+
publicKeyPath: resolve(publicKeyPath),
|
|
107
|
+
privateMode: process.platform === "win32" ? null : `0${(privateSnapshot.info.mode & 0o777).toString(8)}`,
|
|
108
|
+
publicMode: process.platform === "win32" ? null : `0${(publicSnapshot.info.mode & 0o777).toString(8)}`,
|
|
109
|
+
fingerprint: fingerprintValue,
|
|
110
|
+
publicKeyType: publicLine.split(/\s+/, 1)[0],
|
|
111
|
+
};
|
|
112
|
+
} finally {
|
|
113
|
+
await rm(inspectionRoot, { recursive: true, force: true });
|
|
114
|
+
}
|
|
104
115
|
}
|
|
105
116
|
|
|
106
117
|
async function installNoReplace(source, target) {
|
|
@@ -120,17 +131,32 @@ async function installNoReplace(source, target) {
|
|
|
120
131
|
|
|
121
132
|
async function secureKeyModes(privateKeyPath, publicKeyPath) {
|
|
122
133
|
if (process.platform === "win32") return;
|
|
123
|
-
|
|
124
|
-
|
|
134
|
+
chmodRegularFileSync(privateKeyPath, 0o600, "SSH private key");
|
|
135
|
+
chmodRegularFileSync(publicKeyPath, 0o644, "SSH public key");
|
|
125
136
|
}
|
|
126
137
|
|
|
127
|
-
|
|
128
|
-
try { return
|
|
138
|
+
function tryReadKeySnapshot(path, maxBytes, label) {
|
|
139
|
+
try { return readKeySnapshot(path, maxBytes, label); } catch (error) {
|
|
129
140
|
if (error?.code === "ENOENT") return null;
|
|
130
141
|
throw error;
|
|
131
142
|
}
|
|
132
143
|
}
|
|
133
144
|
|
|
145
|
+
function readKeySnapshot(path, maxBytes, label) {
|
|
146
|
+
return readBoundedRegularFileWithInfoSync(path, maxBytes, label);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function assertKeySnapshotCurrent(path, expected, maxBytes, label) {
|
|
150
|
+
const current = readKeySnapshot(path, maxBytes, label);
|
|
151
|
+
const sameIdentity = Number(current.info.dev) === Number(expected.info.dev)
|
|
152
|
+
&& Number(current.info.ino) === Number(expected.info.ino)
|
|
153
|
+
&& Number(current.info.size) === Number(expected.info.size)
|
|
154
|
+
&& Number(current.info.mtimeMs) === Number(expected.info.mtimeMs);
|
|
155
|
+
const sameBytes = current.buffer.length === expected.buffer.length
|
|
156
|
+
&& timingSafeEqual(current.buffer, expected.buffer);
|
|
157
|
+
if (!sameIdentity || !sameBytes) throw new Error(`${label} changed during inspection; retry`);
|
|
158
|
+
}
|
|
159
|
+
|
|
134
160
|
function boundedComment(value) {
|
|
135
161
|
const comment = String(value || "").replace(/[\r\n\0\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, " ").replace(/\s+/g, " ").trim();
|
|
136
162
|
if (!comment) return "machine-mcp";
|