machine-bridge-mcp 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/CONTRIBUTING.md +23 -0
- package/README.md +32 -12
- package/SECURITY.md +20 -10
- package/docs/ARCHITECTURE.md +16 -8
- package/docs/CLIENTS.md +11 -1
- package/docs/LOGGING.md +13 -18
- package/docs/MANAGED_JOBS.md +20 -11
- package/docs/OPERATIONS.md +31 -10
- package/docs/PRIVACY.md +37 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +22 -2
- package/package.json +39 -10
- package/scripts/network-retry.mjs +47 -0
- package/scripts/privacy-check.mjs +177 -0
- package/scripts/release-impact-check.mjs +78 -0
- package/src/local/atomic-fs.mjs +37 -0
- package/src/local/cli.mjs +113 -15
- package/src/local/daemon.mjs +114 -15
- package/src/local/full-access-test.mjs +206 -0
- package/src/local/job-runner.mjs +73 -15
- package/src/local/log.mjs +26 -2
- package/src/local/managed-jobs.mjs +165 -75
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/resource-operations.mjs +66 -0
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +127 -0
- package/src/local/state.mjs +12 -9
- package/src/local/stdio.mjs +78 -20
- package/src/local/tools.mjs +37 -3
- package/src/shared/server-metadata.json +4 -1
- package/src/shared/tool-catalog.json +37 -0
- package/src/worker/index.ts +41 -18
- package/wrangler.jsonc +1 -1
package/src/local/service.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { run } from "./shell.mjs";
|
|
5
|
-
import { ensureOwnerOnlyDir, expandHome } from "./state.mjs";
|
|
5
|
+
import { ensureOwnerOnlyDir, expandHome, ownerOnlyFile } from "./state.mjs";
|
|
6
|
+
import { replaceFileSync } from "./atomic-fs.mjs";
|
|
6
7
|
|
|
7
8
|
const LABEL = "dev.machine-bridge-mcp.daemon";
|
|
8
9
|
const WINDOWS_TASK = "MachineBridgeMCP";
|
|
@@ -55,23 +56,29 @@ export function trimAutostartLogs(stateRoot, options = {}) {
|
|
|
55
56
|
const logs = path.join(root, "logs");
|
|
56
57
|
for (const name of ["daemon.out.log", "daemon.err.log"]) {
|
|
57
58
|
const file = path.join(logs, name);
|
|
59
|
+
let fd;
|
|
58
60
|
try {
|
|
59
|
-
|
|
61
|
+
if (!existsSync(file)) continue;
|
|
62
|
+
const before = lstatSync(file);
|
|
63
|
+
if (before.isSymbolicLink() || !before.isFile()) continue;
|
|
64
|
+
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
65
|
+
fd = openSync(file, Number(fsConstants.O_RDWR) | noFollow);
|
|
66
|
+
const info = fstatSync(fd);
|
|
67
|
+
if (!info.isFile()) continue;
|
|
60
68
|
if (info.size > maxBytes) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
readSync(fd, buffer, 0, length, Math.max(0, current.size - length));
|
|
68
|
-
} finally {
|
|
69
|
-
closeSync(fd);
|
|
70
|
-
}
|
|
71
|
-
writeFileSync(file, lineSafeTail(buffer), { mode: 0o600 });
|
|
69
|
+
const length = Math.min(keepBytes, info.size);
|
|
70
|
+
const buffer = Buffer.alloc(length);
|
|
71
|
+
readSync(fd, buffer, 0, length, Math.max(0, info.size - length));
|
|
72
|
+
const tail = lineSafeTail(buffer);
|
|
73
|
+
ftruncateSync(fd, 0);
|
|
74
|
+
if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
|
|
72
75
|
}
|
|
73
|
-
chmodSync(file, 0o600);
|
|
74
|
-
} catch {
|
|
76
|
+
try { chmodSync(file, 0o600); } catch {}
|
|
77
|
+
} catch {
|
|
78
|
+
// Operational log maintenance is best effort and must not stop startup.
|
|
79
|
+
} finally {
|
|
80
|
+
if (fd !== undefined) try { closeSync(fd); } catch {}
|
|
81
|
+
}
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
@@ -87,22 +94,76 @@ function lineSafeTail(buffer) {
|
|
|
87
94
|
function serviceSpec({ workspace, stateRoot, entryScript }) {
|
|
88
95
|
const root = expandHome(stateRoot);
|
|
89
96
|
const logs = path.join(root, "logs");
|
|
97
|
+
const resolvedEntryScript = path.resolve(entryScript);
|
|
98
|
+
const node = stableNodeExecutable();
|
|
90
99
|
ensureOwnerOnlyDir(root);
|
|
91
100
|
ensureOwnerOnlyDir(logs);
|
|
92
|
-
for (const file of [path.join(logs, "daemon.out.log"), path.join(logs, "daemon.err.log")])
|
|
93
|
-
writeFileSync(file, "", { flag: "a", mode: 0o600 });
|
|
94
|
-
try { chmodSync(file, 0o600); } catch {}
|
|
95
|
-
}
|
|
101
|
+
for (const file of [path.join(logs, "daemon.out.log"), path.join(logs, "daemon.err.log")]) ensurePrivateLogFile(file);
|
|
96
102
|
return {
|
|
97
103
|
workspace,
|
|
98
104
|
stateRoot: root,
|
|
99
|
-
entryScript:
|
|
100
|
-
node
|
|
105
|
+
entryScript: resolvedEntryScript,
|
|
106
|
+
node,
|
|
107
|
+
pathEnv: serviceEnvironmentPath({ node, entryScript: resolvedEntryScript }),
|
|
101
108
|
stdout: path.join(logs, "daemon.out.log"),
|
|
102
109
|
stderr: path.join(logs, "daemon.err.log"),
|
|
103
110
|
};
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
export function stableNodeExecutable(options = {}) {
|
|
114
|
+
const platform = String(options.platform || process.platform);
|
|
115
|
+
const execPath = path.resolve(String(options.execPath || process.execPath));
|
|
116
|
+
const pathEnv = String(options.pathEnv ?? process.env.PATH ?? "");
|
|
117
|
+
let canonicalExec;
|
|
118
|
+
try { canonicalExec = realpathSync(execPath); } catch { return execPath; }
|
|
119
|
+
const executableName = platform === "win32" ? "node.exe" : "node";
|
|
120
|
+
for (const directory of pathEnv.split(path.delimiter).filter(Boolean)) {
|
|
121
|
+
const candidate = path.resolve(directory, executableName);
|
|
122
|
+
try {
|
|
123
|
+
const info = lstatSync(candidate);
|
|
124
|
+
if (!info.isFile() && !info.isSymbolicLink()) continue;
|
|
125
|
+
const candidateCanonical = realpathSync(candidate);
|
|
126
|
+
const sameExecutable = platform === "win32"
|
|
127
|
+
? candidateCanonical.toLowerCase() === canonicalExec.toLowerCase()
|
|
128
|
+
: candidateCanonical === canonicalExec;
|
|
129
|
+
if (!sameExecutable) continue;
|
|
130
|
+
if (platform !== "win32" && (statSync(candidate).mode & 0o111) === 0) continue;
|
|
131
|
+
return candidate;
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
return execPath;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function serviceEnvironmentPath(options = {}) {
|
|
138
|
+
const platform = String(options.platform || process.platform);
|
|
139
|
+
const delimiter = String(options.delimiter || (platform === "win32" ? ";" : ":"));
|
|
140
|
+
const pathEnv = String(options.pathEnv ?? process.env.PATH ?? "");
|
|
141
|
+
const node = String(options.node || process.execPath || "");
|
|
142
|
+
const entryScript = String(options.entryScript || "");
|
|
143
|
+
const defaults = platform === "darwin"
|
|
144
|
+
? ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
|
|
145
|
+
: platform === "win32"
|
|
146
|
+
? []
|
|
147
|
+
: ["/usr/local/bin", "/usr/bin", "/bin", "/usr/local/sbin", "/usr/sbin", "/sbin"];
|
|
148
|
+
const candidates = [
|
|
149
|
+
node ? path.dirname(path.resolve(node)) : "",
|
|
150
|
+
entryScript ? path.dirname(path.resolve(entryScript)) : "",
|
|
151
|
+
...pathEnv.split(delimiter),
|
|
152
|
+
...defaults,
|
|
153
|
+
];
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
const entries = [];
|
|
156
|
+
for (const raw of candidates) {
|
|
157
|
+
if (!raw || !path.isAbsolute(raw)) continue;
|
|
158
|
+
const normalized = path.resolve(raw);
|
|
159
|
+
const key = platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
160
|
+
if (seen.has(key)) continue;
|
|
161
|
+
seen.add(key);
|
|
162
|
+
entries.push(normalized);
|
|
163
|
+
}
|
|
164
|
+
return entries.join(delimiter);
|
|
165
|
+
}
|
|
166
|
+
|
|
106
167
|
export function daemonArgs(spec) {
|
|
107
168
|
return [
|
|
108
169
|
spec.entryScript,
|
|
@@ -115,6 +176,38 @@ export function daemonArgs(spec) {
|
|
|
115
176
|
];
|
|
116
177
|
}
|
|
117
178
|
|
|
179
|
+
function ensurePrivateLogFile(file) {
|
|
180
|
+
if (existsSync(file)) {
|
|
181
|
+
const info = lstatSync(file);
|
|
182
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart log path must be a regular non-symbolic-link file");
|
|
183
|
+
}
|
|
184
|
+
const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
|
|
185
|
+
const fd = openSync(file, Number(fsConstants.O_WRONLY) | Number(fsConstants.O_CREAT) | Number(fsConstants.O_APPEND) | noFollow, 0o600);
|
|
186
|
+
try {
|
|
187
|
+
if (!fstatSync(fd).isFile()) throw new Error("autostart log path is not a regular file");
|
|
188
|
+
} finally {
|
|
189
|
+
closeSync(fd);
|
|
190
|
+
}
|
|
191
|
+
ownerOnlyFile(file);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function writePrivateServiceFile(file, content) {
|
|
195
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
196
|
+
if (existsSync(file)) {
|
|
197
|
+
const info = lstatSync(file);
|
|
198
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new Error("autostart configuration path must be a regular non-symbolic-link file");
|
|
199
|
+
}
|
|
200
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
201
|
+
try {
|
|
202
|
+
writeFileSync(temporary, content, { mode: 0o600, flag: "wx" });
|
|
203
|
+
replaceFileSync(temporary, file);
|
|
204
|
+
ownerOnlyFile(file);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
try { rmSync(temporary, { force: true }); } catch {}
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
118
211
|
function launchdPlistPath() {
|
|
119
212
|
return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
120
213
|
}
|
|
@@ -123,8 +216,8 @@ async function installLaunchd(spec, logger) {
|
|
|
123
216
|
const plistPath = launchdPlistPath();
|
|
124
217
|
mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
125
218
|
const args = [spec.node, ...daemonArgs(spec)];
|
|
126
|
-
|
|
127
|
-
logger.info?.(
|
|
219
|
+
writePrivateServiceFile(plistPath, launchdPlist({ args, pathEnv: spec.pathEnv, stdout: spec.stdout, stderr: spec.stderr }));
|
|
220
|
+
logger.info?.("Autostart installed for next login.");
|
|
128
221
|
return { ok: true, provider: "launchd", path: plistPath };
|
|
129
222
|
}
|
|
130
223
|
|
|
@@ -151,7 +244,7 @@ async function uninstallLaunchd(logger) {
|
|
|
151
244
|
await stopLaunchd(logger).catch(() => {});
|
|
152
245
|
const plistPath = launchdPlistPath();
|
|
153
246
|
if (existsSync(plistPath)) rmSync(plistPath, { force: true });
|
|
154
|
-
logger.info?.(
|
|
247
|
+
logger.info?.("Autostart removed.");
|
|
155
248
|
return { ok: true, provider: "launchd", path: plistPath };
|
|
156
249
|
}
|
|
157
250
|
|
|
@@ -162,7 +255,7 @@ async function statusLaunchd() {
|
|
|
162
255
|
return { ok: existsSync(plistPath), provider: "launchd", installed: existsSync(plistPath), path: plistPath, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
163
256
|
}
|
|
164
257
|
|
|
165
|
-
function launchdPlist({ args, stdout, stderr }) {
|
|
258
|
+
export function launchdPlist({ args, pathEnv, stdout, stderr }) {
|
|
166
259
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
167
260
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
168
261
|
<plist version="1.0">
|
|
@@ -172,6 +265,10 @@ function launchdPlist({ args, stdout, stderr }) {
|
|
|
172
265
|
<array>
|
|
173
266
|
${args.map(arg => ` <string>${escapeXml(arg)}</string>`).join("\n")}
|
|
174
267
|
</array>
|
|
268
|
+
<key>EnvironmentVariables</key>
|
|
269
|
+
<dict>
|
|
270
|
+
<key>PATH</key><string>${escapeXml(pathEnv)}</string>
|
|
271
|
+
</dict>
|
|
175
272
|
<key>RunAtLoad</key><true/>
|
|
176
273
|
<key>KeepAlive</key>
|
|
177
274
|
<dict>
|
|
@@ -191,11 +288,11 @@ function systemdPath() {
|
|
|
191
288
|
async function installSystemd(spec, logger) {
|
|
192
289
|
const servicePath = systemdPath();
|
|
193
290
|
mkdirSync(path.dirname(servicePath), { recursive: true });
|
|
194
|
-
|
|
291
|
+
writePrivateServiceFile(servicePath, systemdUnit(spec));
|
|
195
292
|
const reload = await serviceRun("systemctl", ["--user", "daemon-reload"]);
|
|
196
293
|
const enable = await serviceRun("systemctl", ["--user", "enable", "machine-bridge-mcp.service"]);
|
|
197
294
|
const linger = await serviceRun("loginctl", ["enable-linger", os.userInfo().username]);
|
|
198
|
-
logger.info?.(
|
|
295
|
+
logger.info?.("Autostart installed.");
|
|
199
296
|
return { ok: reload.code === 0 && enable.code === 0, provider: "systemd", path: servicePath, reload, enable, linger };
|
|
200
297
|
}
|
|
201
298
|
|
|
@@ -204,7 +301,7 @@ async function uninstallSystemd(logger) {
|
|
|
204
301
|
const servicePath = systemdPath();
|
|
205
302
|
if (existsSync(servicePath)) rmSync(servicePath, { force: true });
|
|
206
303
|
await serviceRun("systemctl", ["--user", "daemon-reload"]);
|
|
207
|
-
logger.info?.(
|
|
304
|
+
logger.info?.("Autostart removed.");
|
|
208
305
|
return { ok: true, provider: "systemd", path: servicePath };
|
|
209
306
|
}
|
|
210
307
|
|
|
@@ -214,7 +311,7 @@ async function statusSystemd() {
|
|
|
214
311
|
return { ok: existsSync(servicePath), provider: "systemd", installed: existsSync(servicePath), path: servicePath, active: result.code === 0, detail: result.stdout || result.stderr };
|
|
215
312
|
}
|
|
216
313
|
|
|
217
|
-
function systemdUnit(spec) {
|
|
314
|
+
export function systemdUnit(spec) {
|
|
218
315
|
const execArgs = [spec.node, ...daemonArgs(spec)].map(systemdQuote).join(" ");
|
|
219
316
|
return `[Unit]
|
|
220
317
|
Description=Machine Bridge MCP daemon
|
|
@@ -223,6 +320,7 @@ After=network-online.target
|
|
|
223
320
|
[Service]
|
|
224
321
|
Type=simple
|
|
225
322
|
ExecStart=${execArgs}
|
|
323
|
+
Environment=${systemdQuote(`PATH=${spec.pathEnv}`)}
|
|
226
324
|
Restart=on-failure
|
|
227
325
|
RestartSec=5
|
|
228
326
|
StandardOutput=append:${spec.stdout}
|
package/src/local/shell.mjs
CHANGED
|
@@ -85,7 +85,7 @@ function finalizeOutput(value, truncated) {
|
|
|
85
85
|
return truncated > 0 ? `${value}\n\n[truncated ${truncated} bytes]` : value;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
function findWranglerCommand() {
|
|
89
89
|
const suffix = process.platform === "win32" ? ".cmd" : "";
|
|
90
90
|
const local = path.join(packageRoot, "node_modules", ".bin", `wrangler${suffix}`);
|
|
91
91
|
if (existsSync(local)) return { cmd: local, argsPrefix: [] };
|
|
@@ -0,0 +1,127 @@
|
|
|
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
|
+
const fingerprintValue = fingerprint.stdout.trim().split(/\s+/)[1] || "";
|
|
76
|
+
if (!/^SHA256:[A-Za-z0-9+/=]+$/.test(fingerprintValue)) throw new Error("SSH key fingerprint output is invalid");
|
|
77
|
+
return {
|
|
78
|
+
created,
|
|
79
|
+
privateKeyPath: resolve(privateKeyPath),
|
|
80
|
+
publicKeyPath: resolve(publicKeyPath),
|
|
81
|
+
privateMode: process.platform === "win32" ? null : `0${(privateInfo.mode & 0o777).toString(8)}`,
|
|
82
|
+
publicMode: process.platform === "win32" ? null : `0${(publicInfo.mode & 0o777).toString(8)}`,
|
|
83
|
+
fingerprint: fingerprintValue,
|
|
84
|
+
publicKeyType: publicLine.split(/\s+/, 1)[0],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function installNoReplace(source, target) {
|
|
89
|
+
try {
|
|
90
|
+
await link(source, target);
|
|
91
|
+
await unlink(source);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error?.code === "EXDEV") {
|
|
94
|
+
await copyFile(source, target, fsConstants.COPYFILE_EXCL);
|
|
95
|
+
await unlink(source);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (error?.code === "EEXIST") throw new Error(`refusing to replace existing SSH key file: ${target}`);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function secureKeyModes(privateKeyPath, publicKeyPath) {
|
|
104
|
+
if (process.platform === "win32") return;
|
|
105
|
+
await chmod(privateKeyPath, 0o600);
|
|
106
|
+
await chmod(publicKeyPath, 0o644);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function safeLstat(path) {
|
|
110
|
+
try { return await lstat(path); } catch (error) {
|
|
111
|
+
if (error?.code === "ENOENT") return null;
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function boundedComment(value) {
|
|
117
|
+
const comment = String(value || "").replace(/[\r\n\0\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, " ").replace(/\s+/g, " ").trim();
|
|
118
|
+
if (!comment) return "machine-mcp";
|
|
119
|
+
if (Buffer.byteLength(comment) > 256) throw new Error("SSH key comment exceeds 256 bytes");
|
|
120
|
+
return comment;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeRsaBits(value) {
|
|
124
|
+
const bits = Number.parseInt(String(value || "3072"), 10);
|
|
125
|
+
if (![2048, 3072, 4096].includes(bits)) throw new Error("RSA bits must be 2048, 3072, or 4096");
|
|
126
|
+
return bits;
|
|
127
|
+
}
|
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);
|
|
@@ -79,7 +80,7 @@ export function removeStateRoot(stateRoot = defaultStateRoot()) {
|
|
|
79
80
|
return true;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
+
function workspaceHash(workspace) {
|
|
83
84
|
const canonical = resolveWorkspace(workspace);
|
|
84
85
|
const identity = process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
85
86
|
return createHash("sha256").update(identity).digest("hex").slice(0, 24);
|
|
@@ -159,7 +160,7 @@ export function daemonLockPathForState(state) {
|
|
|
159
160
|
return lockPathForState(state, "daemon.lock");
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
|
|
163
|
+
function startupLockPathForState(state) {
|
|
163
164
|
return lockPathForState(state, "startup.lock");
|
|
164
165
|
}
|
|
165
166
|
|
|
@@ -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 {}
|
|
@@ -361,7 +362,7 @@ function assertValidStateMarker(marker, options = {}) {
|
|
|
361
362
|
|
|
362
363
|
function hasOnlyStateEntries(entries) {
|
|
363
364
|
const allowed = new Set([STATE_MARKER, "config.json", "profiles", "logs"]);
|
|
364
|
-
return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d
|
|
365
|
+
return entries.every((entry) => allowed.has(entry) || /^config\.json\.corrupt-\d+(?:-[a-f0-9]{8})?$/.test(entry));
|
|
365
366
|
}
|
|
366
367
|
|
|
367
368
|
function looksLikeSourceTree(root) {
|
|
@@ -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) {
|
|
@@ -483,7 +484,7 @@ function randomToken(prefix) {
|
|
|
483
484
|
return `${prefix}_${randomBytes(32).toString("base64url")}`;
|
|
484
485
|
}
|
|
485
486
|
|
|
486
|
-
|
|
487
|
+
function sha256(value) {
|
|
487
488
|
return createHash("sha256").update(String(value)).digest("hex");
|
|
488
489
|
}
|
|
489
490
|
|
|
@@ -507,7 +508,9 @@ export function redactState(state) {
|
|
|
507
508
|
if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
|
|
508
509
|
if (clone.resources && typeof clone.resources === "object") {
|
|
509
510
|
for (const value of Object.values(clone.resources)) {
|
|
510
|
-
if (value
|
|
511
|
+
if (!value || typeof value !== "object") continue;
|
|
512
|
+
if (value.path) value.path = "<local-resource-path>";
|
|
513
|
+
delete value.pathAliases;
|
|
511
514
|
}
|
|
512
515
|
}
|
|
513
516
|
return clone;
|
package/src/local/stdio.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import readline from "node:readline";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
2
|
import { randomBytes } from "node:crypto";
|
|
4
3
|
import { LocalDaemon } from "./daemon.mjs";
|
|
@@ -25,30 +24,26 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
25
24
|
let negotiatedVersion = MCP_PROTOCOL_VERSION;
|
|
26
25
|
let initialized = false;
|
|
27
26
|
|
|
28
|
-
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
29
27
|
const writes = new Set();
|
|
30
28
|
const send = (message) => {
|
|
31
29
|
if (message === null || message === undefined) return;
|
|
32
30
|
const line = `${JSON.stringify(message)}\n`;
|
|
33
31
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
34
32
|
process.stdout.write(line, (error) => error ? rejectPromise(error) : resolvePromise());
|
|
33
|
+
}).catch((error) => {
|
|
34
|
+
logger.warn("stdio output write failed", { error_class: classifyOperationalError(error) });
|
|
35
35
|
}).finally(() => writes.delete(promise));
|
|
36
36
|
writes.add(promise);
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
await new Promise((resolvePromise, rejectPromise) => {
|
|
50
|
-
rl.once("close", resolvePromise);
|
|
51
|
-
rl.once("error", rejectPromise);
|
|
39
|
+
await consumeBoundedJsonLines(process.stdin, {
|
|
40
|
+
maxLineBytes: MAX_LINE_BYTES,
|
|
41
|
+
onOversize() { send(rpcError(null, -32600, "JSON-RPC message exceeds maximum size")); },
|
|
42
|
+
onLine(line) {
|
|
43
|
+
void handleLine(line).catch((error) => {
|
|
44
|
+
logger.error("stdio request handler failed", { error_class: classifyOperationalError(error) });
|
|
45
|
+
});
|
|
46
|
+
},
|
|
52
47
|
});
|
|
53
48
|
for (const callId of pending.values()) runtime.cancelCall(callId, "stdio input closed");
|
|
54
49
|
await Promise.allSettled([...writes]);
|
|
@@ -111,6 +106,10 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
111
106
|
return;
|
|
112
107
|
}
|
|
113
108
|
if (message.method === "tools/call") {
|
|
109
|
+
if (!("id" in message) || message.id === null) {
|
|
110
|
+
send(rpcError(null, -32600, "tools/call requires a non-null request id"));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
114
113
|
const params = asObject(message.params);
|
|
115
114
|
const name = typeof params.name === "string" ? params.name : "";
|
|
116
115
|
if (!name) {
|
|
@@ -131,14 +130,12 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
131
130
|
const result = await runtime.executeTool(name, args, { callId });
|
|
132
131
|
send(rpcResult(message.id, toolResult(result)));
|
|
133
132
|
const durationMs = Date.now() - started;
|
|
134
|
-
|
|
135
|
-
else logger.debug("tool call completed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs });
|
|
133
|
+
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
134
|
} catch (error) {
|
|
137
|
-
const safeError = runtime.safeErrorMessage(error);
|
|
135
|
+
const safeError = runtime.safeErrorMessage(error, args);
|
|
138
136
|
send(rpcResult(message.id, toolResult({ error: safeError }, true)));
|
|
139
137
|
const durationMs = Date.now() - started;
|
|
140
|
-
logger.
|
|
141
|
-
logger.debug("tool call failure correlation", { call_id: callId.slice(0, 20) });
|
|
138
|
+
logger.debug("tool call failed", { call_id: callId.slice(0, 20), tool: name, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
142
139
|
} finally {
|
|
143
140
|
if (key) pending.delete(key);
|
|
144
141
|
runtime.finishCall(callId);
|
|
@@ -149,6 +146,67 @@ export async function runStdioServer({ workspace, policy, logLevel = "info", job
|
|
|
149
146
|
}
|
|
150
147
|
}
|
|
151
148
|
|
|
149
|
+
function consumeBoundedJsonLines(stream, { maxLineBytes, onLine, onOversize }) {
|
|
150
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
151
|
+
let chunks = [];
|
|
152
|
+
let bytes = 0;
|
|
153
|
+
let discarding = false;
|
|
154
|
+
let settled = false;
|
|
155
|
+
|
|
156
|
+
const resetLine = () => { chunks = []; bytes = 0; };
|
|
157
|
+
const emitLine = () => {
|
|
158
|
+
let line = bytes ? Buffer.concat(chunks, bytes) : Buffer.alloc(0);
|
|
159
|
+
if (line.length && line[line.length - 1] === 13) line = line.subarray(0, line.length - 1);
|
|
160
|
+
resetLine();
|
|
161
|
+
onLine(line.toString("utf8"));
|
|
162
|
+
};
|
|
163
|
+
const onData = (input) => {
|
|
164
|
+
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input);
|
|
165
|
+
let offset = 0;
|
|
166
|
+
while (offset < buffer.length) {
|
|
167
|
+
const newline = buffer.indexOf(10, offset);
|
|
168
|
+
const end = newline === -1 ? buffer.length : newline;
|
|
169
|
+
const segment = buffer.subarray(offset, end);
|
|
170
|
+
if (discarding) {
|
|
171
|
+
if (newline !== -1) discarding = false;
|
|
172
|
+
} else if (bytes + segment.length > maxLineBytes) {
|
|
173
|
+
resetLine();
|
|
174
|
+
onOversize();
|
|
175
|
+
if (newline === -1) discarding = true;
|
|
176
|
+
} else {
|
|
177
|
+
if (segment.length) chunks.push(segment);
|
|
178
|
+
bytes += segment.length;
|
|
179
|
+
if (newline !== -1) emitLine();
|
|
180
|
+
}
|
|
181
|
+
offset = newline === -1 ? buffer.length : newline + 1;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const cleanup = () => {
|
|
185
|
+
stream.off("data", onData);
|
|
186
|
+
stream.off("end", onEnd);
|
|
187
|
+
stream.off("close", onEnd);
|
|
188
|
+
stream.off("error", onError);
|
|
189
|
+
};
|
|
190
|
+
const onEnd = () => {
|
|
191
|
+
if (settled) return;
|
|
192
|
+
settled = true;
|
|
193
|
+
cleanup();
|
|
194
|
+
if (!discarding && bytes > 0) emitLine();
|
|
195
|
+
resolvePromise();
|
|
196
|
+
};
|
|
197
|
+
const onError = (error) => {
|
|
198
|
+
if (settled) return;
|
|
199
|
+
settled = true;
|
|
200
|
+
cleanup();
|
|
201
|
+
rejectPromise(error);
|
|
202
|
+
};
|
|
203
|
+
stream.on("data", onData);
|
|
204
|
+
stream.once("end", onEnd);
|
|
205
|
+
stream.once("close", onEnd);
|
|
206
|
+
stream.once("error", onError);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
152
210
|
function isJsonRpcMessage(value) {
|
|
153
211
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
154
212
|
if (value.jsonrpc !== "2.0") return false;
|