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.
@@ -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
- const info = statSync(file);
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 fd = openSync(file, "r");
62
- let buffer;
63
- try {
64
- const current = fstatSync(fd);
65
- const length = Math.min(keepBytes, current.size);
66
- buffer = Buffer.alloc(length);
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: path.resolve(entryScript),
100
- node: process.execPath,
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
- writeFileSync(plistPath, launchdPlist({ args, stdout: spec.stdout, stderr: spec.stderr }), { mode: 0o644 });
127
- logger.info?.(`Autostart installed for next login: ${plistPath}`);
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?.(`Autostart removed: ${plistPath}`);
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
- writeFileSync(servicePath, systemdUnit(spec), { mode: 0o644 });
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?.(`Autostart installed: ${servicePath}`);
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?.(`Autostart removed: ${servicePath}`);
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}
@@ -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
- export function findWranglerCommand() {
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
+ }
@@ -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);
@@ -79,7 +80,7 @@ export function removeStateRoot(stateRoot = defaultStateRoot()) {
79
80
  return true;
80
81
  }
81
82
 
82
- export function workspaceHash(workspace) {
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
- export function startupLockPathForState(state) {
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
- renameSync(filePath, backupPath);
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+$/.test(entry));
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
- renameSync(tempPath, filePath);
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
- export function sha256(value) {
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 && typeof value === "object" && value.path) value.path = "<local-resource-path>";
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;
@@ -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
- rl.on("line", (line) => {
40
- if (Buffer.byteLength(line) > MAX_LINE_BYTES) {
41
- send(rpcError(null, -32600, "JSON-RPC message exceeds maximum size"));
42
- return;
43
- }
44
- void handleLine(line).catch((error) => {
45
- logger.error("stdio request handler failed", { error_class: classifyOperationalError(error) });
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
- 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 });
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.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) });
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;