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.
- package/CHANGELOG.md +41 -0
- package/CONTRIBUTING.md +8 -0
- package/README.md +5 -5
- package/docs/ARCHITECTURE.md +8 -6
- package/docs/ENGINEERING.md +160 -0
- package/docs/LOGGING.md +65 -28
- package/docs/OPERATIONS.md +7 -1
- package/docs/PRIVACY.md +6 -0
- package/docs/TESTING.md +9 -3
- package/package.json +8 -4
- package/src/local/cli.mjs +339 -280
- package/src/local/full-access-test.mjs +2 -2
- package/src/local/job-runner.mjs +3 -18
- package/src/local/log.mjs +2 -0
- package/src/local/managed-jobs.mjs +57 -77
- package/src/local/relay-connection.mjs +517 -0
- package/src/local/{daemon.mjs → runtime.mjs} +145 -188
- package/src/local/secure-file.mjs +27 -0
- package/src/local/service.mjs +43 -7
- package/src/local/ssh-key.mjs +37 -19
- package/src/local/stdio.mjs +86 -70
- package/src/worker/index.ts +11 -4
|
@@ -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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
126
|
-
this.
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
+
}
|
package/src/local/service.mjs
CHANGED
|
@@ -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())
|
|
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())
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
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) {
|
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 });
|