machine-bridge-mcp 0.6.2 → 0.8.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 +71 -0
- package/CONTRIBUTING.md +31 -0
- package/README.md +20 -17
- package/SECURITY.md +11 -7
- package/docs/ARCHITECTURE.md +14 -12
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +159 -0
- package/docs/LOGGING.md +65 -26
- package/docs/MANAGED_JOBS.md +14 -13
- package/docs/OPERATIONS.md +15 -6
- package/docs/PRIVACY.md +43 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +23 -1
- package/package.json +35 -8
- 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/cli.mjs +369 -272
- package/src/local/full-access-test.mjs +2 -2
- package/src/local/job-runner.mjs +73 -31
- package/src/local/log.mjs +28 -2
- package/src/local/managed-jobs.mjs +194 -125
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/relay-connection.mjs +495 -0
- package/src/local/{daemon.mjs → runtime.mjs} +188 -198
- package/src/local/secure-file.mjs +27 -0
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +41 -21
- package/src/local/state.mjs +7 -5
- package/src/local/stdio.mjs +157 -81
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +5 -1
- package/src/worker/index.ts +45 -21
- 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: [] };
|
package/src/local/ssh-key.mjs
CHANGED
|
@@ -7,44 +7,62 @@ import { run } from "./shell.mjs";
|
|
|
7
7
|
const KEY_TYPES = new Set(["ed25519", "rsa"]);
|
|
8
8
|
|
|
9
9
|
export async function generateSshKeyPair(options = {}) {
|
|
10
|
+
const request = normalizeKeyRequest(options);
|
|
11
|
+
await mkdir(request.parent, { recursive: true, mode: 0o700 });
|
|
12
|
+
if (process.platform !== "win32") await chmod(request.parent, 0o700);
|
|
13
|
+
|
|
14
|
+
const existing = await inspectExistingKeyFiles(request.privateKeyPath, request.publicKeyPath);
|
|
15
|
+
if (existing) {
|
|
16
|
+
await secureKeyModes(request.privateKeyPath, request.publicKeyPath);
|
|
17
|
+
return inspectSshKeyPair(request.privateKeyPath, request.publicKeyPath, false);
|
|
18
|
+
}
|
|
19
|
+
return createSshKeyPair(request);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeKeyRequest(options) {
|
|
10
23
|
const privateKeyPath = resolve(String(options.privateKeyPath || ""));
|
|
11
24
|
if (!privateKeyPath || privateKeyPath === resolve(".")) throw new Error("private key path is required");
|
|
12
|
-
const publicKeyPath = `${privateKeyPath}.pub`;
|
|
13
25
|
const type = String(options.type || "ed25519").toLowerCase();
|
|
14
26
|
if (!KEY_TYPES.has(type)) throw new Error("SSH key type must be ed25519 or rsa");
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
return {
|
|
28
|
+
privateKeyPath,
|
|
29
|
+
publicKeyPath: `${privateKeyPath}.pub`,
|
|
30
|
+
parent: dirname(privateKeyPath),
|
|
31
|
+
type,
|
|
32
|
+
bits: options.bits,
|
|
33
|
+
comment: boundedComment(options.comment || `machine-mcp:${basename(privateKeyPath)}`),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
19
36
|
|
|
37
|
+
async function inspectExistingKeyFiles(privateKeyPath, publicKeyPath) {
|
|
20
38
|
const privateInfo = await safeLstat(privateKeyPath);
|
|
21
39
|
const publicInfo = await safeLstat(publicKeyPath);
|
|
22
40
|
if (privateInfo?.isSymbolicLink() || publicInfo?.isSymbolicLink()) throw new Error("SSH key path must not be a symbolic link");
|
|
23
|
-
if (privateInfo
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
41
|
+
if (!privateInfo && !publicInfo) return false;
|
|
42
|
+
if (!privateInfo?.isFile() || !publicInfo?.isFile()) throw new Error("SSH key pair is incomplete or not a pair of regular files");
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
28
45
|
|
|
46
|
+
async function createSshKeyPair(request) {
|
|
29
47
|
const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
30
|
-
const tempPrivate = resolve(parent, `.${basename(privateKeyPath)}.mbm-${suffix}`);
|
|
48
|
+
const tempPrivate = resolve(request.parent, `.${basename(request.privateKeyPath)}.mbm-${suffix}`);
|
|
31
49
|
const tempPublic = `${tempPrivate}.pub`;
|
|
32
50
|
try {
|
|
33
|
-
const args = ["-q", "-t", type];
|
|
34
|
-
if (type === "rsa") args.push("-b", String(normalizeRsaBits(
|
|
35
|
-
args.push("-N", "", "-f", tempPrivate, "-C", comment);
|
|
51
|
+
const args = ["-q", "-t", request.type];
|
|
52
|
+
if (request.type === "rsa") args.push("-b", String(normalizeRsaBits(request.bits)));
|
|
53
|
+
args.push("-N", "", "-f", tempPrivate, "-C", request.comment);
|
|
36
54
|
const generated = await run("ssh-keygen", args, { capture: true, timeoutMs: 30_000, maxOutputBytes: 64 * 1024 });
|
|
37
55
|
if (generated.code !== 0) throw new Error("ssh-keygen failed");
|
|
38
56
|
await secureKeyModes(tempPrivate, tempPublic);
|
|
39
|
-
await installNoReplace(tempPrivate, privateKeyPath);
|
|
57
|
+
await installNoReplace(tempPrivate, request.privateKeyPath);
|
|
40
58
|
try {
|
|
41
|
-
await installNoReplace(tempPublic, publicKeyPath);
|
|
59
|
+
await installNoReplace(tempPublic, request.publicKeyPath);
|
|
42
60
|
} catch (error) {
|
|
43
|
-
await rm(privateKeyPath, { force: true });
|
|
61
|
+
await rm(request.privateKeyPath, { force: true });
|
|
44
62
|
throw error;
|
|
45
63
|
}
|
|
46
|
-
await secureKeyModes(privateKeyPath, publicKeyPath);
|
|
47
|
-
return inspectSshKeyPair(privateKeyPath, publicKeyPath, true);
|
|
64
|
+
await secureKeyModes(request.privateKeyPath, request.publicKeyPath);
|
|
65
|
+
return inspectSshKeyPair(request.privateKeyPath, request.publicKeyPath, true);
|
|
48
66
|
} finally {
|
|
49
67
|
await rm(tempPrivate, { force: true });
|
|
50
68
|
await rm(tempPublic, { force: true });
|
|
@@ -72,13 +90,15 @@ export async function inspectSshKeyPair(privateKeyPath, publicKeyPath = `${priva
|
|
|
72
90
|
timeoutMs: 15_000,
|
|
73
91
|
maxOutputBytes: 64 * 1024,
|
|
74
92
|
});
|
|
93
|
+
const fingerprintValue = fingerprint.stdout.trim().split(/\s+/)[1] || "";
|
|
94
|
+
if (!/^SHA256:[A-Za-z0-9+/=]+$/.test(fingerprintValue)) throw new Error("SSH key fingerprint output is invalid");
|
|
75
95
|
return {
|
|
76
96
|
created,
|
|
77
97
|
privateKeyPath: resolve(privateKeyPath),
|
|
78
98
|
publicKeyPath: resolve(publicKeyPath),
|
|
79
99
|
privateMode: process.platform === "win32" ? null : `0${(privateInfo.mode & 0o777).toString(8)}`,
|
|
80
100
|
publicMode: process.platform === "win32" ? null : `0${(publicInfo.mode & 0o777).toString(8)}`,
|
|
81
|
-
fingerprint:
|
|
101
|
+
fingerprint: fingerprintValue,
|
|
82
102
|
publicKeyType: publicLine.split(/\s+/, 1)[0],
|
|
83
103
|
};
|
|
84
104
|
}
|
|
@@ -112,7 +132,7 @@ async function safeLstat(path) {
|
|
|
112
132
|
}
|
|
113
133
|
|
|
114
134
|
function boundedComment(value) {
|
|
115
|
-
const comment = String(value || "").replace(/[\r\n\0]/g, " ").trim();
|
|
135
|
+
const comment = String(value || "").replace(/[\r\n\0\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, " ").replace(/\s+/g, " ").trim();
|
|
116
136
|
if (!comment) return "machine-mcp";
|
|
117
137
|
if (Buffer.byteLength(comment) > 256) throw new Error("SSH key comment exceeds 256 bytes");
|
|
118
138
|
return comment;
|
package/src/local/state.mjs
CHANGED
|
@@ -80,7 +80,7 @@ export function removeStateRoot(stateRoot = defaultStateRoot()) {
|
|
|
80
80
|
return true;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
function workspaceHash(workspace) {
|
|
84
84
|
const canonical = resolveWorkspace(workspace);
|
|
85
85
|
const identity = process.platform === "win32" ? canonical.toLowerCase() : canonical;
|
|
86
86
|
return createHash("sha256").update(identity).digest("hex").slice(0, 24);
|
|
@@ -160,7 +160,7 @@ export function daemonLockPathForState(state) {
|
|
|
160
160
|
return lockPathForState(state, "daemon.lock");
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
function startupLockPathForState(state) {
|
|
164
164
|
return lockPathForState(state, "startup.lock");
|
|
165
165
|
}
|
|
166
166
|
|
|
@@ -362,7 +362,7 @@ function assertValidStateMarker(marker, options = {}) {
|
|
|
362
362
|
|
|
363
363
|
function hasOnlyStateEntries(entries) {
|
|
364
364
|
const allowed = new Set([STATE_MARKER, "config.json", "profiles", "logs"]);
|
|
365
|
-
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));
|
|
366
366
|
}
|
|
367
367
|
|
|
368
368
|
function looksLikeSourceTree(root) {
|
|
@@ -484,7 +484,7 @@ function randomToken(prefix) {
|
|
|
484
484
|
return `${prefix}_${randomBytes(32).toString("base64url")}`;
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
-
|
|
487
|
+
function sha256(value) {
|
|
488
488
|
return createHash("sha256").update(String(value)).digest("hex");
|
|
489
489
|
}
|
|
490
490
|
|
|
@@ -508,7 +508,9 @@ export function redactState(state) {
|
|
|
508
508
|
if (clone.worker?.oauthTokenVersion) clone.worker.oauthTokenVersion = "<redacted>";
|
|
509
509
|
if (clone.resources && typeof clone.resources === "object") {
|
|
510
510
|
for (const value of Object.values(clone.resources)) {
|
|
511
|
-
if (value
|
|
511
|
+
if (!value || typeof value !== "object") continue;
|
|
512
|
+
if (value.path) value.path = "<local-resource-path>";
|
|
513
|
+
delete value.pathAliases;
|
|
512
514
|
}
|
|
513
515
|
}
|
|
514
516
|
return clone;
|