machine-bridge-mcp 1.2.9 → 1.2.11
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 +17 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +15 -8
- package/docs/AUDIT.md +22 -0
- package/docs/LOGGING.md +4 -2
- package/docs/MULTI_ACCOUNT.md +2 -2
- package/docs/OPERATIONS.md +5 -4
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +5 -1
- package/docs/TOOL_REFERENCE.md +4 -4
- package/package.json +6 -2
- package/scripts/check-plan.mjs +3 -0
- package/scripts/check-runner.mjs +75 -0
- package/scripts/coverage-check.mjs +1 -1
- package/scripts/github-backlog.mjs +116 -0
- package/scripts/github-push.mjs +4 -0
- package/scripts/release-acceptance.mjs +29 -13
- package/scripts/run-checks.mjs +11 -21
- package/src/local/bounded-output.mjs +20 -3
- package/src/local/errors.mjs +5 -1
- package/src/local/execution-limits.mjs +6 -1
- package/src/local/process-execution.mjs +94 -14
- package/src/local/process-output-stream.mjs +82 -0
- package/src/local/process-result-projection.mjs +25 -0
- package/src/local/process-sessions.mjs +37 -51
- package/src/local/relay-call-recovery.mjs +148 -0
- package/src/local/relay-connection.mjs +5 -0
- package/src/local/runtime-relay.mjs +16 -0
- package/src/local/runtime.mjs +60 -39
- package/src/local/secure-file.mjs +24 -4
- package/src/local/tools.mjs +4 -9
- package/src/shared/result-projection.d.mts +6 -0
- package/src/shared/result-projection.json +4 -0
- package/src/shared/result-projection.mjs +50 -0
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +4 -4
- package/src/worker/daemon-sockets.ts +10 -1
- package/src/worker/errors.ts +18 -4
- package/src/worker/index.ts +45 -9
- package/src/worker/mcp-jsonrpc.ts +4 -2
- package/src/worker/pending-call-contract.ts +26 -0
- package/src/worker/pending-calls.ts +41 -25
- package/tsconfig.local.json +1 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/** @typedef {{id?: unknown, [key: string]: unknown}} RelayResult */
|
|
4
|
+
/** @typedef {{event?: (level: string, name: string, fields: Record<string, unknown>, message: string) => void, warn?: (message: string) => void}} RecoveryLogger */
|
|
5
|
+
/** @typedef {{setTimeout: (callback: () => void, delay: number) => any, clearTimeout: (handle: any) => void}} RecoveryScheduler */
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{
|
|
8
|
+
* logger?: RecoveryLogger,
|
|
9
|
+
* send?: (value: RelayResult) => boolean,
|
|
10
|
+
* isRecoverable?: () => boolean,
|
|
11
|
+
* activeCallIds?: () => Iterable<string>,
|
|
12
|
+
* suppressCall?: (callId: string, reason: string) => void,
|
|
13
|
+
* cancelOrigin?: (reason: string) => number,
|
|
14
|
+
* terminate?: () => void,
|
|
15
|
+
* graceMs?: unknown,
|
|
16
|
+
* scheduler?: RecoveryScheduler,
|
|
17
|
+
* }} RelayCallRecoveryOptions
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const DEFAULT_RECONNECT_GRACE_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
export class RelayCallRecovery {
|
|
23
|
+
/** @param {RelayCallRecoveryOptions} [options] */
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
/** @type {RecoveryLogger} */
|
|
26
|
+
this.logger = options.logger || { warn: (message) => console.warn(message) };
|
|
27
|
+
this.send = typeof options.send === "function" ? options.send : () => false;
|
|
28
|
+
this.isRecoverable = typeof options.isRecoverable === "function" ? options.isRecoverable : () => false;
|
|
29
|
+
this.activeCallIds = typeof options.activeCallIds === "function" ? options.activeCallIds : () => [];
|
|
30
|
+
this.suppressCall = typeof options.suppressCall === "function" ? options.suppressCall : () => {};
|
|
31
|
+
this.cancelOrigin = typeof options.cancelOrigin === "function" ? options.cancelOrigin : () => 0;
|
|
32
|
+
this.terminate = typeof options.terminate === "function" ? options.terminate : () => {};
|
|
33
|
+
this.graceMs = positiveInteger(options.graceMs, DEFAULT_RECONNECT_GRACE_MS);
|
|
34
|
+
this.scheduler = options.scheduler || { setTimeout, clearTimeout };
|
|
35
|
+
/** @type {Map<string, RelayResult>} */
|
|
36
|
+
this.pendingResults = new Map();
|
|
37
|
+
/** @type {any} */
|
|
38
|
+
this.reconnectTimer = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {RelayResult} response */
|
|
42
|
+
deliver(response) {
|
|
43
|
+
const callId = String(response?.id || "");
|
|
44
|
+
if (this.send(response)) {
|
|
45
|
+
if (callId) this.pendingResults.delete(callId);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (callId && this.isRecoverable()) {
|
|
49
|
+
this.pendingResults.set(callId, response);
|
|
50
|
+
this.scheduleExpiry();
|
|
51
|
+
this.logger.event?.("debug", "relay.tool_result.queued", {
|
|
52
|
+
call_id: shortCallId(callId), queued_results: this.pendingResults.size,
|
|
53
|
+
}, "Queued a completed tool result while the relay reconnects");
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
this.logger.event?.("debug", "relay.tool_result.discarded", {
|
|
57
|
+
call_id: shortCallId(callId), reason: "transport_unavailable",
|
|
58
|
+
}, "Discarded a tool result because the relay is no longer recoverable");
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {unknown} callId */
|
|
63
|
+
discard(callId) {
|
|
64
|
+
return this.pendingResults.delete(String(callId));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {Iterable<string>} resumedCallIds @param {(callId: string) => boolean} cancelCall */
|
|
68
|
+
reconcile(resumedCallIds, cancelCall) {
|
|
69
|
+
const resumed = new Set(resumedCallIds);
|
|
70
|
+
let cancelled = 0;
|
|
71
|
+
let discarded = 0;
|
|
72
|
+
for (const callId of this.activeCallIds()) {
|
|
73
|
+
if (!resumed.has(callId) && cancelCall(callId)) cancelled += 1;
|
|
74
|
+
}
|
|
75
|
+
for (const callId of [...this.pendingResults.keys()]) {
|
|
76
|
+
if (!resumed.has(callId) && this.pendingResults.delete(callId)) discarded += 1;
|
|
77
|
+
}
|
|
78
|
+
if (cancelled > 0 || discarded > 0) {
|
|
79
|
+
this.logger.event?.("debug", "relay.calls.reconciled", { cancelled_calls: cancelled, discarded_results: discarded },
|
|
80
|
+
"Cancelled relay work that no longer had a waiting client after reconnect");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
disconnected() {
|
|
85
|
+
const activeCalls = [...this.activeCallIds()].length;
|
|
86
|
+
if (activeCalls === 0 && this.pendingResults.size === 0) return;
|
|
87
|
+
this.scheduleExpiry();
|
|
88
|
+
this.logger.event?.("debug", "relay.calls.awaiting_reconnect", {
|
|
89
|
+
active_calls: activeCalls, queued_results: this.pendingResults.size, grace_ms: this.graceMs,
|
|
90
|
+
}, "Keeping in-flight tool calls alive during a brief relay interruption");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
ready() {
|
|
94
|
+
this.clearTimer();
|
|
95
|
+
let delivered = 0;
|
|
96
|
+
for (const [callId, response] of [...this.pendingResults]) {
|
|
97
|
+
if (!this.send(response)) {
|
|
98
|
+
this.scheduleExpiry();
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
this.pendingResults.delete(callId);
|
|
102
|
+
delivered += 1;
|
|
103
|
+
}
|
|
104
|
+
if (delivered > 0) {
|
|
105
|
+
this.logger.event?.("info", "relay.tool_results.replayed", { delivered_results: delivered },
|
|
106
|
+
"Delivered completed tool results after the relay reconnected");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
stop() {
|
|
111
|
+
this.clearTimer();
|
|
112
|
+
this.pendingResults.clear();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
scheduleExpiry() {
|
|
116
|
+
if (this.reconnectTimer) return;
|
|
117
|
+
this.reconnectTimer = this.scheduler.setTimeout(() => {
|
|
118
|
+
this.reconnectTimer = null;
|
|
119
|
+
for (const callId of this.activeCallIds()) this.suppressCall(callId, "relay_reconnect_timeout");
|
|
120
|
+
const cancelled = this.cancelOrigin("remote relay reconnect grace expired");
|
|
121
|
+
const discarded = this.pendingResults.size;
|
|
122
|
+
this.pendingResults.clear();
|
|
123
|
+
this.terminate();
|
|
124
|
+
if (cancelled > 0 || discarded > 0) {
|
|
125
|
+
this.logger.warn?.(`remote relay did not recover within ${this.graceMs / 1000} seconds; cancelled ${cancelled} call(s) and discarded ${discarded} queued result(s)`);
|
|
126
|
+
}
|
|
127
|
+
}, this.graceMs);
|
|
128
|
+
this.reconnectTimer?.unref?.();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
clearTimer() {
|
|
132
|
+
if (!this.reconnectTimer) return;
|
|
133
|
+
this.scheduler.clearTimeout(this.reconnectTimer);
|
|
134
|
+
this.reconnectTimer = null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @param {unknown} value @param {number} fallback */
|
|
139
|
+
function positiveInteger(value, fallback) {
|
|
140
|
+
const number = Number(value);
|
|
141
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {unknown} value */
|
|
145
|
+
function shortCallId(value) {
|
|
146
|
+
const text = String(value || "");
|
|
147
|
+
return text.length <= 18 ? text : `${text.slice(0, 10)}...${text.slice(-5)}`;
|
|
148
|
+
}
|
|
@@ -31,6 +31,7 @@ export class RelayConnection {
|
|
|
31
31
|
this.expectedVersion = String(options.expectedVersion || "");
|
|
32
32
|
this.onMessage = typeof options.onMessage === "function" ? options.onMessage : () => {};
|
|
33
33
|
this.onDisconnect = typeof options.onDisconnect === "function" ? options.onDisconnect : () => {};
|
|
34
|
+
this.onReady = typeof options.onReady === "function" ? options.onReady : () => {};
|
|
34
35
|
this.onSuperseded = typeof options.onSuperseded === "function" ? options.onSuperseded : () => {};
|
|
35
36
|
this.onFatal = typeof options.onFatal === "function" ? options.onFatal : () => {};
|
|
36
37
|
this.WebSocketClass = options.WebSocketClass || WebSocket;
|
|
@@ -230,8 +231,12 @@ export class RelayConnection {
|
|
|
230
231
|
}
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
const reconnected = this.hasConnected;
|
|
233
235
|
this.hasConnected = true;
|
|
234
236
|
this.resetOutage();
|
|
237
|
+
try { this.onReady({ reconnected, sessionId: this.activeSessionId }); } catch (error) {
|
|
238
|
+
this.logger.error?.("relay ready callback failed", { error_class: classifyOperationalError(error) });
|
|
239
|
+
}
|
|
235
240
|
if (this.connectedOnceResolve) {
|
|
236
241
|
this.connectedOnceResolve(true);
|
|
237
242
|
this.connectedOnceResolve = null;
|
|
@@ -18,12 +18,14 @@ export function createRuntimeRelayConnection(runtime, { workerUrl, secret, expec
|
|
|
18
18
|
expectedVersion: String(expectedVersion || ""),
|
|
19
19
|
helloMessage: () => ({
|
|
20
20
|
type: "hello",
|
|
21
|
+
instance_id: runtime.relayInstanceId,
|
|
21
22
|
tools: runtime.tools(),
|
|
22
23
|
policy: runtime.policy,
|
|
23
24
|
protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
24
25
|
}),
|
|
25
26
|
onMessage: (data, relayContext) => handleRelayData(runtime, data, relayContext),
|
|
26
27
|
onDisconnect: () => runtime.handleRelayDisconnect(),
|
|
28
|
+
onReady: () => runtime.handleRelayReady(),
|
|
27
29
|
onSuperseded: () => {
|
|
28
30
|
runtime.terminateActiveProcesses("SIGKILL");
|
|
29
31
|
runtime.processSessionManager.clear();
|
|
@@ -37,6 +39,20 @@ export function createRuntimeRelayConnection(runtime, { workerUrl, secret, expec
|
|
|
37
39
|
});
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
export function normalizeRelayResumeCalls(message) {
|
|
43
|
+
if (!Array.isArray(message?.ids) || message.ids.length > 32) return { ok: false, ids: [] };
|
|
44
|
+
const ids = [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const value of message.ids) {
|
|
47
|
+
if (typeof value !== "string" || !/^call_[A-Za-z0-9_-]{8,240}$/.test(value) || seen.has(value)) {
|
|
48
|
+
return { ok: false, ids: [] };
|
|
49
|
+
}
|
|
50
|
+
seen.add(value);
|
|
51
|
+
ids.push(value);
|
|
52
|
+
}
|
|
53
|
+
return { ok: true, ids };
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
export function normalizeRelayToolCall(message) {
|
|
41
57
|
const id = typeof message.id === "string" && message.id.length <= 256 ? message.id : "";
|
|
42
58
|
const tool = typeof message.tool === "string" && message.tool.length <= 128 ? message.tool : "";
|
package/src/local/runtime.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
1
2
|
import { realpathSync, rmSync } from "node:fs";
|
|
2
3
|
import { lstat, realpath, stat } from "node:fs/promises";
|
|
3
4
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
@@ -30,7 +31,8 @@ import { AccountAccessGate } from "./account-access.mjs";
|
|
|
30
31
|
import { buildProjectOverview, buildRuntimeInfo } from "./runtime-reporting.mjs";
|
|
31
32
|
import { diagnoseRuntime as runRuntimeDiagnostics } from "./runtime-diagnostics.mjs";
|
|
32
33
|
import { bindRuntimeToolHandlers, runtimeToolHandlerNames as registeredRuntimeToolHandlerNames } from "./runtime-tool-handlers.mjs";
|
|
33
|
-
import { createRuntimeRelayConnection, normalizeRelayToolCall } from "./runtime-relay.mjs";
|
|
34
|
+
import { createRuntimeRelayConnection, normalizeRelayResumeCalls, normalizeRelayToolCall } from "./runtime-relay.mjs";
|
|
35
|
+
import { RelayCallRecovery } from "./relay-call-recovery.mjs";
|
|
34
36
|
import { assertContainedPath, createRuntimeDir, redactRuntimeErrorMessage, stateRootFromProfileStatePath } from "./runtime-paths.mjs";
|
|
35
37
|
import {
|
|
36
38
|
resolveTaskCapabilities as resolveRuntimeTaskCapabilities,
|
|
@@ -59,8 +61,10 @@ export class LocalRuntime {
|
|
|
59
61
|
this.processTracker = new ProcessTracker();
|
|
60
62
|
this.lifecycle = new LifecycleController("local runtime");
|
|
61
63
|
this.observability = new RuntimeObservability();
|
|
64
|
+
this.relayInstanceId = `daemon_${randomBytes(18).toString("base64url")}`;
|
|
62
65
|
this.activeRelayCalls = new Set();
|
|
63
66
|
this.suppressedRelayResults = new Map();
|
|
67
|
+
this.relayResumeSessionId = 0;
|
|
64
68
|
this.callRegistry = new CallRegistry({
|
|
65
69
|
maximum: MAX_CONCURRENT_TOOL_CALLS,
|
|
66
70
|
onCancel: (record) => {
|
|
@@ -131,6 +135,7 @@ export class LocalRuntime {
|
|
|
131
135
|
resolveLocalCommand: (args, context) => this.agentContextManager.resolveLocalCommand(args, context),
|
|
132
136
|
displayPath: (value) => this.displayPath(value),
|
|
133
137
|
throwIfCancelled: (context) => this.throwIfCancelled(context),
|
|
138
|
+
retainCompletedOutput: (value) => this.processSessionManager.retainCompletedOutput(value),
|
|
134
139
|
});
|
|
135
140
|
this.gitService = new GitService({
|
|
136
141
|
resolveExistingPath: (value) => this.resolveExistingPath(value),
|
|
@@ -171,10 +176,16 @@ export class LocalRuntime {
|
|
|
171
176
|
slowMs: SLOW_TOOL_CALL_MS,
|
|
172
177
|
});
|
|
173
178
|
this.relay = createRuntimeRelayConnection(this, {
|
|
174
|
-
workerUrl: remoteWorkerUrl,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
179
|
+
workerUrl: remoteWorkerUrl, secret: remoteSecret, expectedVersion: expectedRelayVersion, onFatal,
|
|
180
|
+
});
|
|
181
|
+
this.relayCallRecovery = new RelayCallRecovery({
|
|
182
|
+
logger: this.logger,
|
|
183
|
+
send: (value) => this.send(value),
|
|
184
|
+
isRecoverable: () => Boolean(this.relay && !this.relay.status?.().closed),
|
|
185
|
+
activeCallIds: () => this.activeRelayCalls,
|
|
186
|
+
suppressCall: (callId, reason) => this.suppressedRelayResults.set(callId, reason),
|
|
187
|
+
cancelOrigin: (reason) => this.callRegistry.cancelOrigin("relay", reason),
|
|
188
|
+
terminate: () => this.terminateActiveProcesses("SIGTERM", true),
|
|
178
189
|
});
|
|
179
190
|
}
|
|
180
191
|
|
|
@@ -219,6 +230,7 @@ export class LocalRuntime {
|
|
|
219
230
|
if (!this.lifecycle.beginStop()) return;
|
|
220
231
|
try {
|
|
221
232
|
this.relay?.stop();
|
|
233
|
+
this.relayCallRecovery.stop();
|
|
222
234
|
this.callRegistry.cancelAll("runtime stopped");
|
|
223
235
|
this.terminateActiveProcesses("SIGKILL");
|
|
224
236
|
this.processSessionManager.clear();
|
|
@@ -243,7 +255,7 @@ export class LocalRuntime {
|
|
|
243
255
|
this.handleRelayProtocolViolation("invalid_server_message");
|
|
244
256
|
return;
|
|
245
257
|
}
|
|
246
|
-
if (this.handleRelayControlMessage(message)) return;
|
|
258
|
+
if (this.handleRelayControlMessage(message, relayContext)) return;
|
|
247
259
|
if (message.type === "relay_probe") {
|
|
248
260
|
if (isRelayReadyContext(relayContext, this.relay)) return this.handleRelayProtocolViolation("unexpected_relay_probe");
|
|
249
261
|
this.handleRelayProbe(message, relayContext);
|
|
@@ -251,20 +263,38 @@ export class LocalRuntime {
|
|
|
251
263
|
}
|
|
252
264
|
if (message.type !== "tool_call") return this.handleRelayProtocolViolation("unexpected_server_message_type");
|
|
253
265
|
if (!isRelayReadyContext(relayContext, this.relay)) return this.handleRelayProtocolViolation("tool_call_before_ready");
|
|
254
|
-
await this.handleRelayToolCall(message
|
|
266
|
+
await this.handleRelayToolCall(message);
|
|
255
267
|
}
|
|
256
268
|
|
|
257
|
-
handleRelayControlMessage(message) {
|
|
269
|
+
handleRelayControlMessage(message, relayContext = {}) {
|
|
258
270
|
if (message.type === "welcome") {
|
|
259
271
|
this.relay?.observeWelcome(message);
|
|
260
272
|
return true;
|
|
261
273
|
}
|
|
262
274
|
if (message.type === "hello_ack") {
|
|
275
|
+
this.relayResumeSessionId = 0;
|
|
263
276
|
this.relay?.acknowledge(message);
|
|
264
277
|
return true;
|
|
265
278
|
}
|
|
279
|
+
if (message.type === "resume_calls") {
|
|
280
|
+
const sessionId = Number(relayContext.sessionId) || 0;
|
|
281
|
+
const resume = normalizeRelayResumeCalls(message);
|
|
282
|
+
if (!resume.ok || !sessionId || relayContext.authenticated !== true || relayContext.ready === true) {
|
|
283
|
+
this.handleRelayProtocolViolation("invalid_resume_calls");
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
this.reconcileRelayCalls(resume.ids);
|
|
287
|
+
this.relayResumeSessionId = sessionId;
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
266
290
|
if (message.type === "ready_ack") {
|
|
291
|
+
const sessionId = Number(relayContext.sessionId) || 0;
|
|
292
|
+
if (!sessionId || sessionId !== this.relayResumeSessionId) {
|
|
293
|
+
this.handleRelayProtocolViolation("resume_calls_required");
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
267
296
|
this.relay?.confirmReady(message);
|
|
297
|
+
this.relayResumeSessionId = 0;
|
|
268
298
|
return true;
|
|
269
299
|
}
|
|
270
300
|
if (message.type === "pong") return true;
|
|
@@ -291,12 +321,12 @@ export class LocalRuntime {
|
|
|
291
321
|
const id = typeof message?.id === "string" && /^probe_[A-Za-z0-9_-]{8,240}$/.test(message.id) ? message.id : "";
|
|
292
322
|
const relaySessionId = Number(relayContext.sessionId) || 0;
|
|
293
323
|
if (!id || !relaySessionId) return this.handleRelayProtocolViolation("invalid_relay_probe");
|
|
294
|
-
this.
|
|
324
|
+
const outcome = this.relay?.sendForSession?.({ type: "relay_probe_result", id }, relaySessionId);
|
|
325
|
+
if (!outcome?.ok) this.handleRelayProtocolViolation("relay_probe_delivery_failed");
|
|
295
326
|
}
|
|
296
327
|
|
|
297
|
-
async handleRelayToolCall(message
|
|
328
|
+
async handleRelayToolCall(message) {
|
|
298
329
|
const envelope = normalizeRelayToolCall(message);
|
|
299
|
-
const relaySessionId = Number(relayContext.sessionId) || 0;
|
|
300
330
|
if (!envelope.ok) {
|
|
301
331
|
this.logger.warn?.("Received an invalid tool request from the relay; the request was rejected.");
|
|
302
332
|
this.logger.event?.("debug", "relay.tool_call.invalid", {
|
|
@@ -307,7 +337,7 @@ export class LocalRuntime {
|
|
|
307
337
|
id: envelope.id,
|
|
308
338
|
ok: false,
|
|
309
339
|
error: { code: "invalid_request", message: "invalid tool_call envelope", retryable: false },
|
|
310
|
-
}
|
|
340
|
+
});
|
|
311
341
|
return;
|
|
312
342
|
}
|
|
313
343
|
if (this.activeRelayCalls.has(envelope.id)) {
|
|
@@ -335,31 +365,15 @@ export class LocalRuntime {
|
|
|
335
365
|
}, "Discarded a tool result because the caller was no longer waiting");
|
|
336
366
|
return;
|
|
337
367
|
}
|
|
338
|
-
this.deliverRelayToolResult(response
|
|
368
|
+
this.deliverRelayToolResult(response);
|
|
339
369
|
} finally {
|
|
340
370
|
this.activeRelayCalls.delete(envelope.id);
|
|
341
371
|
this.suppressedRelayResults.delete(envelope.id);
|
|
342
372
|
}
|
|
343
373
|
}
|
|
344
374
|
|
|
345
|
-
deliverRelayToolResult(response
|
|
346
|
-
|
|
347
|
-
const outcome = this.relay?.sendForSession
|
|
348
|
-
? this.relay.sendForSession(response, sessionId)
|
|
349
|
-
: (this.send(response) ? { ok: true, reason: "sent" } : { ok: false, reason: "transport_unavailable" });
|
|
350
|
-
if (outcome?.ok) return true;
|
|
351
|
-
const reason = String(outcome?.reason || "transport_unavailable");
|
|
352
|
-
const missingSession = reason === "session_ended" && sessionId <= 0;
|
|
353
|
-
this.logger.event?.(missingSession ? "error" : "debug", "relay.tool_result.discarded", {
|
|
354
|
-
call_id: shortCallId(response?.id), reason, relay_session_id: sessionId,
|
|
355
|
-
active_session_id: Number(this.relay?.currentSessionId?.() || 0),
|
|
356
|
-
}, missingSession
|
|
357
|
-
? "Discarded a tool result because the relay session id was missing from the inbound tool_call context"
|
|
358
|
-
: reason === "send_failed"
|
|
359
|
-
? "Could not send a tool result because the relay transport failed"
|
|
360
|
-
: "Discarded a tool result because its relay session had ended");
|
|
361
|
-
if (reason === "send_failed") this.relay?.interrupt?.("relay_transport_error");
|
|
362
|
-
return false;
|
|
375
|
+
deliverRelayToolResult(response) {
|
|
376
|
+
return this.relayCallRecovery.deliver(response);
|
|
363
377
|
}
|
|
364
378
|
|
|
365
379
|
finishCall(callId) {
|
|
@@ -373,18 +387,25 @@ export class LocalRuntime {
|
|
|
373
387
|
|
|
374
388
|
cancelRelayCall(callId, suppressionReason = "caller_cancelled") {
|
|
375
389
|
const id = String(callId);
|
|
390
|
+
const discardedResult = this.relayCallRecovery.discard(id);
|
|
376
391
|
if (this.activeRelayCalls.has(id)) this.suppressedRelayResults.set(id, suppressionReason);
|
|
377
|
-
return this.cancelCall(id, "remote cancellation");
|
|
392
|
+
return this.cancelCall(id, "remote cancellation") || discardedResult;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
reconcileRelayCalls(resumedCallIds) {
|
|
396
|
+
this.relayCallRecovery.reconcile(
|
|
397
|
+
resumedCallIds,
|
|
398
|
+
(callId) => this.cancelRelayCall(callId, "caller_no_longer_waiting"),
|
|
399
|
+
);
|
|
378
400
|
}
|
|
379
401
|
|
|
380
402
|
handleRelayDisconnect() {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
403
|
+
this.relayResumeSessionId = 0;
|
|
404
|
+
this.relayCallRecovery.disconnected();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
handleRelayReady() {
|
|
408
|
+
this.relayCallRecovery.ready();
|
|
388
409
|
}
|
|
389
410
|
|
|
390
411
|
async executeTool(tool, args, context = {}) {
|
|
@@ -16,6 +16,11 @@ export function openRegularFileSync(file, flags, options = {}) {
|
|
|
16
16
|
try {
|
|
17
17
|
const info = fstatSync(fd);
|
|
18
18
|
if (!info.isFile()) throw new Error(`${label} is not a regular file`);
|
|
19
|
+
if (options.verifyPathIdentity === true) {
|
|
20
|
+
const pathInfo = lstatSync(file);
|
|
21
|
+
if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw new Error(`${label} must be a regular file and not a symbolic link`);
|
|
22
|
+
if (!sameFileIdentity(info, pathInfo)) throw new Error(`${label} identity changed while opening`);
|
|
23
|
+
}
|
|
19
24
|
if (Number.isInteger(options.chmod)) setDescriptorMode(fd, options.chmod);
|
|
20
25
|
return { fd, info };
|
|
21
26
|
} catch (error) {
|
|
@@ -37,15 +42,19 @@ export function chmodRegularFileSync(file, mode, label = "path") {
|
|
|
37
42
|
return withRegularFileSync(file, fsConstants.O_RDONLY, { label, chmod: mode }, () => undefined);
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
export function readBoundedRegularFileSync(file, maxBytes, label = "path") {
|
|
41
|
-
return readBoundedRegularFileWithInfoSync(file, maxBytes, label).buffer;
|
|
45
|
+
export function readBoundedRegularFileSync(file, maxBytes, label = "path", options = {}) {
|
|
46
|
+
return readBoundedRegularFileWithInfoSync(file, maxBytes, label, options).buffer;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
export function readBoundedRegularFileWithInfoSync(file, maxBytes, label = "path") {
|
|
49
|
+
export function readBoundedRegularFileWithInfoSync(file, maxBytes, label = "path", options = {}) {
|
|
45
50
|
const limit = Number(maxBytes);
|
|
46
51
|
if (!Number.isSafeInteger(limit) || limit < 0) throw new Error("maximum file size must be a non-negative safe integer");
|
|
47
|
-
return withRegularFileSync(file, fsConstants.O_RDONLY, {
|
|
52
|
+
return withRegularFileSync(file, fsConstants.O_RDONLY, {
|
|
53
|
+
label,
|
|
54
|
+
verifyPathIdentity: options.verifyPathIdentity === true,
|
|
55
|
+
}, (fd, info) => {
|
|
48
56
|
if (info.size > limit) throw new Error(`file exceeds ${limit} bytes`);
|
|
57
|
+
options.afterOpen?.({ fd, info });
|
|
49
58
|
const buffer = Buffer.alloc(info.size);
|
|
50
59
|
let offset = 0;
|
|
51
60
|
while (offset < buffer.length) {
|
|
@@ -104,3 +113,14 @@ function setDescriptorMode(fd, mode) {
|
|
|
104
113
|
if (process.platform !== "win32") throw error;
|
|
105
114
|
}
|
|
106
115
|
}
|
|
116
|
+
|
|
117
|
+
function sameFileIdentity(left, right) {
|
|
118
|
+
const leftDevice = Number(left.dev);
|
|
119
|
+
const rightDevice = Number(right.dev);
|
|
120
|
+
const leftInode = Number(left.ino);
|
|
121
|
+
const rightInode = Number(right.ino);
|
|
122
|
+
if ([leftDevice, rightDevice, leftInode, rightInode].every((value) => Number.isSafeInteger(value) && value >= 0)) {
|
|
123
|
+
return leftDevice === rightDevice && leftInode === rightInode;
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
package/src/local/tools.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
|
|
2
|
+
import { projectMcpResult } from "../shared/result-projection.mjs";
|
|
2
3
|
export {
|
|
3
4
|
DEFAULT_POLICY_PROFILE,
|
|
4
5
|
DEFAULT_POLICY_REVISION,
|
|
@@ -28,10 +29,9 @@ export const MCP_INSTRUCTIONS = Object.freeze(serverMetadata.instructions.map((v
|
|
|
28
29
|
export function toolResult(value, isError = false) {
|
|
29
30
|
const special = specialMcpResult(value);
|
|
30
31
|
if (special) return { ...special, isError };
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
if (structuredContent) result.structuredContent = structuredContent;
|
|
32
|
+
const projection = projectMcpResult(value);
|
|
33
|
+
const result = { content: [{ type: "text", text: projection.text }], isError };
|
|
34
|
+
if (projection.structuredContent) result.structuredContent = projection.structuredContent;
|
|
35
35
|
return result;
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -55,8 +55,3 @@ function specialMcpResult(value) {
|
|
|
55
55
|
}
|
|
56
56
|
return result;
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
function toStructuredContent(value) {
|
|
60
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
61
|
-
return value;
|
|
62
|
-
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import resultProjection from "./result-projection.json" with { type: "json" };
|
|
4
|
+
|
|
5
|
+
export const MCP_TEXT_PROJECTION_KEY = "$mcpText";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keep MCP text content useful for human-only clients without duplicating a
|
|
9
|
+
* large object that is already present in structuredContent.
|
|
10
|
+
* @param {unknown} value
|
|
11
|
+
*/
|
|
12
|
+
export function mirroredResultText(value) {
|
|
13
|
+
if (typeof value === "string") return value;
|
|
14
|
+
const serialized = JSON.stringify(value, null, 2);
|
|
15
|
+
if (typeof serialized !== "string") return String(value ?? "");
|
|
16
|
+
const bytes = new TextEncoder().encode(serialized).byteLength;
|
|
17
|
+
if (bytes <= Number(resultProjection.maxMirroredJsonBytes)) return serialized;
|
|
18
|
+
const object = asObject(value);
|
|
19
|
+
const keys = Object.keys(object)
|
|
20
|
+
.slice(0, Number(resultProjection.maximumSummaryKeys))
|
|
21
|
+
.map((key) => key.replace(/[\r\n\t]+/g, " ").slice(0, 80));
|
|
22
|
+
const fields = keys.length ? ` Fields: ${keys.join(", ")}.` : "";
|
|
23
|
+
return `Structured result is ${bytes} bytes and is available in structuredContent.${fields}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extract an optional domain-provided text projection while keeping the
|
|
28
|
+
* authoritative structured object single-copy and marker-free.
|
|
29
|
+
* @param {unknown} value
|
|
30
|
+
*/
|
|
31
|
+
export function projectMcpResult(value) {
|
|
32
|
+
const object = asObject(value);
|
|
33
|
+
if (Object.prototype.hasOwnProperty.call(object, MCP_TEXT_PROJECTION_KEY)
|
|
34
|
+
&& typeof object[MCP_TEXT_PROJECTION_KEY] === "string") {
|
|
35
|
+
const structuredContent = { ...object };
|
|
36
|
+
const text = String(structuredContent[MCP_TEXT_PROJECTION_KEY]).slice(0, 4096);
|
|
37
|
+
delete structuredContent[MCP_TEXT_PROJECTION_KEY];
|
|
38
|
+
return { text, structuredContent };
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
text: mirroredResultText(value),
|
|
42
|
+
structuredContent: value && typeof value === "object" && !Array.isArray(value) ? value : null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @param {unknown} value */
|
|
47
|
+
function asObject(value) {
|
|
48
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"Managed-job resource redaction is defense in depth, not a guarantee against transformed or partial secret output. Use capture_output=discard for steps that may echo credentials.",
|
|
25
25
|
"Do not use shell encoding, renaming, or alternate tools to bypass host or platform safety policy.",
|
|
26
26
|
"run_process avoids shell parsing but is not an OS sandbox. exec_command is exposed only in shell mode and has the local user's authority.",
|
|
27
|
+
"For commands that may produce substantial output, prefer start_process/read_process or use the output_session_id returned by a truncated run_process, run_local_command, or exec_command result. Read output in bounded pages instead of repeatedly requesting a larger one-shot response.",
|
|
27
28
|
"Per-tool success, failure, cancellation, and timing events are debug-only; default logs contain lifecycle and infrastructure events, not a tool-call transcript.",
|
|
28
29
|
"Inspect before editing, prefer read_file line ranges, edit_file, or apply_patch over whole-file replacement, and report commands that were run."
|
|
29
30
|
]
|
|
@@ -1230,7 +1230,7 @@
|
|
|
1230
1230
|
{
|
|
1231
1231
|
"name": "run_local_command",
|
|
1232
1232
|
"title": "Run registered local command",
|
|
1233
|
-
"description": "Run an effective manifest or automatic package-script command through its fixed argv, cwd, timeout ceiling, and extra-argument policy.",
|
|
1233
|
+
"description": "Run an effective manifest or automatic package-script command through its fixed argv, cwd, timeout ceiling, and extra-argument policy. Large stdout/stderr is previewed inline and retained temporarily for paged read_process continuation.",
|
|
1234
1234
|
"availability": "direct-exec",
|
|
1235
1235
|
"annotations": {
|
|
1236
1236
|
"readOnlyHint": false,
|
|
@@ -1665,7 +1665,7 @@
|
|
|
1665
1665
|
{
|
|
1666
1666
|
"name": "run_process",
|
|
1667
1667
|
"title": "Run process directly",
|
|
1668
|
-
"description": "Execute an argv array without a command shell. This avoids shell parsing but does not sandbox the executable or code it launches.",
|
|
1668
|
+
"description": "Execute an argv array without a command shell. This avoids shell parsing but does not sandbox the executable or code it launches. Large stdout/stderr is previewed inline and retained temporarily for paged read_process continuation.",
|
|
1669
1669
|
"availability": "direct-exec",
|
|
1670
1670
|
"annotations": {
|
|
1671
1671
|
"readOnlyHint": false,
|
|
@@ -1737,7 +1737,7 @@
|
|
|
1737
1737
|
{
|
|
1738
1738
|
"name": "read_process",
|
|
1739
1739
|
"title": "Read process session",
|
|
1740
|
-
"description": "Read bounded stdout and stderr deltas from a
|
|
1740
|
+
"description": "Read bounded stdout and stderr deltas from a running process session or a recently completed one-shot command continuation, optionally waiting briefly for new output.",
|
|
1741
1741
|
"availability": "direct-exec",
|
|
1742
1742
|
"annotations": {
|
|
1743
1743
|
"readOnlyHint": true,
|
|
@@ -2382,7 +2382,7 @@
|
|
|
2382
2382
|
{
|
|
2383
2383
|
"name": "exec_command",
|
|
2384
2384
|
"title": "Execute shell command",
|
|
2385
|
-
"description": "Execute a shell command with workspace cwd. This is not a sandbox and has the operating-system authority of the local user.",
|
|
2385
|
+
"description": "Execute a shell command with workspace cwd. This is not a sandbox and has the operating-system authority of the local user. Large stdout/stderr is previewed inline and retained temporarily for paged read_process continuation.",
|
|
2386
2386
|
"availability": "shell-exec",
|
|
2387
2387
|
"annotations": {
|
|
2388
2388
|
"readOnlyHint": false,
|
|
@@ -7,6 +7,7 @@ export interface DaemonAttachment {
|
|
|
7
7
|
connectedAt: string;
|
|
8
8
|
lastSeenAt?: string;
|
|
9
9
|
probeId?: string;
|
|
10
|
+
instanceId?: string;
|
|
10
11
|
policy?: DaemonPolicy;
|
|
11
12
|
tools?: string[];
|
|
12
13
|
}
|
|
@@ -29,6 +30,7 @@ export class DaemonSocketRegistry {
|
|
|
29
30
|
connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
|
|
30
31
|
lastSeenAt: sanitizeMetadataText(candidate.lastSeenAt, 64),
|
|
31
32
|
probeId: sanitizeProbeId(candidate.probeId),
|
|
33
|
+
instanceId: sanitizeDaemonInstanceId(candidate.instanceId),
|
|
32
34
|
policy,
|
|
33
35
|
tools: sanitizeDaemonTools(candidate.tools, policy),
|
|
34
36
|
};
|
|
@@ -54,12 +56,13 @@ export class DaemonSocketRegistry {
|
|
|
54
56
|
socket.serializeAttachment({ role: "candidate", connectedAt } satisfies DaemonAttachment);
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; policy: DaemonPolicy; tools: string[] }): void {
|
|
59
|
+
beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; instanceId: string; policy: DaemonPolicy; tools: string[] }): void {
|
|
58
60
|
socket.serializeAttachment({
|
|
59
61
|
role: "probing",
|
|
60
62
|
connectedAt: values.connectedAt,
|
|
61
63
|
lastSeenAt: values.connectedAt,
|
|
62
64
|
probeId: values.probeId,
|
|
65
|
+
instanceId: values.instanceId,
|
|
63
66
|
policy: values.policy,
|
|
64
67
|
tools: values.tools,
|
|
65
68
|
} satisfies DaemonAttachment);
|
|
@@ -89,6 +92,7 @@ export class DaemonSocketRegistry {
|
|
|
89
92
|
role: "expired",
|
|
90
93
|
connectedAt: attachment.connectedAt,
|
|
91
94
|
lastSeenAt: attachment.lastSeenAt,
|
|
95
|
+
instanceId: attachment.instanceId,
|
|
92
96
|
} satisfies DaemonAttachment);
|
|
93
97
|
}
|
|
94
98
|
|
|
@@ -105,3 +109,8 @@ function sanitizeProbeId(value: unknown): string | undefined {
|
|
|
105
109
|
if (typeof value !== "string" || !/^probe_[A-Za-z0-9_-]{8,240}$/.test(value)) return undefined;
|
|
106
110
|
return value;
|
|
107
111
|
}
|
|
112
|
+
|
|
113
|
+
function sanitizeDaemonInstanceId(value: unknown): string | undefined {
|
|
114
|
+
if (typeof value !== "string" || !/^daemon_[A-Za-z0-9_-]{16,96}$/.test(value)) return undefined;
|
|
115
|
+
return value;
|
|
116
|
+
}
|