machine-bridge-mcp 1.2.1 → 1.2.5
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 +37 -1
- package/README.md +2 -0
- package/SECURITY.md +1 -1
- package/browser-extension/manifest.json +2 -2
- package/docs/AGENT_CONTEXT.md +1 -1
- package/docs/ARCHITECTURE.md +14 -13
- package/docs/AUDIT.md +24 -0
- package/docs/ENGINEERING.md +1 -1
- package/docs/LOGGING.md +12 -8
- package/docs/OPERATIONS.md +10 -5
- package/docs/TESTING.md +3 -3
- package/docs/UPGRADING.md +5 -3
- package/package.json +1 -1
- package/src/local/call-registry.mjs +9 -3
- package/src/local/capability-ranking.mjs +3 -2
- package/src/local/log.mjs +10 -2
- package/src/local/relay-connection.mjs +99 -12
- package/src/local/runtime.mjs +86 -28
- package/src/local/tool-executor.mjs +3 -3
- package/src/worker/daemon-liveness.ts +63 -0
- package/src/worker/daemon-sockets.ts +107 -0
- package/src/worker/index.ts +210 -113
- package/src/worker/observability.ts +5 -2
- package/src/worker/pending-calls.ts +17 -0
- package/wrangler.jsonc +1 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { isLiveDaemonAttachment, withDaemonLastSeenAt, type DaemonRole } from "./daemon-liveness.ts";
|
|
2
|
+
import { sanitizeMetadataText } from "./http.ts";
|
|
3
|
+
import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy.ts";
|
|
4
|
+
|
|
5
|
+
export interface DaemonAttachment {
|
|
6
|
+
role: DaemonRole;
|
|
7
|
+
connectedAt: string;
|
|
8
|
+
lastSeenAt?: string;
|
|
9
|
+
probeId?: string;
|
|
10
|
+
policy?: DaemonPolicy;
|
|
11
|
+
tools?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface WebSocketContext {
|
|
15
|
+
getWebSockets(): WebSocket[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class DaemonSocketRegistry {
|
|
19
|
+
constructor(private readonly context: WebSocketContext) {}
|
|
20
|
+
|
|
21
|
+
attachment(socket: WebSocket): DaemonAttachment | undefined {
|
|
22
|
+
const raw = socket.deserializeAttachment();
|
|
23
|
+
if (!raw || typeof raw !== "object") return undefined;
|
|
24
|
+
const candidate = raw as Partial<DaemonAttachment>;
|
|
25
|
+
if (!["candidate", "probing", "expired", "daemon"].includes(String(candidate.role))) return undefined;
|
|
26
|
+
const policy = sanitizeDaemonPolicy(candidate.policy);
|
|
27
|
+
return {
|
|
28
|
+
role: candidate.role as DaemonRole,
|
|
29
|
+
connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
|
|
30
|
+
lastSeenAt: sanitizeMetadataText(candidate.lastSeenAt, 64),
|
|
31
|
+
probeId: sanitizeProbeId(candidate.probeId),
|
|
32
|
+
policy,
|
|
33
|
+
tools: sanitizeDaemonTools(candidate.tools, policy),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
readyAttachment(socket: WebSocket): DaemonAttachment | undefined {
|
|
38
|
+
const attachment = this.attachment(socket);
|
|
39
|
+
return attachment?.role === "daemon" ? attachment : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
candidateSockets(): WebSocket[] { return this.openSockets("candidate"); }
|
|
43
|
+
probingSockets(): WebSocket[] { return this.openSockets("probing"); }
|
|
44
|
+
readyRoleSockets(): WebSocket[] { return this.openSockets("daemon").sort((left, right) => this.connectedAt(right) - this.connectedAt(left)); }
|
|
45
|
+
readySockets(now = Date.now()): WebSocket[] {
|
|
46
|
+
return this.readyRoleSockets().filter((socket) => isLiveDaemonAttachment(this.readyAttachment(socket), now));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
nonReadySockets(): WebSocket[] {
|
|
50
|
+
return this.context.getWebSockets().filter((socket) => this.attachment(socket)?.role !== "daemon" && socket.readyState === WebSocket.OPEN);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beginCandidate(socket: WebSocket, connectedAt = new Date().toISOString()): void {
|
|
54
|
+
socket.serializeAttachment({ role: "candidate", connectedAt } satisfies DaemonAttachment);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beginProbe(socket: WebSocket, values: { connectedAt: string; probeId: string; policy: DaemonPolicy; tools: string[] }): void {
|
|
58
|
+
socket.serializeAttachment({
|
|
59
|
+
role: "probing",
|
|
60
|
+
connectedAt: values.connectedAt,
|
|
61
|
+
lastSeenAt: values.connectedAt,
|
|
62
|
+
probeId: values.probeId,
|
|
63
|
+
policy: values.policy,
|
|
64
|
+
tools: values.tools,
|
|
65
|
+
} satisfies DaemonAttachment);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
promote(socket: WebSocket, lastSeenAt = new Date().toISOString()): DaemonAttachment | undefined {
|
|
69
|
+
const attachment = this.attachment(socket);
|
|
70
|
+
if (attachment?.role !== "probing") return undefined;
|
|
71
|
+
const ready = { ...attachment, role: "daemon" as const, lastSeenAt };
|
|
72
|
+
delete ready.probeId;
|
|
73
|
+
socket.serializeAttachment(ready satisfies DaemonAttachment);
|
|
74
|
+
return ready;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
touch(socket: WebSocket, lastSeenAt = new Date().toISOString()): DaemonAttachment | undefined {
|
|
78
|
+
const attachment = this.attachment(socket);
|
|
79
|
+
if (!attachment || (attachment.role !== "probing" && attachment.role !== "daemon")) return undefined;
|
|
80
|
+
const touched = withDaemonLastSeenAt(attachment, lastSeenAt);
|
|
81
|
+
socket.serializeAttachment(touched satisfies DaemonAttachment);
|
|
82
|
+
return touched;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
expire(socket: WebSocket): void {
|
|
86
|
+
const attachment = this.attachment(socket);
|
|
87
|
+
if (!attachment) return;
|
|
88
|
+
socket.serializeAttachment({
|
|
89
|
+
role: "expired",
|
|
90
|
+
connectedAt: attachment.connectedAt,
|
|
91
|
+
lastSeenAt: attachment.lastSeenAt,
|
|
92
|
+
} satisfies DaemonAttachment);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private openSockets(role: DaemonRole): WebSocket[] {
|
|
96
|
+
return this.context.getWebSockets().filter((socket) => this.attachment(socket)?.role === role && socket.readyState === WebSocket.OPEN);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private connectedAt(socket: WebSocket): number {
|
|
100
|
+
return Date.parse(this.attachment(socket)?.connectedAt ?? "") || 0;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sanitizeProbeId(value: unknown): string | undefined {
|
|
105
|
+
if (typeof value !== "string" || !/^probe_[A-Za-z0-9_-]{8,240}$/.test(value)) return undefined;
|
|
106
|
+
return value;
|
|
107
|
+
}
|
package/src/worker/index.ts
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
2
|
import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
|
|
3
3
|
import { PendingCallRegistrationError, PendingCallRegistry } from "./pending-calls.ts";
|
|
4
|
+
import {
|
|
5
|
+
DAEMON_HELLO_TIMEOUT_MS,
|
|
6
|
+
DAEMON_LIVENESS_TIMEOUT_MS,
|
|
7
|
+
DAEMON_READY_TIMEOUT_MS,
|
|
8
|
+
daemonLastSeenMs,
|
|
9
|
+
daemonLivenessDeadlineMs,
|
|
10
|
+
daemonReadyDeadlineMs,
|
|
11
|
+
isFreshDaemonCandidate,
|
|
12
|
+
} from "./daemon-liveness.ts";
|
|
13
|
+
import { DaemonSocketRegistry } from "./daemon-sockets.ts";
|
|
4
14
|
import { mcpClientRequestKey, resolveMcpSession } from "./mcp-session.ts";
|
|
5
15
|
import { daemonToolTimeoutMs } from "./tool-timeout.ts";
|
|
6
16
|
import { WorkerObservability } from "./observability.ts";
|
|
7
17
|
import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors.ts";
|
|
8
|
-
import { sanitizeDaemonPolicy, sanitizeDaemonTools
|
|
18
|
+
import { sanitizeDaemonPolicy, sanitizeDaemonTools } from "./policy.ts";
|
|
9
19
|
import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access.ts";
|
|
10
20
|
import { OAuthController, type AuthorizedToken, type OAuthControllerEnv } from "./oauth-controller.ts";
|
|
11
21
|
import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority.ts";
|
|
12
22
|
import { serverInfoTool, workspaceTools } from "./tool-catalog.ts";
|
|
13
23
|
import { OFFLINE_ACCESS_SCOPE, randomToken, safeEqual } from "./oauth-state.ts";
|
|
14
24
|
import {
|
|
15
|
-
HttpError, applyCors, baseUrl, bearerToken, corsPreflight, json, methodNotAllowed,
|
|
25
|
+
HttpError, applyCors, baseUrl, bearerToken, corsPreflight, json, methodNotAllowed,
|
|
16
26
|
parseJsonRequest, workerErrorClass,
|
|
17
27
|
} from "./http.ts";
|
|
18
28
|
|
|
19
29
|
const SERVER_NAME = String(serverMetadata.name);
|
|
20
|
-
const SERVER_VERSION = "1.2.
|
|
30
|
+
const SERVER_VERSION = "1.2.5";
|
|
21
31
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
22
32
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
23
33
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -25,7 +35,6 @@ const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
|
25
35
|
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
26
36
|
const MAX_PENDING_CALLS = 32;
|
|
27
37
|
const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
28
|
-
const DAEMON_HELLO_TIMEOUT_MS = 10_000;
|
|
29
38
|
|
|
30
39
|
interface BridgeEnv extends OAuthControllerEnv {
|
|
31
40
|
BRIDGE: DurableObjectNamespace<BridgeRoom>;
|
|
@@ -45,23 +54,18 @@ interface JsonRpcRequest {
|
|
|
45
54
|
params?: unknown;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
interface DaemonAttachment {
|
|
49
|
-
role: "candidate" | "expired" | "daemon";
|
|
50
|
-
connectedAt: string;
|
|
51
|
-
policy?: DaemonPolicy;
|
|
52
|
-
tools?: string[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
57
|
const MCP_INSTRUCTIONS = serverMetadata.instructions.map((value) => String(value)).join("\n");
|
|
56
58
|
|
|
57
59
|
export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
58
60
|
private readonly pending = new PendingCallRegistry(MAX_PENDING_CALLS);
|
|
59
61
|
private readonly observability = new WorkerObservability();
|
|
60
62
|
private readonly oauth: OAuthController;
|
|
63
|
+
private readonly daemonRegistry: DaemonSocketRegistry;
|
|
61
64
|
|
|
62
65
|
constructor(ctx: DurableObjectState, env: BridgeEnv) {
|
|
63
66
|
super(ctx, env);
|
|
64
67
|
this.oauth = new OAuthController(ctx, env, SERVER_NAME);
|
|
68
|
+
this.daemonRegistry = new DaemonSocketRegistry(ctx);
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
async fetch(request: Request): Promise<Response> {
|
|
@@ -156,7 +160,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
156
160
|
}
|
|
157
161
|
const body = parsed;
|
|
158
162
|
|
|
159
|
-
const socketAttachment = this.
|
|
163
|
+
const socketAttachment = this.daemonRegistry.attachment(ws);
|
|
160
164
|
if (!socketAttachment) {
|
|
161
165
|
closeWebSocketQuietly(ws, 1008, "missing daemon attachment");
|
|
162
166
|
return;
|
|
@@ -168,60 +172,87 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
168
172
|
}
|
|
169
173
|
|
|
170
174
|
if (body.type === "hello") {
|
|
171
|
-
if (socketAttachment.role
|
|
175
|
+
if (socketAttachment.role !== "candidate") {
|
|
172
176
|
rejectDaemonMessage(ws, "duplicate_hello", 1002, "duplicate daemon hello");
|
|
173
177
|
return;
|
|
174
178
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
await this.scheduleCandidateAlarm();
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
179
|
+
if (!isFreshDaemonCandidate(socketAttachment.connectedAt)) {
|
|
180
|
+
closeWebSocketQuietly(ws, 1008, "stale daemon candidate");
|
|
181
|
+
await this.scheduleSocketAlarms();
|
|
182
|
+
return;
|
|
182
183
|
}
|
|
183
184
|
const daemonPolicy = sanitizeDaemonPolicy(body.policy);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
const authenticatedAt = new Date().toISOString();
|
|
186
|
+
const probeId = randomToken("probe");
|
|
187
|
+
this.daemonRegistry.beginProbe(ws, {
|
|
188
|
+
connectedAt: authenticatedAt,
|
|
189
|
+
probeId,
|
|
187
190
|
policy: daemonPolicy,
|
|
188
191
|
tools: sanitizeDaemonTools(body.tools, daemonPolicy),
|
|
189
|
-
}
|
|
192
|
+
});
|
|
190
193
|
this.observability.socketAuthenticated();
|
|
191
|
-
await this.scheduleCandidateAlarm();
|
|
192
194
|
try {
|
|
193
195
|
ws.send(JSON.stringify({ type: "hello_ack", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
196
|
+
ws.send(JSON.stringify({ type: "relay_probe", id: probeId }));
|
|
194
197
|
} catch {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
} satisfies DaemonAttachment);
|
|
199
|
-
closeWebSocketQuietly(ws, 1011, "daemon hello acknowledgement failed");
|
|
198
|
+
this.daemonRegistry.expire(ws);
|
|
199
|
+
closeWebSocketQuietly(ws, 1011, "daemon readiness probe failed");
|
|
200
|
+
await this.scheduleSocketAlarms();
|
|
200
201
|
return;
|
|
201
202
|
}
|
|
202
|
-
|
|
203
|
-
closeWebSocketQuietly(socket, 1012, "replaced by authenticated daemon");
|
|
204
|
-
}
|
|
203
|
+
await this.scheduleSocketAlarms();
|
|
205
204
|
return;
|
|
206
205
|
}
|
|
207
206
|
|
|
208
|
-
if (socketAttachment.role
|
|
207
|
+
if (socketAttachment.role === "candidate") {
|
|
209
208
|
closeWebSocketQuietly(ws, 1008, "daemon hello required");
|
|
210
209
|
return;
|
|
211
210
|
}
|
|
212
211
|
|
|
213
212
|
if (body.type === "heartbeat" || body.type === "ping") {
|
|
213
|
+
await this.touchDaemonSocket(ws);
|
|
214
214
|
ws.send(JSON.stringify({ type: "pong", ts: body.ts ?? Date.now() }));
|
|
215
215
|
return;
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
if (socketAttachment.role === "probing") {
|
|
219
|
+
if (body.type !== "relay_probe_result" || typeof body.id !== "string" || body.id !== socketAttachment.probeId) {
|
|
220
|
+
rejectDaemonMessage(ws, "invalid_relay_probe_result", 1002, "invalid daemon readiness result");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const readyAt = new Date().toISOString();
|
|
224
|
+
if (!this.daemonRegistry.promote(ws, readyAt)) {
|
|
225
|
+
rejectDaemonMessage(ws, "invalid_relay_readiness_state", 1002, "invalid daemon readiness state");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
ws.send(JSON.stringify({ type: "ready_ack", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
230
|
+
} catch {
|
|
231
|
+
this.invalidateDaemonSocket(ws, "daemon readiness acknowledgement failed", "daemon ready timeout", "daemon_ready_timeout");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
this.observability.socketReady();
|
|
235
|
+
for (const previous of this.daemonRegistry.readyRoleSockets().filter((socket) => socket !== ws)) {
|
|
236
|
+
closeWebSocketQuietly(previous, 1012, "replaced by verified daemon");
|
|
237
|
+
}
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (socketAttachment.role !== "daemon") {
|
|
242
|
+
closeWebSocketQuietly(ws, 1008, "daemon readiness required");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
218
246
|
if (body.type !== "tool_result" || typeof body.id !== "string") {
|
|
219
247
|
rejectDaemonMessage(ws, "unknown_message_type", 1002, "unknown daemon message type");
|
|
220
248
|
return;
|
|
221
249
|
}
|
|
222
250
|
|
|
223
|
-
|
|
224
|
-
|
|
251
|
+
await this.touchDaemonSocket(ws);
|
|
252
|
+
const matched = body.ok === false
|
|
253
|
+
? this.pending.reject(body.id, daemonToolError(body.error), ws)
|
|
254
|
+
: this.pending.resolve(body.id, ws, body.result);
|
|
255
|
+
if (!matched) this.observability.unmatchedResult();
|
|
225
256
|
}
|
|
226
257
|
|
|
227
258
|
async webSocketClose(ws: WebSocket): Promise<void> {
|
|
@@ -235,8 +266,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
235
266
|
|
|
236
267
|
private async cleanupDaemonSocket(ws: WebSocket, message: string): Promise<void> {
|
|
237
268
|
this.observability.socketDisconnected();
|
|
238
|
-
await this.scheduleCandidateAlarm();
|
|
239
269
|
this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
|
|
270
|
+
await this.scheduleSocketAlarms();
|
|
240
271
|
}
|
|
241
272
|
|
|
242
273
|
private async handleMcp(request: Request, base: string): Promise<Response> {
|
|
@@ -269,19 +300,31 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
269
300
|
|
|
270
301
|
const session = await resolveMcpSession(request, body.method, this.oauth.identityKey(), authorized.tokenKey);
|
|
271
302
|
if (session.kind === "invalid") return json(rpcError(body.id, -32001, "MCP session not found"), 404);
|
|
272
|
-
const response = await this.dispatchJsonRpc(
|
|
303
|
+
const response = await this.dispatchJsonRpc(
|
|
304
|
+
body,
|
|
305
|
+
base,
|
|
306
|
+
authorized,
|
|
307
|
+
session.kind === "active" ? session.sessionId : "",
|
|
308
|
+
request.signal,
|
|
309
|
+
);
|
|
273
310
|
if (response === null) return new Response(null, { status: 202 });
|
|
274
311
|
return session.kind === "initialize" ? json(response, 200, { "mcp-session-id": session.sessionId }) : json(response);
|
|
275
312
|
}
|
|
276
313
|
|
|
277
|
-
private async dispatchJsonRpc(
|
|
314
|
+
private async dispatchJsonRpc(
|
|
315
|
+
request: JsonRpcRequest,
|
|
316
|
+
base: string,
|
|
317
|
+
authorized: AuthorizedToken,
|
|
318
|
+
sessionId: string,
|
|
319
|
+
signal?: AbortSignal,
|
|
320
|
+
): Promise<Record<string, unknown> | null> {
|
|
278
321
|
if (request.method === "initialize") {
|
|
279
322
|
const requested = asObject(request.params).protocolVersion;
|
|
280
323
|
const protocolVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])
|
|
281
324
|
? requested
|
|
282
325
|
: MCP_PROTOCOL_VERSION;
|
|
283
326
|
const bootstrap = this.daemonToolEnabled("session_bootstrap")
|
|
284
|
-
? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized).catch(() => null)
|
|
327
|
+
? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized, undefined, signal).catch(() => null)
|
|
285
328
|
: null;
|
|
286
329
|
const localInstructions = sessionInstructionText(bootstrap);
|
|
287
330
|
return rpcResult(request.id, {
|
|
@@ -310,7 +353,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
310
353
|
const name = requiredString(params, "name");
|
|
311
354
|
const args = asObject(params.arguments);
|
|
312
355
|
try {
|
|
313
|
-
const result = await this.callTool(
|
|
356
|
+
const result = await this.callTool(
|
|
357
|
+
name,
|
|
358
|
+
args,
|
|
359
|
+
base,
|
|
360
|
+
authorized,
|
|
361
|
+
mcpClientRequestKey(authorized.tokenKey, sessionId, request.id),
|
|
362
|
+
signal,
|
|
363
|
+
);
|
|
314
364
|
return rpcResult(request.id, textToolResult(result));
|
|
315
365
|
} catch (error) {
|
|
316
366
|
return rpcResult(request.id, textToolResult({ error: publicWorkerToolError(error) }, true));
|
|
@@ -318,7 +368,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
318
368
|
}
|
|
319
369
|
return rpcError(request.id, -32601, `Method not found: ${request.method}`);
|
|
320
370
|
}
|
|
321
|
-
private async callTool(
|
|
371
|
+
private async callTool(
|
|
372
|
+
name: string,
|
|
373
|
+
args: Record<string, unknown>,
|
|
374
|
+
base: string,
|
|
375
|
+
authorized: AuthorizedToken,
|
|
376
|
+
requestKey?: string,
|
|
377
|
+
signal?: AbortSignal,
|
|
378
|
+
): Promise<unknown> {
|
|
322
379
|
if (name === "server_info") {
|
|
323
380
|
const { daemon, tools, authorization } = this.authorityContext(authorized);
|
|
324
381
|
return {
|
|
@@ -332,7 +389,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
332
389
|
daemon,
|
|
333
390
|
worker: {
|
|
334
391
|
pending_calls: this.pending.snapshot(),
|
|
335
|
-
daemon_candidates: this.candidateSockets().length,
|
|
392
|
+
daemon_candidates: this.daemonRegistry.candidateSockets().length,
|
|
393
|
+
daemon_probes: this.daemonRegistry.probingSockets().length,
|
|
394
|
+
sockets_live: {
|
|
395
|
+
authenticated: this.daemonRegistry.readyRoleSockets().length + this.daemonRegistry.probingSockets().length,
|
|
396
|
+
ready: this.daemonRegistry.readySockets().length,
|
|
397
|
+
probing: this.daemonRegistry.probingSockets().length,
|
|
398
|
+
candidates: this.daemonRegistry.candidateSockets().length,
|
|
399
|
+
},
|
|
336
400
|
observability: this.observability.snapshot(),
|
|
337
401
|
},
|
|
338
402
|
tools,
|
|
@@ -351,14 +415,21 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
351
415
|
if (workspaceTools.some((tool) => tool.name === name)) {
|
|
352
416
|
if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
|
|
353
417
|
if (!accountRoleAllowsTool(authorized.role, name)) throw new WorkerToolError("authorization_denied", "tool is not allowed for this account role");
|
|
354
|
-
const result = await this.callDaemonTool(name, args, authorized, requestKey);
|
|
418
|
+
const result = await this.callDaemonTool(name, args, authorized, requestKey, signal);
|
|
355
419
|
return name === "project_overview" ? decorateProjectOverview(result, { accountId: authorized.accountId,
|
|
356
420
|
accountVersion: authorized.accountVersion, role: authorized.role }) : result;
|
|
357
421
|
}
|
|
358
422
|
throw new Error(`unknown tool: ${name}`);
|
|
359
423
|
}
|
|
360
|
-
private async callDaemonTool(
|
|
361
|
-
|
|
424
|
+
private async callDaemonTool(
|
|
425
|
+
name: string,
|
|
426
|
+
args: Record<string, unknown>,
|
|
427
|
+
authorized: AuthorizedToken,
|
|
428
|
+
requestKey?: string,
|
|
429
|
+
signal?: AbortSignal,
|
|
430
|
+
): Promise<unknown> {
|
|
431
|
+
this.reclaimStaleDaemonSockets();
|
|
432
|
+
const socket = this.daemonRegistry.readySockets()[0];
|
|
362
433
|
if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
|
|
363
434
|
const id = randomToken("call");
|
|
364
435
|
const timeoutMs = daemonToolTimeoutMs(name, args);
|
|
@@ -372,8 +443,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
372
443
|
timeoutMs,
|
|
373
444
|
onTimeout: (record) => {
|
|
374
445
|
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
446
|
+
const silentForMs = Date.now() - daemonLastSeenMs(this.daemonRegistry.readyAttachment(record.socket));
|
|
447
|
+
if (!Number.isFinite(silentForMs) || silentForMs > 45_000) {
|
|
448
|
+
this.invalidateDaemonSocket(record.socket, "daemon became unresponsive", "daemon liveness timeout");
|
|
449
|
+
}
|
|
375
450
|
return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
|
|
376
451
|
},
|
|
452
|
+
signal,
|
|
453
|
+
onAbort: (record) => {
|
|
454
|
+
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
455
|
+
return new WorkerToolError("cancelled", "MCP client stopped waiting for the tool result");
|
|
456
|
+
},
|
|
377
457
|
});
|
|
378
458
|
} catch (error) {
|
|
379
459
|
if (error instanceof PendingCallRegistrationError) {
|
|
@@ -382,13 +462,16 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
382
462
|
throw error;
|
|
383
463
|
}
|
|
384
464
|
this.observability.callStarted(name);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
465
|
+
if (!signal?.aborted) {
|
|
466
|
+
try {
|
|
467
|
+
socket.send(JSON.stringify({
|
|
468
|
+
type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs,
|
|
469
|
+
authorization: { account_id: authorized.accountId, account_version: authorized.accountVersion, role: authorized.role },
|
|
470
|
+
}));
|
|
471
|
+
} catch {
|
|
472
|
+
this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
|
|
473
|
+
this.invalidateDaemonSocket(socket, "failed to send daemon tool call", "daemon send failed");
|
|
474
|
+
}
|
|
392
475
|
}
|
|
393
476
|
try {
|
|
394
477
|
const value = await result;
|
|
@@ -413,7 +496,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
413
496
|
const supplied = request.headers.get("X-Bridge-Token") ?? "";
|
|
414
497
|
if (!expected || !(await safeEqual(supplied, expected))) return new Response("Unauthorized daemon", { status: 401 });
|
|
415
498
|
|
|
416
|
-
for (const socket of this.
|
|
499
|
+
for (const socket of this.daemonRegistry.nonReadySockets()) {
|
|
417
500
|
closeWebSocketQuietly(socket, 1012, "replaced by newer daemon candidate");
|
|
418
501
|
}
|
|
419
502
|
|
|
@@ -421,11 +504,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
421
504
|
const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
|
|
422
505
|
this.ctx.acceptWebSocket(server);
|
|
423
506
|
this.observability.socketCandidate();
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
connectedAt: new Date().toISOString(),
|
|
427
|
-
} satisfies DaemonAttachment);
|
|
428
|
-
await this.ctx.storage.setAlarm(Date.now() + DAEMON_HELLO_TIMEOUT_MS);
|
|
507
|
+
this.daemonRegistry.beginCandidate(server);
|
|
508
|
+
await this.scheduleSocketAlarms();
|
|
429
509
|
server.send(JSON.stringify({ type: "welcome", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
430
510
|
return new Response(null, { status: 101, webSocket: client });
|
|
431
511
|
}
|
|
@@ -446,79 +526,80 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
446
526
|
return this.daemonAdvertisedTools().has(name);
|
|
447
527
|
}
|
|
448
528
|
private daemonAdvertisedTools(): Set<string> {
|
|
449
|
-
|
|
529
|
+
this.reclaimStaleDaemonSockets();
|
|
530
|
+
const socket = this.daemonRegistry.readySockets()[0];
|
|
450
531
|
if (!socket) return new Set();
|
|
451
|
-
const attachment = this.
|
|
532
|
+
const attachment = this.daemonRegistry.readyAttachment(socket);
|
|
452
533
|
if (!attachment?.tools) return new Set();
|
|
453
534
|
return new Set(attachment.tools);
|
|
454
535
|
}
|
|
455
536
|
|
|
456
|
-
private
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
.sort((left, right) => {
|
|
460
|
-
const leftTime = Date.parse(this.daemonAttachment(left)?.connectedAt ?? "") || 0;
|
|
461
|
-
const rightTime = Date.parse(this.daemonAttachment(right)?.connectedAt ?? "") || 0;
|
|
462
|
-
return rightTime - leftTime;
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
private candidateSockets(): WebSocket[] {
|
|
467
|
-
return this.ctx.getWebSockets().filter((socket) => this.socketAttachment(socket)?.role === "candidate" && socket.readyState === WebSocket.OPEN);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
private nonDaemonSockets(): WebSocket[] {
|
|
471
|
-
return this.ctx.getWebSockets().filter((socket) => {
|
|
472
|
-
const role = this.socketAttachment(socket)?.role;
|
|
473
|
-
return role !== "daemon" && socket.readyState === WebSocket.OPEN;
|
|
474
|
-
});
|
|
537
|
+
private async touchDaemonSocket(ws: WebSocket): Promise<void> {
|
|
538
|
+
if (!this.daemonRegistry.touch(ws)) return;
|
|
539
|
+
await this.scheduleSocketAlarms();
|
|
475
540
|
}
|
|
476
541
|
|
|
477
|
-
private
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
tools: sanitizeDaemonTools(candidate.tools, policy),
|
|
488
|
-
};
|
|
542
|
+
private invalidateDaemonSocket(
|
|
543
|
+
ws: WebSocket,
|
|
544
|
+
message: string,
|
|
545
|
+
closeReason: string,
|
|
546
|
+
errorCode = "daemon_liveness_timeout",
|
|
547
|
+
): void {
|
|
548
|
+
this.daemonRegistry.expire(ws);
|
|
549
|
+
this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
|
|
550
|
+
sendWebSocketQuietly(ws, { type: "error", error: errorCode });
|
|
551
|
+
closeWebSocketQuietly(ws, 1008, closeReason);
|
|
489
552
|
}
|
|
490
553
|
|
|
491
|
-
private
|
|
492
|
-
const
|
|
493
|
-
|
|
554
|
+
private reclaimStaleDaemonSockets(now = Date.now()): void {
|
|
555
|
+
for (const socket of this.daemonRegistry.readyRoleSockets()) {
|
|
556
|
+
const deadline = daemonLivenessDeadlineMs(this.daemonRegistry.readyAttachment(socket));
|
|
557
|
+
if (Number.isFinite(deadline) && deadline > now) continue;
|
|
558
|
+
this.invalidateDaemonSocket(socket, "daemon became unresponsive", "daemon liveness timeout");
|
|
559
|
+
}
|
|
494
560
|
}
|
|
495
561
|
|
|
496
562
|
async alarm(): Promise<void> {
|
|
497
563
|
const now = Date.now();
|
|
498
564
|
let nextDeadline = Number.POSITIVE_INFINITY;
|
|
499
|
-
for (const socket of this.candidateSockets()) {
|
|
500
|
-
const attachment = this.
|
|
565
|
+
for (const socket of this.daemonRegistry.candidateSockets()) {
|
|
566
|
+
const attachment = this.daemonRegistry.attachment(socket);
|
|
501
567
|
const connectedAt = Date.parse(attachment?.connectedAt ?? "");
|
|
502
568
|
const deadline = connectedAt + DAEMON_HELLO_TIMEOUT_MS;
|
|
503
569
|
if (!Number.isFinite(connectedAt) || deadline <= now) {
|
|
504
|
-
|
|
505
|
-
role: "expired",
|
|
506
|
-
connectedAt: attachment?.connectedAt ?? new Date(0).toISOString(),
|
|
507
|
-
} satisfies DaemonAttachment);
|
|
570
|
+
this.daemonRegistry.expire(socket);
|
|
508
571
|
sendWebSocketQuietly(socket, { type: "error", error: "daemon_hello_timeout" });
|
|
509
572
|
closeWebSocketQuietly(socket, 1008, "daemon hello timeout");
|
|
510
573
|
continue;
|
|
511
574
|
}
|
|
512
575
|
nextDeadline = Math.min(nextDeadline, deadline);
|
|
513
576
|
}
|
|
577
|
+
for (const socket of this.daemonRegistry.probingSockets()) {
|
|
578
|
+
const attachment = this.daemonRegistry.attachment(socket);
|
|
579
|
+
const readyDeadline = daemonReadyDeadlineMs(attachment);
|
|
580
|
+
const liveDeadline = daemonLivenessDeadlineMs(attachment);
|
|
581
|
+
if (!Number.isFinite(readyDeadline) || !Number.isFinite(liveDeadline) || Math.min(readyDeadline, liveDeadline) <= now) {
|
|
582
|
+
this.invalidateDaemonSocket(socket, "daemon did not complete end-to-end readiness verification", "daemon ready timeout", "daemon_ready_timeout");
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
nextDeadline = Math.min(nextDeadline, readyDeadline, liveDeadline);
|
|
586
|
+
}
|
|
587
|
+
for (const socket of this.daemonRegistry.readyRoleSockets()) {
|
|
588
|
+
const deadline = daemonLivenessDeadlineMs(this.daemonRegistry.readyAttachment(socket));
|
|
589
|
+
if (!Number.isFinite(deadline) || deadline <= now) {
|
|
590
|
+
this.invalidateDaemonSocket(socket, "daemon became unresponsive", "daemon liveness timeout");
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
nextDeadline = Math.min(nextDeadline, deadline);
|
|
594
|
+
}
|
|
514
595
|
if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(nextDeadline);
|
|
515
596
|
else await this.ctx.storage.deleteAlarm();
|
|
516
597
|
}
|
|
517
598
|
|
|
518
|
-
private async
|
|
599
|
+
private async scheduleSocketAlarms(): Promise<void> {
|
|
519
600
|
let nextDeadline = Number.POSITIVE_INFINITY;
|
|
520
|
-
for (const socket of this.candidateSockets()) {
|
|
521
|
-
const attachment = this.
|
|
601
|
+
for (const socket of this.daemonRegistry.candidateSockets()) {
|
|
602
|
+
const attachment = this.daemonRegistry.attachment(socket);
|
|
522
603
|
const connectedAt = Date.parse(attachment?.connectedAt ?? "");
|
|
523
604
|
if (!Number.isFinite(connectedAt)) {
|
|
524
605
|
closeWebSocketQuietly(socket, 1008, "invalid daemon candidate timestamp");
|
|
@@ -526,19 +607,42 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
526
607
|
}
|
|
527
608
|
nextDeadline = Math.min(nextDeadline, connectedAt + DAEMON_HELLO_TIMEOUT_MS);
|
|
528
609
|
}
|
|
610
|
+
for (const socket of this.daemonRegistry.probingSockets()) {
|
|
611
|
+
const attachment = this.daemonRegistry.attachment(socket);
|
|
612
|
+
const readyDeadline = daemonReadyDeadlineMs(attachment);
|
|
613
|
+
const liveDeadline = daemonLivenessDeadlineMs(attachment);
|
|
614
|
+
if (!Number.isFinite(readyDeadline) || !Number.isFinite(liveDeadline)) {
|
|
615
|
+
this.invalidateDaemonSocket(socket, "daemon readiness state is invalid", "daemon ready timeout", "daemon_ready_timeout");
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
nextDeadline = Math.min(nextDeadline, readyDeadline, liveDeadline);
|
|
619
|
+
}
|
|
620
|
+
for (const socket of this.daemonRegistry.readyRoleSockets()) {
|
|
621
|
+
const deadline = daemonLivenessDeadlineMs(this.daemonRegistry.readyAttachment(socket));
|
|
622
|
+
if (!Number.isFinite(deadline)) {
|
|
623
|
+
this.invalidateDaemonSocket(socket, "daemon became unresponsive", "invalid daemon liveness timestamp");
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
nextDeadline = Math.min(nextDeadline, deadline);
|
|
627
|
+
}
|
|
529
628
|
if (Number.isFinite(nextDeadline)) await this.ctx.storage.setAlarm(Math.max(Date.now(), nextDeadline));
|
|
530
629
|
else await this.ctx.storage.deleteAlarm();
|
|
531
630
|
}
|
|
532
631
|
|
|
533
632
|
private daemonStatus(detail: boolean): Record<string, unknown> {
|
|
534
|
-
|
|
535
|
-
const
|
|
633
|
+
this.reclaimStaleDaemonSockets();
|
|
634
|
+
const sockets = this.daemonRegistry.readySockets();
|
|
635
|
+
const attachment = sockets[0] ? this.daemonRegistry.readyAttachment(sockets[0]) : undefined;
|
|
536
636
|
const tools = attachment?.tools ?? [];
|
|
537
637
|
const base = {
|
|
538
638
|
connected: sockets.length > 0,
|
|
539
639
|
count: sockets.length,
|
|
540
640
|
tool_count: tools.length,
|
|
541
641
|
connected_at: attachment?.connectedAt ?? null,
|
|
642
|
+
last_seen_at: attachment?.lastSeenAt ?? attachment?.connectedAt ?? null,
|
|
643
|
+
readiness_verified: sockets.length > 0,
|
|
644
|
+
readiness_timeout_ms: DAEMON_READY_TIMEOUT_MS,
|
|
645
|
+
liveness_timeout_ms: DAEMON_LIVENESS_TIMEOUT_MS,
|
|
542
646
|
};
|
|
543
647
|
if (!detail) return base;
|
|
544
648
|
return {
|
|
@@ -672,13 +776,6 @@ function textToolResult(value: unknown, isError = false): Record<string, unknown
|
|
|
672
776
|
return result;
|
|
673
777
|
}
|
|
674
778
|
|
|
675
|
-
function isFreshDaemonCandidate(connectedAt: string): boolean {
|
|
676
|
-
const timestamp = Date.parse(connectedAt);
|
|
677
|
-
if (!Number.isFinite(timestamp)) return false;
|
|
678
|
-
const age = Date.now() - timestamp;
|
|
679
|
-
return age >= 0 && age <= DAEMON_HELLO_TIMEOUT_MS;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
779
|
function asObject(value: unknown): Record<string, unknown> {
|
|
683
780
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
684
781
|
return value as Record<string, unknown>;
|