machine-bridge-mcp 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/CONTRIBUTING.md +31 -0
- package/README.md +20 -17
- package/SECURITY.md +11 -7
- package/docs/ARCHITECTURE.md +14 -12
- package/docs/CLIENTS.md +2 -2
- package/docs/ENGINEERING.md +159 -0
- package/docs/LOGGING.md +65 -26
- package/docs/MANAGED_JOBS.md +14 -13
- package/docs/OPERATIONS.md +15 -6
- package/docs/PRIVACY.md +43 -0
- package/docs/RELEASING.md +9 -4
- package/docs/TESTING.md +23 -1
- package/package.json +35 -8
- package/scripts/network-retry.mjs +47 -0
- package/scripts/privacy-check.mjs +177 -0
- package/scripts/release-impact-check.mjs +78 -0
- package/src/local/cli.mjs +369 -272
- package/src/local/full-access-test.mjs +2 -2
- package/src/local/job-runner.mjs +73 -31
- package/src/local/log.mjs +28 -2
- package/src/local/managed-jobs.mjs +194 -125
- package/src/local/process-sessions.mjs +5 -5
- package/src/local/relay-connection.mjs +495 -0
- package/src/local/{daemon.mjs → runtime.mjs} +188 -198
- package/src/local/secure-file.mjs +27 -0
- package/src/local/service.mjs +128 -30
- package/src/local/shell.mjs +1 -1
- package/src/local/ssh-key.mjs +41 -21
- package/src/local/state.mjs +7 -5
- package/src/local/stdio.mjs +157 -81
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +5 -1
- package/src/worker/index.ts +45 -21
- package/wrangler.jsonc +1 -1
|
@@ -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() {
|
|
@@ -86,7 +119,7 @@ export class LocalDaemon {
|
|
|
86
119
|
protocol_version: MCP_PROTOCOL_VERSION,
|
|
87
120
|
supported_protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
88
121
|
workspace: this.displayPath(this.workspace),
|
|
89
|
-
workspace_name: basename(this.workspace),
|
|
122
|
+
workspace_name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace",
|
|
90
123
|
policy: this.policy,
|
|
91
124
|
policy_contract: {
|
|
92
125
|
named_profile_is_canonical: this.policy.profile === "custom" || policyMatchesNamedProfile(this.policy),
|
|
@@ -99,8 +132,17 @@ export class LocalDaemon {
|
|
|
99
132
|
operating_system_permissions_apply: true,
|
|
100
133
|
host_policy_is_independent: true,
|
|
101
134
|
},
|
|
135
|
+
tool_delivery: {
|
|
136
|
+
full_profile_scope: "local-daemon-and-relay-advertisement",
|
|
137
|
+
daemon_advertised_tool_count: this.tools().length + 1,
|
|
138
|
+
host_exposed_tools_known_to_server: false,
|
|
139
|
+
host_may_expose_subset: true,
|
|
140
|
+
},
|
|
102
141
|
tools: ["server_info", ...this.tools()],
|
|
103
142
|
observability: {
|
|
143
|
+
relay_readiness: "authenticated-hello-acknowledged",
|
|
144
|
+
brief_relay_interruptions: "debug-only",
|
|
145
|
+
raw_transport_details: "debug-only",
|
|
104
146
|
per_tool_events: "debug-only",
|
|
105
147
|
default_logs_include_tool_failures: false,
|
|
106
148
|
tool_arguments_or_results_logged: false,
|
|
@@ -116,112 +158,19 @@ export class LocalDaemon {
|
|
|
116
158
|
}
|
|
117
159
|
|
|
118
160
|
start() {
|
|
119
|
-
if (!this.
|
|
120
|
-
this.
|
|
121
|
-
this.connectedOnce = new Promise((resolvePromise, rejectPromise) => {
|
|
122
|
-
this.connectedOnceResolve = resolvePromise;
|
|
123
|
-
this.connectedOnceReject = rejectPromise;
|
|
124
|
-
});
|
|
125
|
-
this.connect();
|
|
126
|
-
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();
|
|
127
163
|
}
|
|
128
164
|
|
|
129
165
|
stop() {
|
|
130
|
-
this.
|
|
131
|
-
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
132
|
-
this.heartbeat = null;
|
|
133
|
-
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
134
|
-
this.reconnectTimer = null;
|
|
135
|
-
this.ws?.close();
|
|
136
|
-
this.ws = null;
|
|
166
|
+
this.relay?.stop();
|
|
137
167
|
this.terminateActiveProcesses("SIGKILL");
|
|
138
168
|
this.processSessionManager.clear();
|
|
139
|
-
this.reconnectAttempt = 0;
|
|
140
169
|
rmSync(this.runtimeDir, { recursive: true, force: true });
|
|
141
170
|
}
|
|
142
171
|
|
|
143
|
-
connect() {
|
|
144
|
-
if (this.closed) return;
|
|
145
|
-
const wsUrl = `${this.workerUrl.replace(/^http/i, "ws")}/daemon/ws`;
|
|
146
|
-
this.logger.debug?.("connecting to remote relay", { endpoint: redactUrl(wsUrl) });
|
|
147
|
-
const socket = new WebSocket(wsUrl, { headers: { "X-Bridge-Token": this.secret } });
|
|
148
|
-
this.ws = socket;
|
|
149
|
-
|
|
150
|
-
socket.on("open", () => {
|
|
151
|
-
if (this.ws !== socket || this.closed) {
|
|
152
|
-
socket.close();
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
this.reconnectAttempt = 0;
|
|
156
|
-
this.logger.info?.("remote relay connected");
|
|
157
|
-
this.send({ type: "hello", tools: this.tools(), policy: this.policy, protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS });
|
|
158
|
-
if (this.connectedOnceResolve) {
|
|
159
|
-
this.connectedOnceResolve(true);
|
|
160
|
-
this.connectedOnceResolve = null;
|
|
161
|
-
this.connectedOnceReject = null;
|
|
162
|
-
}
|
|
163
|
-
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
164
|
-
this.heartbeat = setInterval(() => this.send({ type: "heartbeat", ts: Date.now() }), 25_000);
|
|
165
|
-
this.heartbeat.unref?.();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
socket.on("message", data => {
|
|
169
|
-
const raw = String(data);
|
|
170
|
-
if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
|
171
|
-
this.logger.warn?.("oversized websocket message rejected");
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
void this.handleMessage(raw).catch(error => {
|
|
175
|
-
this.logger.error?.("daemon message handler failed", { error_class: classifyOperationalError(error) });
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
socket.on("close", (code, reason) => {
|
|
180
|
-
if (this.ws !== socket) return;
|
|
181
|
-
this.ws = null;
|
|
182
|
-
this.terminateActiveProcesses("SIGTERM", true);
|
|
183
|
-
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
184
|
-
this.heartbeat = null;
|
|
185
|
-
const reasonText = String(reason || "").slice(0, 128);
|
|
186
|
-
const fields = { code, reason: reasonText };
|
|
187
|
-
if (isSupersededClose(code, reasonText)) {
|
|
188
|
-
this.closed = true;
|
|
189
|
-
this.terminateActiveProcesses("SIGKILL");
|
|
190
|
-
this.processSessionManager.clear();
|
|
191
|
-
this.logger.warn?.("daemon connection permanently superseded", fields);
|
|
192
|
-
queueMicrotask(() => {
|
|
193
|
-
try { this.onSuperseded?.(); } catch (error) {
|
|
194
|
-
this.logger.error?.("daemon superseded callback failed", { error_class: classifyOperationalError(error) });
|
|
195
|
-
}
|
|
196
|
-
});
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
if (this.closed) this.logger.debug?.("remote relay closed", fields);
|
|
200
|
-
else this.logger.warn?.("remote relay disconnected", fields);
|
|
201
|
-
if (!this.closed) {
|
|
202
|
-
const delay = reconnectDelay(this.reconnectAttempt++);
|
|
203
|
-
this.logger.debug?.("scheduling daemon reconnect", { delay_ms: delay });
|
|
204
|
-
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
205
|
-
this.reconnectTimer.unref?.();
|
|
206
|
-
}
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
socket.on("error", error => {
|
|
210
|
-
if (this.ws !== socket) return;
|
|
211
|
-
if (this.closed) this.logger.debug?.("remote relay closed during shutdown", { error_class: classifyOperationalError(error) });
|
|
212
|
-
else this.logger.error?.("remote relay error", { error_class: classifyOperationalError(error) });
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
|
|
216
172
|
send(value) {
|
|
217
|
-
|
|
218
|
-
try {
|
|
219
|
-
this.ws.send(JSON.stringify(value));
|
|
220
|
-
return true;
|
|
221
|
-
} catch (error) {
|
|
222
|
-
this.logger.warn?.("remote relay send failed", { error_class: classifyOperationalError(error) });
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
173
|
+
return this.relay?.send(value) === true;
|
|
225
174
|
}
|
|
226
175
|
|
|
227
176
|
async handleMessage(raw) {
|
|
@@ -230,50 +179,59 @@ export class LocalDaemon {
|
|
|
230
179
|
this.logger.warn?.("invalid websocket JSON");
|
|
231
180
|
return;
|
|
232
181
|
}
|
|
233
|
-
if (
|
|
234
|
-
if (message.type === "cancel_call") {
|
|
235
|
-
if (typeof message.id === "string") this.cancelCall(message.id, "remote cancellation");
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
182
|
+
if (this.handleRelayControlMessage(message)) return;
|
|
238
183
|
if (message.type !== "tool_call") {
|
|
239
184
|
this.logger.warn?.("unknown websocket message", { type: String(message.type || "") });
|
|
240
185
|
return;
|
|
241
186
|
}
|
|
187
|
+
await this.handleRelayToolCall(message);
|
|
188
|
+
}
|
|
242
189
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
190
|
+
handleRelayControlMessage(message) {
|
|
191
|
+
if (message.type === "hello_ack") {
|
|
192
|
+
this.relay?.acknowledge(message);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (message.type === "pong") return true;
|
|
196
|
+
if (message.type === "cancel_call") {
|
|
197
|
+
if (typeof message.id === "string") this.cancelCall(message.id, "remote cancellation");
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async handleRelayToolCall(message) {
|
|
204
|
+
const envelope = normalizeRelayToolCall(message);
|
|
205
|
+
if (!envelope.ok) {
|
|
247
206
|
this.logger.warn?.("invalid tool_call envelope");
|
|
248
|
-
if (id
|
|
207
|
+
if (envelope.id) this.send({ type: "tool_result", id: envelope.id, ok: false, error: { message: "invalid tool_call envelope" } });
|
|
249
208
|
return;
|
|
250
209
|
}
|
|
251
210
|
if (this.activeToolCalls >= MAX_CONCURRENT_TOOL_CALLS) {
|
|
252
|
-
this.send({ type: "tool_result", id, ok: false, error: { message: "too many concurrent tool calls" } });
|
|
211
|
+
this.send({ type: "tool_result", id: envelope.id, ok: false, error: { message: "too many concurrent tool calls" } });
|
|
253
212
|
return;
|
|
254
213
|
}
|
|
255
214
|
|
|
256
|
-
const
|
|
257
|
-
const deadline = setTimeout(() => this.cancelCall(id, "relay deadline exceeded"), relayTimeoutMs);
|
|
215
|
+
const deadline = setTimeout(() => this.cancelCall(envelope.id, "relay deadline exceeded"), envelope.timeoutMs);
|
|
258
216
|
deadline.unref?.();
|
|
259
217
|
this.activeToolCalls += 1;
|
|
260
218
|
const started = Date.now();
|
|
261
|
-
this.logger.debug?.("tool call started", { call_id: shortCallId(id), tool });
|
|
219
|
+
this.logger.debug?.("tool call started", { call_id: shortCallId(envelope.id), tool: envelope.tool });
|
|
262
220
|
try {
|
|
263
|
-
const result = await this.executeTool(tool,
|
|
264
|
-
if (this.cancelledCalls.has(id)) throw new Error("tool call cancelled");
|
|
265
|
-
this.send({ type: "tool_result", id, ok: true, result });
|
|
221
|
+
const result = await this.executeTool(envelope.tool, envelope.arguments, { callId: envelope.id });
|
|
222
|
+
if (this.cancelledCalls.has(envelope.id)) throw new Error("tool call cancelled");
|
|
223
|
+
this.send({ type: "tool_result", id: envelope.id, ok: true, result });
|
|
266
224
|
const durationMs = Date.now() - started;
|
|
267
|
-
this.logger.debug?.(durationMs >= SLOW_TOOL_CALL_MS ? "slow tool call completed" : "tool call completed", { call_id: shortCallId(id), tool, duration_ms: durationMs });
|
|
225
|
+
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 });
|
|
268
226
|
} catch (error) {
|
|
269
|
-
const safeError = this.safeErrorMessage(error);
|
|
270
|
-
this.send({ type: "tool_result", id, ok: false, error: { message: safeError } });
|
|
227
|
+
const safeError = this.safeErrorMessage(error, envelope.arguments);
|
|
228
|
+
this.send({ type: "tool_result", id: envelope.id, ok: false, error: { message: safeError } });
|
|
271
229
|
const durationMs = Date.now() - started;
|
|
272
|
-
this.logger.debug?.("tool call failed", { call_id: shortCallId(id), tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
230
|
+
this.logger.debug?.("tool call failed", { call_id: shortCallId(envelope.id), tool: envelope.tool, duration_ms: durationMs, error_class: classifyOperationalError(error) });
|
|
273
231
|
} finally {
|
|
274
232
|
clearTimeout(deadline);
|
|
275
233
|
this.activeToolCalls -= 1;
|
|
276
|
-
this.finishCall(id);
|
|
234
|
+
this.finishCall(envelope.id);
|
|
277
235
|
}
|
|
278
236
|
}
|
|
279
237
|
|
|
@@ -299,38 +257,9 @@ export class LocalDaemon {
|
|
|
299
257
|
|
|
300
258
|
async executeTool(tool, args, context = {}) {
|
|
301
259
|
if (!["server_info", ...this.tools()].includes(tool)) throw new Error(`tool disabled or unknown: ${tool}`);
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
case "list_roots": return this.listRoots();
|
|
306
|
-
case "list_dir": return this.listDir(args.path || ".", context);
|
|
307
|
-
case "list_files": return this.listFiles(args.path || ".", clampInt(args.max_files, 1000, 1, 10000), context);
|
|
308
|
-
case "read_file": return this.readFile(args, context);
|
|
309
|
-
case "view_image": return this.viewImage(args, context);
|
|
310
|
-
case "write_file": return this.writeFile(args, context);
|
|
311
|
-
case "edit_file": return this.editFile(args, context);
|
|
312
|
-
case "apply_patch": return this.applyPatch(args, context);
|
|
313
|
-
case "search_text": return this.searchText(args, context);
|
|
314
|
-
case "git_status": return this.gitStatus(args, context);
|
|
315
|
-
case "git_diff": return this.gitDiff(args, context);
|
|
316
|
-
case "git_log": return this.gitLog(args, context);
|
|
317
|
-
case "git_show": return this.gitShow(args, context);
|
|
318
|
-
case "diagnose_runtime": return this.diagnoseRuntime(context);
|
|
319
|
-
case "list_local_resources": return this.managedJobManager.listResources();
|
|
320
|
-
case "generate_ssh_key_resource": return this.generateSshKeyResource(args, context);
|
|
321
|
-
case "stage_job": return this.managedJobManager.stage(args);
|
|
322
|
-
case "start_job": return this.managedJobManager.start(args);
|
|
323
|
-
case "list_jobs": return this.managedJobManager.list(args);
|
|
324
|
-
case "read_job": return this.managedJobManager.read(args);
|
|
325
|
-
case "cancel_job": return this.managedJobManager.cancel(args);
|
|
326
|
-
case "run_process": return this.runDirectProcess(args, context);
|
|
327
|
-
case "start_process": return this.processSessionManager.start(args, context);
|
|
328
|
-
case "read_process": return this.processSessionManager.read(args, context);
|
|
329
|
-
case "write_process": return this.processSessionManager.write(args, context);
|
|
330
|
-
case "kill_process": return this.processSessionManager.kill(args, context);
|
|
331
|
-
case "exec_command": return this.execCommand(args.command, clampInt(args.timeout_seconds, 120, 1, 600), context);
|
|
332
|
-
default: throw new Error(`unknown daemon tool: ${tool}`);
|
|
333
|
-
}
|
|
260
|
+
const handler = RUNTIME_TOOL_HANDLERS[tool];
|
|
261
|
+
if (!handler) throw new Error(`runtime handler is missing for tool: ${tool}`);
|
|
262
|
+
return handler(this, args, context);
|
|
334
263
|
}
|
|
335
264
|
|
|
336
265
|
async projectOverview(context = {}) {
|
|
@@ -339,7 +268,7 @@ export class LocalDaemon {
|
|
|
339
268
|
const git = await this.runProcess("git", ["-c", "core.fsmonitor=false", "-C", this.workspace, "rev-parse", "--show-toplevel"], 10_000, true, 512 * 1024, context);
|
|
340
269
|
return {
|
|
341
270
|
workspace: this.displayPath(this.workspace),
|
|
342
|
-
workspaceName: basename(this.workspace),
|
|
271
|
+
workspaceName: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace",
|
|
343
272
|
gitRoot: git.code === 0 ? this.displayPath(git.stdout.trim()) : "",
|
|
344
273
|
policy: this.policy,
|
|
345
274
|
tools: ["server_info", ...this.tools()],
|
|
@@ -348,7 +277,7 @@ export class LocalDaemon {
|
|
|
348
277
|
}
|
|
349
278
|
|
|
350
279
|
listRoots() {
|
|
351
|
-
const roots = [{ name: basename(this.workspace), path: this.displayPath(this.workspace), default: true }];
|
|
280
|
+
const roots = [{ name: this.policy.exposeAbsolutePaths ? basename(this.workspace) : "workspace", path: this.displayPath(this.workspace), default: true }];
|
|
352
281
|
if (this.policy.unrestrictedPaths) {
|
|
353
282
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
354
283
|
if (home && home !== this.workspace) roots.push({ name: "home", path: this.displayPath(resolve(home)), default: false });
|
|
@@ -757,18 +686,22 @@ export class LocalDaemon {
|
|
|
757
686
|
targetPath: target,
|
|
758
687
|
comment: args.comment || `machine-mcp:${args.name}`,
|
|
759
688
|
});
|
|
689
|
+
const exposePaths = args.expose_paths === true;
|
|
760
690
|
return {
|
|
761
691
|
name: key.name,
|
|
762
692
|
created: key.created,
|
|
763
693
|
registered: key.registered,
|
|
764
|
-
private_key_path: this.displayPath(key.privateKeyPath),
|
|
765
|
-
public_key_path: this.displayPath(key.publicKeyPath),
|
|
766
694
|
fingerprint: key.fingerprint,
|
|
767
695
|
key_type: key.keyType,
|
|
768
696
|
private_mode: key.privateMode,
|
|
769
697
|
public_mode: key.publicMode,
|
|
770
698
|
private_key_content_exposed: key.privateKeyContentExposed,
|
|
771
699
|
available_to_new_jobs_immediately: key.availableToNewJobsImmediately,
|
|
700
|
+
paths_exposed: exposePaths,
|
|
701
|
+
...(exposePaths ? {
|
|
702
|
+
private_key_path: resolve(key.privateKeyPath),
|
|
703
|
+
public_key_path: resolve(key.publicKeyPath),
|
|
704
|
+
} : {}),
|
|
772
705
|
};
|
|
773
706
|
}
|
|
774
707
|
|
|
@@ -831,7 +764,7 @@ export class LocalDaemon {
|
|
|
831
764
|
timer.unref?.();
|
|
832
765
|
const cleanup = () => {
|
|
833
766
|
clearTimeout(timer);
|
|
834
|
-
if (killTimer
|
|
767
|
+
if (killTimer) clearTimeout(killTimer);
|
|
835
768
|
this.activeProcesses.delete(child);
|
|
836
769
|
if (context.callId) {
|
|
837
770
|
const set = this.callProcesses.get(context.callId);
|
|
@@ -944,19 +877,25 @@ export class LocalDaemon {
|
|
|
944
877
|
|
|
945
878
|
displayPath(fullPath) {
|
|
946
879
|
const absolute = resolve(fullPath);
|
|
947
|
-
if (this.policy.exposeAbsolutePaths
|
|
948
|
-
assertContainedPath(this.workspace, absolute);
|
|
880
|
+
if (this.policy.exposeAbsolutePaths) return absolute;
|
|
949
881
|
const shown = relative(this.workspace, absolute);
|
|
950
|
-
|
|
882
|
+
const insideWorkspace = shown === "" || (!shown.startsWith(`..${sep}`) && shown !== ".." && !isAbsolute(shown));
|
|
883
|
+
if (insideWorkspace) return shown ? shown.split(sep).join("/") : ".";
|
|
884
|
+
return `<external-path:${sha256(absolute).slice(0, 12)}>`;
|
|
951
885
|
}
|
|
952
886
|
|
|
953
|
-
safeErrorMessage(error) {
|
|
887
|
+
safeErrorMessage(error, toolArgs = {}) {
|
|
954
888
|
let message = boundedErrorMessage(error);
|
|
955
889
|
if (!this.policy.exposeAbsolutePaths) {
|
|
956
890
|
for (const prefix of equivalentPathPrefixes(this.workspace, this.workspaceInput)) message = replacePathPrefix(message, prefix, ".");
|
|
957
891
|
for (const prefix of equivalentPathPrefixes(this.runtimeDir)) message = replacePathPrefix(message, prefix, "<runtime>");
|
|
958
892
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
959
893
|
if (home) message = replacePathPrefix(message, resolve(home), "<home>");
|
|
894
|
+
for (const candidate of collectToolPathCandidates(error, toolArgs, this.workspaceInput)) {
|
|
895
|
+
const absolute = isAbsolute(candidate) ? resolve(candidate) : resolve(this.workspaceInput, candidate);
|
|
896
|
+
const replacement = this.displayPath(absolute);
|
|
897
|
+
for (const prefix of equivalentPathPrefixes(candidate, absolute)) message = replacePathPrefix(message, prefix, replacement);
|
|
898
|
+
}
|
|
960
899
|
}
|
|
961
900
|
return message;
|
|
962
901
|
}
|
|
@@ -993,25 +932,6 @@ function stateRootFromProfileStatePath(statePath) {
|
|
|
993
932
|
return dirname(profilesDir);
|
|
994
933
|
}
|
|
995
934
|
|
|
996
|
-
function normalizeWorkerUrl(value) {
|
|
997
|
-
let url;
|
|
998
|
-
try { url = new URL(String(value || "")); } catch { throw new Error("invalid Worker URL"); }
|
|
999
|
-
if (url.protocol !== "https:") throw new Error("Worker URL must use HTTPS");
|
|
1000
|
-
if (url.username || url.password) throw new Error("Worker URL must not contain credentials");
|
|
1001
|
-
if (url.pathname !== "/" || url.search || url.hash) throw new Error("Worker URL must be an origin without a path, query, or fragment");
|
|
1002
|
-
return url.origin;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
export function isSupersededClose(code, reason) {
|
|
1006
|
-
return Number(code) === 1012 && String(reason || "") === "replaced by authenticated daemon";
|
|
1007
|
-
}
|
|
1008
|
-
|
|
1009
|
-
function reconnectDelay(attempt) {
|
|
1010
|
-
const base = Math.min(3000 * (2 ** Math.min(attempt, 4)), 60_000);
|
|
1011
|
-
return base + Math.floor(Math.random() * 1000);
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
935
|
function assertContainedPath(root, target) {
|
|
1016
936
|
const rel = relative(root, target);
|
|
1017
937
|
if (rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))) return;
|
|
@@ -1197,11 +1117,85 @@ function detectImageMime(buffer) {
|
|
|
1197
1117
|
return "";
|
|
1198
1118
|
}
|
|
1199
1119
|
|
|
1120
|
+
function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, onFatal }) {
|
|
1121
|
+
if (!workerUrl) return null;
|
|
1122
|
+
return new RelayConnection({
|
|
1123
|
+
workerUrl,
|
|
1124
|
+
secret,
|
|
1125
|
+
logger: runtime.logger,
|
|
1126
|
+
maxPayload: MAX_WS_MESSAGE_BYTES,
|
|
1127
|
+
expectedServer: SERVER_NAME,
|
|
1128
|
+
expectedVersion: String(expectedVersion || ""),
|
|
1129
|
+
helloMessage: () => ({
|
|
1130
|
+
type: "hello",
|
|
1131
|
+
tools: runtime.tools(),
|
|
1132
|
+
policy: runtime.policy,
|
|
1133
|
+
protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
1134
|
+
}),
|
|
1135
|
+
onMessage: (data) => handleRelayData(runtime, data),
|
|
1136
|
+
onDisconnect: () => runtime.terminateActiveProcesses("SIGTERM", true),
|
|
1137
|
+
onSuperseded: () => {
|
|
1138
|
+
runtime.terminateActiveProcesses("SIGKILL");
|
|
1139
|
+
runtime.processSessionManager.clear();
|
|
1140
|
+
runtime.onSuperseded?.();
|
|
1141
|
+
},
|
|
1142
|
+
onFatal: (error) => {
|
|
1143
|
+
runtime.terminateActiveProcesses("SIGKILL");
|
|
1144
|
+
runtime.processSessionManager.clear();
|
|
1145
|
+
onFatal?.(error);
|
|
1146
|
+
},
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function handleRelayData(runtime, data) {
|
|
1151
|
+
const raw = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
|
1152
|
+
if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
|
1153
|
+
runtime.logger.warn?.("oversized websocket message rejected");
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
return runtime.handleMessage(raw);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function normalizeRelayToolCall(message) {
|
|
1160
|
+
const id = typeof message.id === "string" && message.id.length <= 256 ? message.id : "";
|
|
1161
|
+
const tool = typeof message.tool === "string" && message.tool.length <= 128 ? message.tool : "";
|
|
1162
|
+
const argumentsValue = message.arguments === undefined ? {} : message.arguments;
|
|
1163
|
+
if (!id || !tool || !isPlainRecord(argumentsValue)) return { ok: false, id };
|
|
1164
|
+
return {
|
|
1165
|
+
ok: true,
|
|
1166
|
+
id,
|
|
1167
|
+
tool,
|
|
1168
|
+
arguments: argumentsValue,
|
|
1169
|
+
timeoutMs: clampInt(message.timeout_ms, 60_000, 1000, 610_000),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1200
1173
|
function isPlainRecord(value) {
|
|
1201
1174
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1202
1175
|
}
|
|
1203
1176
|
|
|
1204
1177
|
|
|
1178
|
+
function collectToolPathCandidates(error, toolArgs, workspace) {
|
|
1179
|
+
const candidates = new Set();
|
|
1180
|
+
for (const value of [error?.path, error?.dest]) if (typeof value === "string" && value) candidates.add(value);
|
|
1181
|
+
const visit = (value, key = "", depth = 0) => {
|
|
1182
|
+
if (depth > 5 || value === null || value === undefined) return;
|
|
1183
|
+
if (typeof value === "string") {
|
|
1184
|
+
if (/(?:^|[_-])(?:path|cwd|workspace|root|directory|dir)(?:$|[_-])/i.test(key) && value && !value.includes("\0")) candidates.add(value);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
if (Array.isArray(value)) {
|
|
1188
|
+
for (const item of value.slice(0, 64)) visit(item, key, depth + 1);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (typeof value !== "object") return;
|
|
1192
|
+
for (const [childKey, child] of Object.entries(value).slice(0, 128)) visit(child, childKey, depth + 1);
|
|
1193
|
+
};
|
|
1194
|
+
visit(toolArgs);
|
|
1195
|
+
candidates.delete(workspace);
|
|
1196
|
+
return [...candidates].sort((left, right) => right.length - left.length);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1205
1199
|
function equivalentPathPrefixes(...values) {
|
|
1206
1200
|
const prefixes = new Set(values.filter(Boolean).map((value) => String(value)));
|
|
1207
1201
|
for (const value of [...prefixes]) {
|
|
@@ -1220,7 +1214,3 @@ function replacePathPrefix(message, pathValue, replacement) {
|
|
|
1220
1214
|
function shortCallId(value) {
|
|
1221
1215
|
return String(value || "").slice(0, 20);
|
|
1222
1216
|
}
|
|
1223
|
-
|
|
1224
|
-
function redactUrl(value) {
|
|
1225
|
-
try { return new URL(value).origin; } catch { return "<invalid-url>"; }
|
|
1226
|
-
}
|
|
@@ -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
|
+
}
|