machine-bridge-mcp 0.7.1 → 0.8.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.
@@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
4
4
  import { chmod, link, lstat, mkdir, open, opendir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import path, { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
- import WebSocket from "ws";
7
+ import { RelayConnection } from "./relay-connection.mjs";
8
8
  import { applyUpdateHunks, parsePatchEnvelope } from "./patch.mjs";
9
9
  import { executionEnv, workspaceShellCommand } from "./shell.mjs";
10
10
  import { MAX_COMMAND_BYTES, ProcessSessionManager, terminateProcessTree, validateArgv } from "./process-sessions.mjs";
@@ -24,11 +24,46 @@ const MAX_WALK_ENTRIES = 200_000;
24
24
  const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
25
25
  const SLOW_TOOL_CALL_MS = 30_000;
26
26
 
27
- export class LocalDaemon {
28
- constructor({ workerUrl = "", secret = "", workspace, policy, logger = console, onSuperseded = null, jobRoot = "", resources = {}, resourceStatePath = "", recoverJobs = true }) {
29
- this.workerUrl = workerUrl ? normalizeWorkerUrl(workerUrl) : "";
30
- if (this.workerUrl && (typeof secret !== "string" || secret.length < 16)) throw new Error("daemon secret is missing or too short");
31
- this.secret = secret || "";
27
+ const RUNTIME_TOOL_HANDLERS = Object.freeze({
28
+ server_info: (runtime) => runtime.runtimeInfo(),
29
+ project_overview: (runtime, _args, context) => runtime.projectOverview(context),
30
+ list_roots: (runtime) => runtime.listRoots(),
31
+ list_dir: (runtime, args, context) => runtime.listDir(args.path || ".", context),
32
+ list_files: (runtime, args, context) => runtime.listFiles(args.path || ".", clampInt(args.max_files, 1000, 1, 10000), context),
33
+ read_file: (runtime, args, context) => runtime.readFile(args, context),
34
+ view_image: (runtime, args, context) => runtime.viewImage(args, context),
35
+ write_file: (runtime, args, context) => runtime.writeFile(args, context),
36
+ edit_file: (runtime, args, context) => runtime.editFile(args, context),
37
+ apply_patch: (runtime, args, context) => runtime.applyPatch(args, context),
38
+ search_text: (runtime, args, context) => runtime.searchText(args, context),
39
+ git_status: (runtime, args, context) => runtime.gitStatus(args, context),
40
+ git_diff: (runtime, args, context) => runtime.gitDiff(args, context),
41
+ git_log: (runtime, args, context) => runtime.gitLog(args, context),
42
+ git_show: (runtime, args, context) => runtime.gitShow(args, context),
43
+ diagnose_runtime: (runtime, _args, context) => runtime.diagnoseRuntime(context),
44
+ list_local_resources: (runtime) => runtime.managedJobManager.listResources(),
45
+ generate_ssh_key_resource: (runtime, args, context) => runtime.generateSshKeyResource(args, context),
46
+ stage_job: (runtime, args) => runtime.managedJobManager.stage(args),
47
+ start_job: (runtime, args) => runtime.managedJobManager.start(args),
48
+ list_jobs: (runtime, args) => runtime.managedJobManager.list(args),
49
+ read_job: (runtime, args) => runtime.managedJobManager.read(args),
50
+ cancel_job: (runtime, args) => runtime.managedJobManager.cancel(args),
51
+ run_process: (runtime, args, context) => runtime.runDirectProcess(args, context),
52
+ start_process: (runtime, args, context) => runtime.processSessionManager.start(args, context),
53
+ read_process: (runtime, args, context) => runtime.processSessionManager.read(args, context),
54
+ write_process: (runtime, args, context) => runtime.processSessionManager.write(args, context),
55
+ kill_process: (runtime, args, context) => runtime.processSessionManager.kill(args, context),
56
+ exec_command: (runtime, args, context) => runtime.execCommand(args.command, clampInt(args.timeout_seconds, 120, 1, 600), context),
57
+ });
58
+
59
+ export function runtimeToolHandlerNames() {
60
+ return Object.keys(RUNTIME_TOOL_HANDLERS);
61
+ }
62
+
63
+ export class LocalRuntime {
64
+ constructor({ workerUrl = "", secret = "", expectedRelayVersion = "", workspace, policy, logger = console, onSuperseded = null, onFatal = null, jobRoot = "", resources = {}, resourceStatePath = "", recoverJobs = true }) {
65
+ const remoteWorkerUrl = workerUrl ? String(workerUrl) : "";
66
+ const remoteSecret = secret || "";
32
67
  this.workspaceInput = resolve(workspace || process.cwd());
33
68
  this.workspace = realpathSync.native ? realpathSync.native(this.workspaceInput) : realpathSync(this.workspaceInput);
34
69
  this.workspaceCanonicalPromise = null;
@@ -36,18 +71,10 @@ export class LocalDaemon {
36
71
  this.logger = logger;
37
72
  this.onSuperseded = typeof onSuperseded === "function" ? onSuperseded : null;
38
73
  this.resourceStatePath = resourceStatePath ? resolve(resourceStatePath) : "";
39
- this.closed = false;
40
- this.ws = null;
41
- this.heartbeat = null;
42
- this.reconnectTimer = null;
43
- this.connectedOnce = null;
44
- this.connectedOnceResolve = null;
45
- this.connectedOnceReject = null;
46
74
  this.activeToolCalls = 0;
47
75
  this.activeProcesses = new Set();
48
76
  this.callProcesses = new Map();
49
77
  this.cancelledCalls = new Set();
50
- this.reconnectAttempt = 0;
51
78
  this.mutationQueue = Promise.resolve();
52
79
  this.runtimeDir = createRuntimeDir();
53
80
  if (typeof jobRoot !== "string" || !jobRoot.trim()) throw new Error("persistent managed-job root is required");
@@ -74,6 +101,12 @@ export class LocalDaemon {
74
101
  displayPath: (value) => this.displayPath(value),
75
102
  throwIfCancelled: (context) => this.throwIfCancelled(context),
76
103
  });
104
+ this.relay = createRelayConnection(this, {
105
+ workerUrl: remoteWorkerUrl,
106
+ secret: remoteSecret,
107
+ expectedVersion: expectedRelayVersion,
108
+ onFatal,
109
+ });
77
110
  }
78
111
 
79
112
  tools() {
@@ -107,6 +140,9 @@ export class LocalDaemon {
107
140
  },
108
141
  tools: ["server_info", ...this.tools()],
109
142
  observability: {
143
+ relay_readiness: "authenticated-hello-acknowledged",
144
+ brief_relay_interruptions: "debug-only",
145
+ raw_transport_details: "debug-only",
110
146
  per_tool_events: "debug-only",
111
147
  default_logs_include_tool_failures: false,
112
148
  tool_arguments_or_results_logged: false,
@@ -122,112 +158,19 @@ export class LocalDaemon {
122
158
  }
123
159
 
124
160
  start() {
125
- if (!this.workerUrl || !this.secret) throw new Error("remote daemon start requires a Worker URL and daemon secret");
126
- this.closed = false;
127
- this.connectedOnce = new Promise((resolvePromise, rejectPromise) => {
128
- this.connectedOnceResolve = resolvePromise;
129
- this.connectedOnceReject = rejectPromise;
130
- });
131
- this.connect();
132
- return this.connectedOnce;
161
+ if (!this.relay) throw new Error("remote daemon start requires a Worker URL and daemon secret");
162
+ return this.relay.start();
133
163
  }
134
164
 
135
165
  stop() {
136
- this.closed = true;
137
- if (this.heartbeat) clearInterval(this.heartbeat);
138
- this.heartbeat = null;
139
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
140
- this.reconnectTimer = null;
141
- this.ws?.close();
142
- this.ws = null;
166
+ this.relay?.stop();
143
167
  this.terminateActiveProcesses("SIGKILL");
144
168
  this.processSessionManager.clear();
145
- this.reconnectAttempt = 0;
146
169
  rmSync(this.runtimeDir, { recursive: true, force: true });
147
170
  }
148
171
 
149
- connect() {
150
- if (this.closed) return;
151
- const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
152
- this.logger.debug?.("connecting to remote relay", { endpoint: redactUrl(wsUrl) });
153
- const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret }, maxPayload: MAX_WS_MESSAGE_BYTES });
154
- this.ws = socket;
155
-
156
- socket.on("open", () => {
157
- if (this.ws !== socket || this.closed) {
158
- socket.close();
159
- return;
160
- }
161
- this.reconnectAttempt = 0;
162
- this.logger.info?.("remote relay connected");
163
- this.send({ type: "hello", tools: this.tools(), policy: this.policy, protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS });
164
- if (this.connectedOnceResolve) {
165
- this.connectedOnceResolve(true);
166
- this.connectedOnceResolve = null;
167
- this.connectedOnceReject = null;
168
- }
169
- if (this.heartbeat) clearInterval(this.heartbeat);
170
- this.heartbeat = setInterval(() => this.send({ type: "heartbeat", ts: Date.now() }), 25_000);
171
- this.heartbeat.unref?.();
172
- });
173
-
174
- socket.on("message", data => {
175
- const raw = String(data);
176
- if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
177
- this.logger.warn?.("oversized websocket message rejected");
178
- return;
179
- }
180
- void this.handleMessage(raw).catch(error => {
181
- this.logger.error?.("daemon message handler failed", { error_class: classifyOperationalError(error) });
182
- });
183
- });
184
-
185
- socket.on("close", (code, reason) => {
186
- if (this.ws !== socket) return;
187
- this.ws = null;
188
- this.terminateActiveProcesses("SIGTERM", true);
189
- if (this.heartbeat) clearInterval(this.heartbeat);
190
- this.heartbeat = null;
191
- const reasonText = String(reason || "").slice(0, 128);
192
- const fields = { code, reason: reasonText };
193
- if (isSupersededClose(code, reasonText)) {
194
- this.closed = true;
195
- this.terminateActiveProcesses("SIGKILL");
196
- this.processSessionManager.clear();
197
- this.logger.warn?.("daemon connection permanently superseded", fields);
198
- queueMicrotask(() => {
199
- try { this.onSuperseded?.(); } catch (error) {
200
- this.logger.error?.("daemon superseded callback failed", { error_class: classifyOperationalError(error) });
201
- }
202
- });
203
- return;
204
- }
205
- if (this.closed) this.logger.debug?.("remote relay closed", fields);
206
- else this.logger.warn?.("remote relay disconnected", fields);
207
- if (!this.closed) {
208
- const delay = reconnectDelay(this.reconnectAttempt++);
209
- this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay });
210
- this.reconnectTimer = setTimeout(() => this.connect(), delay);
211
- this.reconnectTimer.unref?.();
212
- }
213
- });
214
-
215
- socket.on("error", error => {
216
- if (this.ws !== socket) return;
217
- if (this.closed) this.logger.debug?.("remote relay closed during shutdown", { error_class: classifyOperationalError(error) });
218
- else this.logger.error?.("remote relay error", { error_class: classifyOperationalError(error) });
219
- });
220
- }
221
-
222
172
  send(value) {
223
- if (this.ws?.readyState !== WebSocket.OPEN) return false;
224
- try {
225
- this.ws.send(JSON.stringify(value));
226
- return true;
227
- } catch (error) {
228
- this.logger.warn?.("remote relay send failed", { error_class: classifyOperationalError(error) });
229
- return false;
230
- }
173
+ return this.relay?.send(value) === true;
231
174
  }
232
175
 
233
176
  async handleMessage(raw) {
@@ -236,50 +179,63 @@ export class LocalDaemon {
236
179
  this.logger.warn?.("invalid websocket JSON");
237
180
  return;
238
181
  }
239
- if (message.type === "welcome" || message.type === "hello_ack" || message.type === "pong") return;
240
- if (message.type === "cancel_call") {
241
- if (typeof message.id === "string") this.cancelCall(message.id, "remote cancellation");
242
- return;
243
- }
182
+ if (this.handleRelayControlMessage(message)) return;
244
183
  if (message.type !== "tool_call") {
245
184
  this.logger.warn?.("unknown websocket message", { type: String(message.type || "") });
246
185
  return;
247
186
  }
187
+ await this.handleRelayToolCall(message);
188
+ }
189
+
190
+ handleRelayControlMessage(message) {
191
+ if (message.type === "welcome") {
192
+ this.relay?.observeWelcome(message);
193
+ return true;
194
+ }
195
+ if (message.type === "hello_ack") {
196
+ this.relay?.acknowledge(message);
197
+ return true;
198
+ }
199
+ if (message.type === "pong") return true;
200
+ if (message.type === "cancel_call") {
201
+ if (typeof message.id === "string") this.cancelCall(message.id, "remote cancellation");
202
+ return true;
203
+ }
204
+ return false;
205
+ }
248
206
 
249
- const id = typeof message.id === "string" ? message.id : "";
250
- const tool = typeof message.tool === "string" ? message.tool : "";
251
- const argumentsValue = message.arguments === undefined ? {} : message.arguments;
252
- if (!id || id.length > 256 || !tool || tool.length > 128 || !isPlainRecord(argumentsValue)) {
207
+ async handleRelayToolCall(message) {
208
+ const envelope = normalizeRelayToolCall(message);
209
+ if (!envelope.ok) {
253
210
  this.logger.warn?.("invalid tool_call envelope");
254
- if (id && id.length <= 256) this.send({ type: "tool_result", id, ok: false, error: { message: "invalid tool_call envelope" } });
211
+ if (envelope.id) this.send({ type: "tool_result", id: envelope.id, ok: false, error: { message: "invalid tool_call envelope" } });
255
212
  return;
256
213
  }
257
214
  if (this.activeToolCalls >= MAX_CONCURRENT_TOOL_CALLS) {
258
- this.send({ type: "tool_result", id, ok: false, error: { message: "too many concurrent tool calls" } });
215
+ this.send({ type: "tool_result", id: envelope.id, ok: false, error: { message: "too many concurrent tool calls" } });
259
216
  return;
260
217
  }
261
218
 
262
- const relayTimeoutMs = clampInt(message.timeout_ms, 60_000, 1000, 610_000);
263
- const deadline = setTimeout(() => this.cancelCall(id, "relay deadline exceeded"), relayTimeoutMs);
219
+ const deadline = setTimeout(() => this.cancelCall(envelope.id, "relay deadline exceeded"), envelope.timeoutMs);
264
220
  deadline.unref?.();
265
221
  this.activeToolCalls += 1;
266
222
  const started = Date.now();
267
- this.logger.debug?.("tool call started", { call_id: shortCallId(id), tool });
223
+ this.logger.debug?.("tool call started", { call_id: shortCallId(envelope.id), tool: envelope.tool });
268
224
  try {
269
- const result = await this.executeTool(tool, argumentsValue, { callId: id });
270
- if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
271
- this.send({ type: "tool_result", id, ok: true, result });
225
+ const result = await this.executeTool(envelope.tool, envelope.arguments, { callId: envelope.id });
226
+ if (this.cancelledCalls.has(envelope.id)) throw new Error("tool call cancelled");
227
+ this.send({ type: "tool_result", id: envelope.id, ok: true, result });
272
228
  const durationMs = Date.now() - started;
273
- this.logger.debug?.(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
229
+ this.logger.debug?.(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: shortCallId(envelope.id), tool: envelope.tool, duration_ms: durationMs });
274
230
  } catch (error) {
275
- const safeError = this.safeErrorMessage(error, argumentsValue);
276
- this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
231
+ const safeError = this.safeErrorMessage(error, envelope.arguments);
232
+ this.send({ type: "tool_result", id: envelope.id, ok: false, error: { message: safeError } });
277
233
  const durationMs = Date.now() - started;
278
- this.logger.debug?.("tool call failed", { call_id: shortCallId(id), tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
234
+ this.logger.debug?.("tool call failed", { call_id: shortCallId(envelope.id), tool: envelope.tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
279
235
  } finally {
280
236
  clearTimeout(deadline);
281
237
  this.activeToolCalls -= 1;
282
- this.finishCall(id);
238
+ this.finishCall(envelope.id);
283
239
  }
284
240
  }
285
241
 
@@ -305,38 +261,9 @@ export class LocalDaemon {
305
261
 
306
262
  async executeTool(tool, args, context = {}) {
307
263
  if (!["server_info", ...this.tools()].includes(tool)) throw new Error(`tool disabled or unknown: ${tool}`);
308
- switch (tool) {
309
- case "server_info": return this.runtimeInfo();
310
- case "project_overview": return this.projectOverview(context);
311
- case "list_roots": return this.listRoots();
312
- case "list_dir": return this.listDir(args.path || ".", context);
313
- case "list_files": return this.listFiles(args.path || ".", clampInt(args.max_files, 1000, 1, 10000), context);
314
- case "read_file": return this.readFile(args, context);
315
- case "view_image": return this.viewImage(args, context);
316
- case "write_file": return this.writeFile(args, context);
317
- case "edit_file": return this.editFile(args, context);
318
- case "apply_patch": return this.applyPatch(args, context);
319
- case "search_text": return this.searchText(args, context);
320
- case "git_status": return this.gitStatus(args, context);
321
- case "git_diff": return this.gitDiff(args, context);
322
- case "git_log": return this.gitLog(args, context);
323
- case "git_show": return this.gitShow(args, context);
324
- case "diagnose_runtime": return this.diagnoseRuntime(context);
325
- case "list_local_resources": return this.managedJobManager.listResources();
326
- case "generate_ssh_key_resource": return this.generateSshKeyResource(args, context);
327
- case "stage_job": return this.managedJobManager.stage(args);
328
- case "start_job": return this.managedJobManager.start(args);
329
- case "list_jobs": return this.managedJobManager.list(args);
330
- case "read_job": return this.managedJobManager.read(args);
331
- case "cancel_job": return this.managedJobManager.cancel(args);
332
- case "run_process": return this.runDirectProcess(args, context);
333
- case "start_process": return this.processSessionManager.start(args, context);
334
- case "read_process": return this.processSessionManager.read(args, context);
335
- case "write_process": return this.processSessionManager.write(args, context);
336
- case "kill_process": return this.processSessionManager.kill(args, context);
337
- case "exec_command": return this.execCommand(args.command, clampInt(args.timeout_seconds, 120, 1, 600), context);
338
- default: throw new Error(`unknown daemon tool: ${tool}`);
339
- }
264
+ const handler = RUNTIME_TOOL_HANDLERS[tool];
265
+ if (!handler) throw new Error(`runtime handler is missing for tool: ${tool}`);
266
+ return handler(this, args, context);
340
267
  }
341
268
 
342
269
  async projectOverview(context = {}) {
@@ -1009,25 +936,6 @@ function stateRootFromProfileStatePath(statePath) {
1009
936
  return dirname(profilesDir);
1010
937
  }
1011
938
 
1012
- function normalizeWorkerUrl(value) {
1013
- let url;
1014
- try { url = new URL(String(value || "")); } catch { throw new Error("invalid Worker URL"); }
1015
- if (url.protocol !== "https:") throw new Error("Worker URL must use HTTPS");
1016
- if (url.username || url.password) throw new Error("Worker URL must not contain credentials");
1017
- if (url.pathname !== "/" || url.search || url.hash) throw new Error("Worker URL must be an origin without a path, query, or fragment");
1018
- return url.origin;
1019
- }
1020
-
1021
- export function isSupersededClose(code, reason) {
1022
- return Number(code) === 1012 && String(reason || "") === "replaced by authenticated daemon";
1023
- }
1024
-
1025
- function reconnectDelay(attempt) {
1026
- const base = Math.min(3000 * (2 ** Math.min(attempt, 4)), 60_000);
1027
- return base + Math.floor(Math.random() * 1000);
1028
- }
1029
-
1030
-
1031
939
  function assertContainedPath(root, target) {
1032
940
  const rel = relative(root, target);
1033
941
  if (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))) return;
@@ -1213,6 +1121,59 @@ function detectImageMime(buffer) {
1213
1121
  return "";
1214
1122
  }
1215
1123
 
1124
+ function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, onFatal }) {
1125
+ if (!workerUrl) return null;
1126
+ return new RelayConnection({
1127
+ workerUrl,
1128
+ secret,
1129
+ logger: runtime.logger,
1130
+ maxPayload: MAX_WS_MESSAGE_BYTES,
1131
+ expectedServer: SERVER_NAME,
1132
+ expectedVersion: String(expectedVersion || ""),
1133
+ helloMessage: () => ({
1134
+ type: "hello",
1135
+ tools: runtime.tools(),
1136
+ policy: runtime.policy,
1137
+ protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
1138
+ }),
1139
+ onMessage: (data) => handleRelayData(runtime, data),
1140
+ onDisconnect: () => runtime.terminateActiveProcesses("SIGTERM", true),
1141
+ onSuperseded: () => {
1142
+ runtime.terminateActiveProcesses("SIGKILL");
1143
+ runtime.processSessionManager.clear();
1144
+ runtime.onSuperseded?.();
1145
+ },
1146
+ onFatal: (error) => {
1147
+ runtime.terminateActiveProcesses("SIGKILL");
1148
+ runtime.processSessionManager.clear();
1149
+ onFatal?.(error);
1150
+ },
1151
+ });
1152
+ }
1153
+
1154
+ function handleRelayData(runtime, data) {
1155
+ const raw = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
1156
+ if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
1157
+ runtime.logger.warn?.("oversized websocket message rejected");
1158
+ return;
1159
+ }
1160
+ return runtime.handleMessage(raw);
1161
+ }
1162
+
1163
+ function normalizeRelayToolCall(message) {
1164
+ const id = typeof message.id === "string" && message.id.length <= 256 ? message.id : "";
1165
+ const tool = typeof message.tool === "string" && message.tool.length <= 128 ? message.tool : "";
1166
+ const argumentsValue = message.arguments === undefined ? {} : message.arguments;
1167
+ if (!id || !tool || !isPlainRecord(argumentsValue)) return { ok: false, id };
1168
+ return {
1169
+ ok: true,
1170
+ id,
1171
+ tool,
1172
+ arguments: argumentsValue,
1173
+ timeoutMs: clampInt(message.timeout_ms, 60_000, 1000, 610_000),
1174
+ };
1175
+ }
1176
+
1216
1177
  function isPlainRecord(value) {
1217
1178
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
1218
1179
  }
@@ -1257,7 +1218,3 @@ function replacePathPrefix(message, pathValue, replacement) {
1257
1218
  function shortCallId(value) {
1258
1219
  return String(value || "").slice(0, 20);
1259
1220
  }
1260
-
1261
- function redactUrl(value) {
1262
- try { return new URL(value).origin; } catch { return "<invalid-url>"; }
1263
- }
@@ -0,0 +1,27 @@
1
+ import { closeSync, constants as fsConstants, fstatSync, openSync, readSync } from "node:fs";
2
+
3
+ export function readBoundedRegularFileSync(file, maxBytes) {
4
+ return readBoundedRegularFileWithInfoSync(file, maxBytes).buffer;
5
+ }
6
+
7
+ export function readBoundedRegularFileWithInfoSync(file, maxBytes) {
8
+ const limit = Number(maxBytes);
9
+ if (!Number.isSafeInteger(limit) || limit < 0) throw new Error("maximum file size must be a non-negative safe integer");
10
+ const flags = Number(fsConstants.O_RDONLY) | Number(fsConstants.O_NOFOLLOW || 0);
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");
15
+ if (info.size > limit) throw new Error(`file exceeds ${limit} bytes`);
16
+ const buffer = Buffer.alloc(info.size);
17
+ let offset = 0;
18
+ while (offset < buffer.length) {
19
+ const count = readSync(fd, buffer, offset, buffer.length - offset, offset);
20
+ if (!count) break;
21
+ offset += count;
22
+ }
23
+ return { buffer: buffer.subarray(0, offset), info };
24
+ } finally {
25
+ closeSync(fd);
26
+ }
27
+ }
@@ -4,10 +4,12 @@ import path from "node:path";
4
4
  import { run } from "./shell.mjs";
5
5
  import { ensureOwnerOnlyDir, expandHome, ownerOnlyFile } from "./state.mjs";
6
6
  import { replaceFileSync } from "./atomic-fs.mjs";
7
+ import { readBoundedRegularFileSync } from "./secure-file.mjs";
7
8
 
8
9
  const LABEL = "dev.machine-bridge-mcp.daemon";
9
10
  const WINDOWS_TASK = "MachineBridgeMCP";
10
11
  const SERVICE_COMMAND_OUTPUT_BYTES = 64 * 1024;
12
+ const AUTOSTART_LOG_SCHEMA_VERSION = 2;
11
13
 
12
14
  function serviceRun(command, args) {
13
15
  return run(command, args, {
@@ -53,33 +55,67 @@ export function trimAutostartLogs(stateRoot, options = {}) {
53
55
  const root = expandHome(stateRoot);
54
56
  const maxBytes = Number.isFinite(Number(options.maxBytes)) ? Math.max(1024, Number(options.maxBytes)) : 2 * 1024 * 1024;
55
57
  const keepBytes = Math.min(maxBytes, Number.isFinite(Number(options.keepBytes)) ? Math.max(1024, Number(options.keepBytes)) : 1024 * 1024);
58
+ const schemaVersion = String(Number.isInteger(Number(options.schemaVersion)) && Number(options.schemaVersion) > 0
59
+ ? Number(options.schemaVersion)
60
+ : AUTOSTART_LOG_SCHEMA_VERSION);
56
61
  const logs = path.join(root, "logs");
62
+ const schemaFile = path.join(logs, ".log-schema");
63
+ const migrate = readLogSchema(schemaFile) !== schemaVersion;
64
+ let migrationComplete = true;
65
+
57
66
  for (const name of ["daemon.out.log", "daemon.err.log"]) {
58
67
  const file = path.join(logs, name);
59
68
  let fd;
60
69
  try {
61
70
  if (!existsSync(file)) continue;
62
71
  const before = lstatSync(file);
63
- if (before.isSymbolicLink() || !before.isFile()) continue;
72
+ if (before.isSymbolicLink() || !before.isFile()) {
73
+ if (migrate) migrationComplete = false;
74
+ continue;
75
+ }
64
76
  const noFollow = Number(fsConstants.O_NOFOLLOW || 0);
65
77
  fd = openSync(file, Number(fsConstants.O_RDWR) | noFollow);
66
78
  const info = fstatSync(fd);
67
- if (!info.isFile()) continue;
68
- if (info.size > maxBytes) {
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);
79
+ if (!info.isFile()) {
80
+ if (migrate) migrationComplete = false;
81
+ continue;
82
+ }
83
+ if (migrate && info.size > 0) {
84
+ const legacy = readLogTail(fd, info.size, maxBytes);
85
+ if (legacy.length) writePrivateServiceFile(path.join(logs, legacyLogName(name)), legacy);
86
+ ftruncateSync(fd, 0);
87
+ } else if (info.size > maxBytes) {
88
+ const tail = readLogTail(fd, info.size, keepBytes);
73
89
  ftruncateSync(fd, 0);
74
90
  if (tail.length) writeSync(fd, tail, 0, tail.length, 0);
75
91
  }
76
92
  try { chmodSync(file, 0o600); } catch {}
77
93
  } catch {
94
+ if (migrate) migrationComplete = false;
78
95
  // Operational log maintenance is best effort and must not stop startup.
79
96
  } finally {
80
97
  if (fd !== undefined) try { closeSync(fd); } catch {}
81
98
  }
82
99
  }
100
+
101
+ if (migrate && migrationComplete) {
102
+ try { writePrivateServiceFile(schemaFile, `${schemaVersion}\n`); } catch {}
103
+ }
104
+ }
105
+
106
+ function readLogSchema(file) {
107
+ try { return readBoundedRegularFileSync(file, 64).toString("utf8").trim(); } catch { return ""; }
108
+ }
109
+
110
+ function readLogTail(fd, size, limit) {
111
+ const length = Math.min(limit, size);
112
+ const buffer = Buffer.alloc(length);
113
+ readSync(fd, buffer, 0, length, Math.max(0, size - length));
114
+ return lineSafeTail(buffer);
115
+ }
116
+
117
+ function legacyLogName(name) {
118
+ return name.endsWith(".log") ? `${name.slice(0, -4)}.legacy.log` : `${name}.legacy`;
83
119
  }
84
120
 
85
121
  function lineSafeTail(buffer) {
@@ -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
- 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);
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 || 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
- }
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(options.bits)));
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 });