@nudge-ui/mcp 0.2.4 → 0.2.6
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/README.md +18 -0
- package/dist/adapterRouting.d.ts +0 -1
- package/dist/adapterRouting.js +0 -1
- package/dist/bridge.d.ts +6 -57
- package/dist/bridge.js +293 -806
- package/dist/bridgeSession.d.ts +87 -0
- package/dist/bridgeSession.js +438 -0
- package/dist/cli.d.ts +3 -30
- package/dist/cli.js +54 -169
- package/dist/cli.mjs +237 -1372
- package/dist/discovery.d.ts +5 -6
- package/dist/discovery.js +5 -8
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/internal/agent-protocol.d.ts +53 -38
- package/dist/internal/agent-protocol.js +151 -42
- package/dist/project.d.ts +2 -7
- package/dist/project.js +5 -10
- package/dist/projectSessionClient.d.ts +4 -6
- package/dist/projectSessionClient.js +37 -15
- package/dist/protocol.d.ts +4 -20
- package/dist/protocol.js +3 -18
- package/dist/server.d.ts +3 -28
- package/dist/server.js +65 -137
- package/dist/sessionRegistry.d.ts +0 -2
- package/dist/sessionRegistry.js +1 -23
- package/package.json +1 -16
package/dist/bridge.js
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
import { createServer, } from "node:http";
|
|
2
2
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
3
|
-
import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin,
|
|
3
|
+
import { AGENT_PROTOCOL_LIMITS, AGENT_PROTOCOL_VERSION, canonicalOrigin, isAgentActivity, isAgentStatusUpdate, isCanvasCommand, isCanvasCommandResult, validateSketchAttachments, } from "./internal/agent-protocol.js";
|
|
4
|
+
import { BridgeSessionError, createBridgeSession, } from "./bridgeSession.js";
|
|
4
5
|
import { AGENT_CONTROL_ENDPOINTS, BRIDGE_ENDPOINTS } from "./protocol.js";
|
|
5
6
|
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
6
7
|
const MAX_BODY_BYTES = 256 * 1024;
|
|
7
8
|
const MAX_PROMPT_BODY_BYTES = 12 * 1024 * 1024;
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
const SESSION_ERROR_STATUS = {
|
|
10
|
+
project_not_found: 404,
|
|
11
|
+
origin_not_allowed: 403,
|
|
12
|
+
origin_mismatch: 403,
|
|
13
|
+
invalid_session: 401,
|
|
14
|
+
invalid_page_url: 400,
|
|
15
|
+
invalid_status: 400,
|
|
16
|
+
invalid_activity: 400,
|
|
17
|
+
invalid_canvas_command: 400,
|
|
18
|
+
invalid_routes: 400,
|
|
19
|
+
already_paired: 409,
|
|
20
|
+
browser_unpaired: 409,
|
|
21
|
+
listener_active: 409,
|
|
22
|
+
listener_unavailable: 409,
|
|
23
|
+
listener_cancelled: 409,
|
|
24
|
+
listener_aborted: 409,
|
|
25
|
+
request_in_flight: 409,
|
|
26
|
+
unknown_request: 409,
|
|
27
|
+
inactive_request: 409,
|
|
28
|
+
command_in_flight: 409,
|
|
29
|
+
no_command: 409,
|
|
30
|
+
unknown_command: 409,
|
|
31
|
+
session_claimed: 409,
|
|
32
|
+
agent_not_owner: 409,
|
|
33
|
+
session_closed: 409,
|
|
14
34
|
};
|
|
15
35
|
class BridgeRequestError extends Error {
|
|
16
36
|
statusCode;
|
|
@@ -22,26 +42,18 @@ class BridgeRequestError extends Error {
|
|
|
22
42
|
this.code = code;
|
|
23
43
|
}
|
|
24
44
|
}
|
|
25
|
-
function
|
|
26
|
-
return
|
|
27
|
-
}
|
|
28
|
-
function randomId() {
|
|
29
|
-
return randomUUID();
|
|
30
|
-
}
|
|
31
|
-
function deferred() {
|
|
32
|
-
let resolvePromise;
|
|
33
|
-
let rejectPromise;
|
|
34
|
-
const promise = new Promise((resolve, reject) => {
|
|
35
|
-
resolvePromise = resolve;
|
|
36
|
-
rejectPromise = reject;
|
|
37
|
-
});
|
|
38
|
-
return { promise, resolve: resolvePromise, reject: rejectPromise };
|
|
45
|
+
function route(method, path, handler) {
|
|
46
|
+
return [`${method} ${path}`, handler];
|
|
39
47
|
}
|
|
40
48
|
function loopbackAddress(address) {
|
|
41
49
|
if (!address)
|
|
42
50
|
return false;
|
|
43
51
|
return LOOPBACK_HOSTS.has(address) || address === "::ffff:127.0.0.1";
|
|
44
52
|
}
|
|
53
|
+
function requireLoopback(request, code, message) {
|
|
54
|
+
if (!loopbackAddress(request.socket.remoteAddress))
|
|
55
|
+
throw new BridgeRequestError(403, code, message);
|
|
56
|
+
}
|
|
45
57
|
function validPort(port) {
|
|
46
58
|
return Number.isInteger(port) && port >= 0 && port <= 65_535;
|
|
47
59
|
}
|
|
@@ -54,38 +66,31 @@ function headerValue(request, name) {
|
|
|
54
66
|
function requestOrigin(request) {
|
|
55
67
|
return canonicalOrigin(headerValue(request, "origin"));
|
|
56
68
|
}
|
|
57
|
-
function
|
|
58
|
-
|
|
59
|
-
throw new BridgeRequestError(404, "project_not_found", "The requested project is not served by this companion.");
|
|
60
|
-
}
|
|
69
|
+
function optionalString(value) {
|
|
70
|
+
return typeof value === "string" ? value : undefined;
|
|
61
71
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
function setCors(response, origin) {
|
|
73
|
+
response.setHeader("Access-Control-Allow-Origin", origin);
|
|
74
|
+
response.setHeader("Access-Control-Allow-Credentials", "true");
|
|
75
|
+
response.setHeader("Vary", "Origin");
|
|
66
76
|
}
|
|
67
|
-
function
|
|
77
|
+
function sendJson(response, statusCode, body, origin) {
|
|
68
78
|
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
69
79
|
response.setHeader("Cache-Control", "no-store");
|
|
70
80
|
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
71
|
-
if (origin)
|
|
72
|
-
response
|
|
73
|
-
response.setHeader("Access-Control-Allow-Credentials", "true");
|
|
74
|
-
response.setHeader("Vary", "Origin");
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
function sendJson(response, statusCode, body, origin) {
|
|
78
|
-
jsonHeaders(response, origin);
|
|
81
|
+
if (origin)
|
|
82
|
+
setCors(response, origin);
|
|
79
83
|
response.statusCode = statusCode;
|
|
80
84
|
response.end(JSON.stringify(body));
|
|
81
85
|
}
|
|
82
|
-
function
|
|
86
|
+
function errorResponse(error) {
|
|
83
87
|
if (error instanceof BridgeRequestError) {
|
|
84
|
-
|
|
85
|
-
sendJson(response, error.statusCode, body, origin);
|
|
86
|
-
return;
|
|
88
|
+
return { statusCode: error.statusCode, body: { error: { code: error.code, message: error.message } } };
|
|
87
89
|
}
|
|
88
|
-
|
|
90
|
+
if (error instanceof BridgeSessionError) {
|
|
91
|
+
return { statusCode: SESSION_ERROR_STATUS[error.code], body: { error: { code: error.code, message: error.message } } };
|
|
92
|
+
}
|
|
93
|
+
return { statusCode: 500, body: { error: { code: "internal_error", message: "The bridge could not complete the request." } } };
|
|
89
94
|
}
|
|
90
95
|
async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
|
|
91
96
|
const chunks = [];
|
|
@@ -122,7 +127,7 @@ function pngDimensions(bytes) {
|
|
|
122
127
|
height: bytes.readUInt32BE(20),
|
|
123
128
|
};
|
|
124
129
|
}
|
|
125
|
-
function
|
|
130
|
+
function decodeSketchAttachments(value) {
|
|
126
131
|
let attachments;
|
|
127
132
|
try {
|
|
128
133
|
attachments = validateSketchAttachments(value);
|
|
@@ -143,591 +148,288 @@ function parseSketchAttachments(value) {
|
|
|
143
148
|
}
|
|
144
149
|
return attachments;
|
|
145
150
|
}
|
|
146
|
-
function
|
|
151
|
+
function decodePromptDispatch(body) {
|
|
152
|
+
const { prompt, changeRevision, clientDispatchId } = body;
|
|
153
|
+
if (typeof prompt !== "string" || prompt.length === 0 || prompt.length > AGENT_PROTOCOL_LIMITS.prompt) {
|
|
154
|
+
throw new BridgeRequestError(400, "invalid_prompt", "prompt must be a bounded non-empty string");
|
|
155
|
+
}
|
|
156
|
+
if (changeRevision !== undefined && (typeof changeRevision !== "number" || !Number.isSafeInteger(changeRevision) || changeRevision < 0)) {
|
|
157
|
+
throw new BridgeRequestError(400, "invalid_revision", "changeRevision must be a non-negative safe integer");
|
|
158
|
+
}
|
|
159
|
+
if (clientDispatchId !== undefined && (typeof clientDispatchId !== "string"
|
|
160
|
+
|| clientDispatchId.length === 0 || clientDispatchId.length > AGENT_PROTOCOL_LIMITS.clientDispatchId)) {
|
|
161
|
+
throw new BridgeRequestError(400, "invalid_dispatch_id", "clientDispatchId must be a bounded non-empty string");
|
|
162
|
+
}
|
|
147
163
|
return {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
164
|
+
prompt,
|
|
165
|
+
...(changeRevision === undefined ? {} : { changeRevision }),
|
|
166
|
+
...(clientDispatchId === undefined ? {} : { clientDispatchId }),
|
|
167
|
+
attachments: decodeSketchAttachments(body.attachments),
|
|
152
168
|
};
|
|
153
169
|
}
|
|
170
|
+
function decodeAgentId(value) {
|
|
171
|
+
if (value === undefined || value === null)
|
|
172
|
+
return null;
|
|
173
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 256) {
|
|
174
|
+
throw new BridgeRequestError(400, "invalid_agent_id", "agentId must be a bounded non-empty string.");
|
|
175
|
+
}
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
154
178
|
function writeSse(response, envelope) {
|
|
155
179
|
if (response.destroyed || response.writableEnded)
|
|
156
|
-
return;
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
return "listening";
|
|
166
|
-
return "offline";
|
|
167
|
-
}
|
|
168
|
-
function statusWith(projectId, listenerActive, paired, request, pageUrl, agentClientName) {
|
|
169
|
-
return {
|
|
170
|
-
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
171
|
-
projectId,
|
|
172
|
-
connection: connectionFor(listenerActive, paired, request),
|
|
173
|
-
listenerActive,
|
|
174
|
-
paired,
|
|
175
|
-
pageUrl,
|
|
176
|
-
...(agentClientName === undefined ? {} : { agentClientName }),
|
|
177
|
-
request,
|
|
178
|
-
};
|
|
180
|
+
return false;
|
|
181
|
+
try {
|
|
182
|
+
response.write(`event: ${envelope.event.type}\ndata: ${JSON.stringify(envelope)}\n\n`);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
response.destroy();
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
179
189
|
}
|
|
180
|
-
/**
|
|
181
|
-
* Creates the local browser bridge. It binds only to loopback and keeps every
|
|
182
|
-
* pairing, prompt, command, and acknowledgement in process memory.
|
|
183
|
-
*/
|
|
190
|
+
/** Binds only to loopback and keeps every pairing, prompt, command, and acknowledgement in process memory. */
|
|
184
191
|
export function createLoopbackBridge(options) {
|
|
185
|
-
if (typeof options.projectId !== "string" || options.projectId.length === 0
|
|
186
|
-
|| options.projectId.length > AGENT_PROTOCOL_LIMITS.projectId) {
|
|
187
|
-
throw new TypeError("projectId must be a bounded non-empty string");
|
|
188
|
-
}
|
|
189
|
-
const configuredOrigin = options.origin === undefined ? null : canonicalOrigin(options.origin);
|
|
190
|
-
if (options.origin !== undefined && !configuredOrigin)
|
|
191
|
-
throw new TypeError("origin must be a canonical HTTP(S) origin");
|
|
192
192
|
const host = options.host ?? "127.0.0.1";
|
|
193
193
|
if (!LOOPBACK_HOSTS.has(host))
|
|
194
194
|
throw new TypeError("The browser bridge must bind to a loopback host");
|
|
195
195
|
const port = options.port ?? 0;
|
|
196
196
|
if (!validPort(port))
|
|
197
197
|
throw new TypeError("port must be between 0 and 65535");
|
|
198
|
-
const
|
|
199
|
-
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60_000) {
|
|
200
|
-
throw new TypeError("commandTimeoutMs must be between 1 and 60000 milliseconds");
|
|
201
|
-
}
|
|
202
|
-
const allowedOrigins = [
|
|
203
|
-
...(configuredOrigin ? [configuredOrigin] : []),
|
|
204
|
-
...(options.allowedOrigins ?? []),
|
|
205
|
-
].map((candidate) => canonicalOrigin(candidate));
|
|
206
|
-
if (allowedOrigins.some((candidate) => candidate === null)) {
|
|
207
|
-
throw new TypeError("allowedOrigins must contain canonical HTTP(S) origins");
|
|
208
|
-
}
|
|
209
|
-
const canonicalAllowedOrigins = allowedOrigins;
|
|
210
|
-
const clock = options.clock ?? SYSTEM_CLOCK;
|
|
211
|
-
const tokenFactory = options.tokenFactory ?? randomToken;
|
|
212
|
-
const idFactory = options.idFactory ?? randomId;
|
|
213
|
-
const agentControlToken = options.agentControlToken;
|
|
198
|
+
const { agentControlToken } = options;
|
|
214
199
|
if (agentControlToken !== undefined && (agentControlToken.length < 32 || agentControlToken.length > 256)) {
|
|
215
200
|
throw new TypeError("agentControlToken must contain between 32 and 256 characters");
|
|
216
201
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
let agentClientName;
|
|
230
|
-
let agentOwnerSeenAt = 0;
|
|
231
|
-
let agentOwnerExpiry = null;
|
|
232
|
-
const getStatus = () => statusWith(options.projectId, listener !== null, sessionToken !== null, currentRequest, lastPageUrl, agentClientName);
|
|
233
|
-
const broadcast = (event) => {
|
|
234
|
-
if (!sessionToken)
|
|
235
|
-
return;
|
|
236
|
-
const envelope = createEnvelope(options.projectId, sessionToken, event);
|
|
237
|
-
for (const response of [...sseClients]) {
|
|
238
|
-
if (response.destroyed || response.writableEnded) {
|
|
239
|
-
sseClients.delete(response);
|
|
240
|
-
continue;
|
|
241
|
-
}
|
|
242
|
-
try {
|
|
243
|
-
writeSse(response, envelope);
|
|
244
|
-
}
|
|
245
|
-
catch {
|
|
246
|
-
sseClients.delete(response);
|
|
247
|
-
response.destroy();
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
};
|
|
251
|
-
const broadcastStatus = () => {
|
|
252
|
-
const statusEvent = { type: "status", status: getStatus() };
|
|
253
|
-
broadcast(statusEvent);
|
|
254
|
-
};
|
|
255
|
-
const originIsAllowed = (candidate) => {
|
|
256
|
-
if (!candidate)
|
|
257
|
-
return false;
|
|
258
|
-
// Once paired, the explicitly approved browser's exact origin remains the
|
|
259
|
-
// only origin accepted. Before pairing, only an origin configured by the
|
|
260
|
-
// host (directly or through the allow-list) is eligible. An empty list is
|
|
261
|
-
// deliberately closed rather than a trust-on-first-use wildcard.
|
|
262
|
-
if (pairedOrigin === candidate)
|
|
263
|
-
return true;
|
|
264
|
-
return isAllowedOrigin(candidate, canonicalAllowedOrigins);
|
|
265
|
-
};
|
|
266
|
-
const requireBrowserRequest = (request, url, bodyToken) => {
|
|
267
|
-
if (!loopbackAddress(request.socket.remoteAddress)) {
|
|
268
|
-
throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
|
|
269
|
-
}
|
|
270
|
-
const incomingOrigin = requestOrigin(request);
|
|
271
|
-
if (!originIsAllowed(incomingOrigin)) {
|
|
272
|
-
throw new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project.");
|
|
273
|
-
}
|
|
274
|
-
if (pairedOrigin !== incomingOrigin) {
|
|
275
|
-
throw new BridgeRequestError(403, "origin_mismatch", "The browser origin does not match the paired project.");
|
|
276
|
-
}
|
|
277
|
-
const candidate = typeof bodyToken === "string" ? bodyToken : url.searchParams.get("sessionToken");
|
|
278
|
-
parseToken(candidate, sessionToken);
|
|
279
|
-
return incomingOrigin;
|
|
280
|
-
};
|
|
281
|
-
const handleOptions = (request, response) => {
|
|
282
|
-
const incomingOrigin = requestOrigin(request);
|
|
283
|
-
if (incomingOrigin && !originIsAllowed(incomingOrigin)) {
|
|
284
|
-
sendError(response, new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project."), null);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
if (incomingOrigin) {
|
|
288
|
-
response.setHeader("Access-Control-Allow-Origin", incomingOrigin);
|
|
289
|
-
response.setHeader("Access-Control-Allow-Credentials", "true");
|
|
290
|
-
response.setHeader("Vary", "Origin");
|
|
291
|
-
}
|
|
292
|
-
response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
293
|
-
response.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
294
|
-
response.statusCode = 204;
|
|
295
|
-
response.end();
|
|
202
|
+
const session = createBridgeSession({
|
|
203
|
+
projectId: options.projectId,
|
|
204
|
+
origin: options.origin,
|
|
205
|
+
allowedOrigins: options.allowedOrigins,
|
|
206
|
+
commandTimeoutMs: options.commandTimeoutMs,
|
|
207
|
+
clock: options.clock,
|
|
208
|
+
tokenFactory: options.tokenFactory ?? (() => randomBytes(32).toString("base64url")),
|
|
209
|
+
idFactory: options.idFactory ?? randomUUID,
|
|
210
|
+
});
|
|
211
|
+
const browserOrigin = (request) => {
|
|
212
|
+
requireLoopback(request, "loopback_only", "The browser bridge accepts loopback connections only.");
|
|
213
|
+
return session.authorizeOrigin(requestOrigin(request));
|
|
296
214
|
};
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
const incomingOrigin = requestOrigin(request);
|
|
302
|
-
if (!originIsAllowed(incomingOrigin)) {
|
|
303
|
-
throw new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project.");
|
|
304
|
-
}
|
|
305
|
-
parseProjectId(url.searchParams.get("projectId"), options.projectId);
|
|
306
|
-
sendJson(response, 200, {
|
|
307
|
-
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
308
|
-
projectId: options.projectId,
|
|
309
|
-
origin: incomingOrigin,
|
|
310
|
-
status: getStatus(),
|
|
311
|
-
sessionRequired: true,
|
|
312
|
-
pageUrl: lastPageUrl,
|
|
313
|
-
}, incomingOrigin);
|
|
215
|
+
const pairedBrowser = (request, sessionToken) => {
|
|
216
|
+
requireLoopback(request, "loopback_only", "The browser bridge accepts loopback connections only.");
|
|
217
|
+
return session.authorizeBrowser(requestOrigin(request), sessionToken);
|
|
314
218
|
};
|
|
315
219
|
const readPairRequest = async (request) => {
|
|
316
|
-
|
|
317
|
-
throw new BridgeRequestError(403, "loopback_only", "The browser bridge accepts loopback connections only.");
|
|
318
|
-
}
|
|
319
|
-
const incomingOrigin = requestOrigin(request);
|
|
320
|
-
if (!originIsAllowed(incomingOrigin)) {
|
|
321
|
-
throw new BridgeRequestError(403, "origin_not_allowed", "The browser origin is not allowed for this project.");
|
|
322
|
-
}
|
|
220
|
+
const origin = browserOrigin(request);
|
|
323
221
|
const body = await readJsonBody(request);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (!requestedOrigin || requestedOrigin !== incomingOrigin) {
|
|
222
|
+
session.assertProject(body.projectId);
|
|
223
|
+
if (canonicalOrigin(body.origin) !== origin) {
|
|
327
224
|
throw new BridgeRequestError(403, "origin_mismatch", "Pairing requires an explicitly configured project origin.");
|
|
328
225
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (typeof body.pageUrl !== "string" || body.pageUrl.length === 0 || body.pageUrl.length > AGENT_PROTOCOL_LIMITS.routeUrl
|
|
332
|
-
|| !isSameOriginRoute({ url: body.pageUrl }, requestedOrigin)) {
|
|
333
|
-
throw new BridgeRequestError(400, "invalid_page_url", "pageUrl must be a bounded same-origin URL.");
|
|
334
|
-
}
|
|
335
|
-
requestedPageUrl = new URL(body.pageUrl, requestedOrigin).href;
|
|
226
|
+
if (body.pageUrl !== undefined && typeof body.pageUrl !== "string") {
|
|
227
|
+
throw new BridgeRequestError(400, "invalid_page_url", "pageUrl must be a bounded same-origin URL.");
|
|
336
228
|
}
|
|
337
|
-
return {
|
|
229
|
+
return { origin, ...(body.pageUrl === undefined ? {} : { pageUrl: body.pageUrl }), body };
|
|
338
230
|
};
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
if (sessionToken) {
|
|
342
|
-
if (body.sessionToken !== sessionToken) {
|
|
343
|
-
throw new BridgeRequestError(409, "already_paired", "A browser is already paired with this project.");
|
|
344
|
-
}
|
|
345
|
-
lastPageUrl = requestedPageUrl ?? lastPageUrl;
|
|
346
|
-
const existing = {
|
|
347
|
-
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
348
|
-
projectId: options.projectId,
|
|
349
|
-
origin: pairedOrigin,
|
|
350
|
-
sessionToken,
|
|
351
|
-
...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
|
|
352
|
-
status: getStatus(),
|
|
353
|
-
};
|
|
354
|
-
sendJson(response, 200, existing, incomingOrigin);
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
sessionToken = tokenFactory();
|
|
358
|
-
if (!sessionToken || sessionToken.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
|
|
359
|
-
throw new Error("tokenFactory returned an invalid session token");
|
|
360
|
-
}
|
|
361
|
-
pairedOrigin = incomingOrigin;
|
|
362
|
-
lastPageUrl = requestedPageUrl ?? null;
|
|
363
|
-
const connected = { type: "connected", status: getStatus() };
|
|
364
|
-
broadcast(connected);
|
|
365
|
-
const result = {
|
|
366
|
-
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
367
|
-
projectId: options.projectId,
|
|
368
|
-
origin: pairedOrigin,
|
|
369
|
-
sessionToken,
|
|
370
|
-
...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
|
|
371
|
-
status: getStatus(),
|
|
372
|
-
};
|
|
373
|
-
sendJson(response, 200, result, incomingOrigin);
|
|
374
|
-
broadcastStatus();
|
|
375
|
-
};
|
|
376
|
-
const handleTakeover = async (request, response) => {
|
|
377
|
-
const { incomingOrigin, requestedPageUrl } = await readPairRequest(request);
|
|
378
|
-
// Takeover is intentionally separate from normal pairing. The caller has
|
|
379
|
-
// made an explicit UI choice to revoke the existing browser session.
|
|
380
|
-
if (sessionToken)
|
|
381
|
-
disconnectBrowser(sessionToken);
|
|
382
|
-
const pairing = pairBrowser(options.projectId, incomingOrigin, requestedPageUrl);
|
|
383
|
-
sendJson(response, 200, pairing, incomingOrigin);
|
|
384
|
-
};
|
|
385
|
-
const handleEvents = (request, response, url) => {
|
|
386
|
-
parseProjectId(url.searchParams.get("projectId"), options.projectId);
|
|
387
|
-
const incomingOrigin = requireBrowserRequest(request, url);
|
|
388
|
-
response.statusCode = 200;
|
|
389
|
-
response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
390
|
-
response.setHeader("Cache-Control", "no-cache, no-store");
|
|
391
|
-
response.setHeader("Connection", "keep-alive");
|
|
392
|
-
response.setHeader("X-Accel-Buffering", "no");
|
|
393
|
-
response.setHeader("Access-Control-Allow-Origin", incomingOrigin);
|
|
394
|
-
response.setHeader("Access-Control-Allow-Credentials", "true");
|
|
395
|
-
response.setHeader("Vary", "Origin");
|
|
396
|
-
response.flushHeaders();
|
|
397
|
-
sseClients.add(response);
|
|
398
|
-
const initial = { type: "status", status: getStatus() };
|
|
399
|
-
writeSse(response, createEnvelope(options.projectId, sessionToken, initial));
|
|
400
|
-
const closeClient = () => {
|
|
401
|
-
sseClients.delete(response);
|
|
402
|
-
};
|
|
403
|
-
request.once("close", closeClient);
|
|
404
|
-
response.once("close", closeClient);
|
|
405
|
-
};
|
|
406
|
-
const handleStatus = (request, response, url) => {
|
|
407
|
-
parseProjectId(url.searchParams.get("projectId"), options.projectId);
|
|
408
|
-
const incomingOrigin = requireBrowserRequest(request, url);
|
|
409
|
-
sendJson(response, 200, getStatus(), incomingOrigin);
|
|
410
|
-
};
|
|
411
|
-
const handlePrompt = async (request, response) => {
|
|
412
|
-
const body = await readJsonBody(request, MAX_PROMPT_BODY_BYTES);
|
|
413
|
-
const incomingOrigin = requireBrowserRequest(request, new URL("http://bridge.invalid"), body.sessionToken);
|
|
414
|
-
parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
|
|
415
|
-
if (typeof body.prompt !== "string" || body.prompt.length === 0 || body.prompt.length > AGENT_PROTOCOL_LIMITS.prompt) {
|
|
416
|
-
throw new BridgeRequestError(400, "invalid_prompt", "prompt must be a bounded non-empty string");
|
|
417
|
-
}
|
|
418
|
-
if (body.changeRevision !== undefined
|
|
419
|
-
&& (!Number.isSafeInteger(body.changeRevision) || body.changeRevision < 0)) {
|
|
420
|
-
throw new BridgeRequestError(400, "invalid_revision", "changeRevision must be a non-negative safe integer");
|
|
421
|
-
}
|
|
422
|
-
if (body.clientDispatchId !== undefined
|
|
423
|
-
&& (typeof body.clientDispatchId !== "string"
|
|
424
|
-
|| body.clientDispatchId.length === 0
|
|
425
|
-
|| body.clientDispatchId.length > AGENT_PROTOCOL_LIMITS.clientDispatchId)) {
|
|
426
|
-
throw new BridgeRequestError(400, "invalid_dispatch_id", "clientDispatchId must be a bounded non-empty string");
|
|
427
|
-
}
|
|
428
|
-
const attachments = parseSketchAttachments(body.attachments);
|
|
429
|
-
if (!listener) {
|
|
430
|
-
throw new BridgeRequestError(409, "listener_unavailable", "The agent has not opened a listening call.");
|
|
431
|
-
}
|
|
432
|
-
if (currentRequest) {
|
|
433
|
-
throw new BridgeRequestError(409, "request_in_flight", "Another Nudge request is still in flight.");
|
|
434
|
-
}
|
|
435
|
-
const prompt = {
|
|
436
|
-
requestId: idFactory(),
|
|
437
|
-
projectId: options.projectId,
|
|
438
|
-
prompt: body.prompt,
|
|
439
|
-
...(body.changeRevision === undefined ? {} : { changeRevision: body.changeRevision }),
|
|
440
|
-
...(body.clientDispatchId === undefined ? {} : { clientDispatchId: body.clientDispatchId }),
|
|
441
|
-
...(attachments.length === 0 ? {} : { sketches: attachments.map(({ data: _data, ...metadata }) => metadata) }),
|
|
442
|
-
};
|
|
443
|
-
const delivered = {
|
|
444
|
-
...prompt,
|
|
445
|
-
...(attachments.length === 0 ? {} : { attachments }),
|
|
446
|
-
};
|
|
447
|
-
const pending = listener;
|
|
448
|
-
listener = null;
|
|
449
|
-
if (pending.abortHandler && pending.signal)
|
|
450
|
-
pending.signal.removeEventListener("abort", pending.abortHandler);
|
|
451
|
-
currentRequest = { ...prompt, status: "working" };
|
|
452
|
-
pending.resolve(delivered);
|
|
453
|
-
const result = { request: prompt, status: "working" };
|
|
454
|
-
sendJson(response, 202, result, incomingOrigin);
|
|
455
|
-
broadcastStatus();
|
|
456
|
-
};
|
|
457
|
-
const handleCanvasAcknowledgement = async (request, response) => {
|
|
458
|
-
const body = await readJsonBody(request);
|
|
459
|
-
const url = new URL("http://bridge.invalid");
|
|
460
|
-
const incomingOrigin = requireBrowserRequest(request, url, body.sessionToken);
|
|
461
|
-
parseProjectId(typeof body.projectId === "string" ? body.projectId : null, options.projectId);
|
|
462
|
-
if (!pendingCanvas)
|
|
463
|
-
throw new BridgeRequestError(409, "no_command", "No Canvas command is awaiting an acknowledgement.");
|
|
464
|
-
if (typeof body.commandId !== "string" || body.commandId !== pendingCanvas.command.commandId) {
|
|
465
|
-
throw new BridgeRequestError(409, "unknown_command", "The Canvas command is no longer awaiting an acknowledgement.");
|
|
466
|
-
}
|
|
467
|
-
if (typeof body.ok !== "boolean")
|
|
468
|
-
throw new BridgeRequestError(400, "invalid_acknowledgement", "ok must be a boolean");
|
|
469
|
-
// Acknowledgement payloads flow back to the agent verbatim, so every
|
|
470
|
-
// optional field is validated before it is accepted into the result.
|
|
471
|
-
if (body.state !== undefined && !isCanvasState(body.state)) {
|
|
472
|
-
throw new BridgeRequestError(400, "invalid_acknowledgement", "state must be a valid Canvas state.");
|
|
473
|
-
}
|
|
474
|
-
if (body.group !== undefined && !isCanvasGroup(body.group)) {
|
|
475
|
-
throw new BridgeRequestError(400, "invalid_acknowledgement", "group must be a valid Canvas group.");
|
|
476
|
-
}
|
|
477
|
-
if (body.error !== undefined) {
|
|
478
|
-
const error = body.error;
|
|
479
|
-
if (typeof error !== "object" || error === null
|
|
480
|
-
|| typeof error.code !== "string" || typeof error.message !== "string") {
|
|
481
|
-
throw new BridgeRequestError(400, "invalid_acknowledgement", "error must include string code and message fields.");
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
const acknowledgement = {
|
|
485
|
-
commandId: body.commandId,
|
|
486
|
-
ok: body.ok,
|
|
487
|
-
...(body.state === undefined ? {} : { state: body.state }),
|
|
488
|
-
...(body.group === undefined ? {} : { group: body.group }),
|
|
489
|
-
...(body.error === undefined ? {} : { error: body.error }),
|
|
490
|
-
};
|
|
491
|
-
if (acknowledgement.ok && acknowledgement.error) {
|
|
492
|
-
throw new BridgeRequestError(400, "invalid_acknowledgement", "A successful acknowledgement cannot include an error.");
|
|
493
|
-
}
|
|
494
|
-
const pendingCommand = pendingCanvas;
|
|
495
|
-
pendingCanvas = null;
|
|
496
|
-
clock.clearTimeout(pendingCommand.timeout);
|
|
497
|
-
const result = {
|
|
498
|
-
commandId: acknowledgement.commandId,
|
|
499
|
-
ok: acknowledgement.ok,
|
|
500
|
-
...(acknowledgement.state === undefined ? {} : { state: acknowledgement.state }),
|
|
501
|
-
...(acknowledgement.group === undefined ? {} : { group: acknowledgement.group }),
|
|
502
|
-
...(acknowledgement.error === undefined ? {} : { error: acknowledgement.error }),
|
|
503
|
-
};
|
|
504
|
-
pendingCommand.resolve(result);
|
|
505
|
-
sendJson(response, 202, { commandId: result.commandId, accepted: true }, incomingOrigin);
|
|
506
|
-
};
|
|
507
|
-
const handleDisconnect = async (request, response) => {
|
|
508
|
-
const body = await readJsonBody(request);
|
|
509
|
-
const incomingOrigin = requireBrowserRequest(request, new URL("http://bridge.invalid"), body.sessionToken);
|
|
510
|
-
disconnectBrowser(body.sessionToken);
|
|
511
|
-
sendJson(response, 200, { disconnected: true }, incomingOrigin);
|
|
512
|
-
};
|
|
513
|
-
const requireAgentControl = (request, bodyAgentId) => {
|
|
514
|
-
if (!agentControlToken || !loopbackAddress(request.socket.remoteAddress)) {
|
|
231
|
+
const requireAgentControl = (request) => {
|
|
232
|
+
if (!agentControlToken) {
|
|
515
233
|
throw new BridgeRequestError(403, "agent_control_unavailable", "Private agent control is unavailable.");
|
|
516
234
|
}
|
|
235
|
+
requireLoopback(request, "agent_control_unavailable", "Private agent control is unavailable.");
|
|
517
236
|
if (headerValue(request, "authorization") !== `Bearer ${agentControlToken}`) {
|
|
518
237
|
throw new BridgeRequestError(401, "invalid_agent_credential", "The agent control credential is invalid.");
|
|
519
238
|
}
|
|
520
|
-
if (bodyAgentId === undefined)
|
|
521
|
-
return null;
|
|
522
|
-
if (typeof bodyAgentId !== "string" || bodyAgentId.length === 0 || bodyAgentId.length > 256) {
|
|
523
|
-
throw new BridgeRequestError(400, "invalid_agent_id", "agentId must be a bounded non-empty string.");
|
|
524
|
-
}
|
|
525
|
-
return bodyAgentId;
|
|
526
239
|
};
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
throw new BridgeRequestError(409, "agent_not_owner", "This MCP adapter does not own the project session.");
|
|
531
|
-
}
|
|
532
|
-
agentOwnerSeenAt = clock.now();
|
|
533
|
-
scheduleAgentOwnerExpiry();
|
|
534
|
-
return agentId;
|
|
535
|
-
};
|
|
536
|
-
const releaseAgentOwner = (reason) => {
|
|
537
|
-
if (agentOwnerExpiry !== null)
|
|
538
|
-
clock.clearTimeout(agentOwnerExpiry);
|
|
539
|
-
agentOwnerExpiry = null;
|
|
540
|
-
agentOwner = null;
|
|
541
|
-
agentClientName = undefined;
|
|
542
|
-
agentOwnerSeenAt = 0;
|
|
543
|
-
cancelListener(reason);
|
|
544
|
-
if (currentRequest?.status === "working") {
|
|
545
|
-
currentRequest = { ...currentRequest, status: "interrupted", error: reason };
|
|
546
|
-
broadcastStatus();
|
|
547
|
-
}
|
|
548
|
-
if (pendingCanvas) {
|
|
549
|
-
const pendingCommand = pendingCanvas;
|
|
550
|
-
pendingCanvas = null;
|
|
551
|
-
clock.clearTimeout(pendingCommand.timeout);
|
|
552
|
-
pendingCommand.resolve({
|
|
553
|
-
commandId: pendingCommand.command.commandId,
|
|
554
|
-
ok: false,
|
|
555
|
-
error: { code: "agent_released", message: reason },
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
broadcastStatus();
|
|
559
|
-
};
|
|
560
|
-
const scheduleAgentOwnerExpiry = () => {
|
|
561
|
-
if (agentOwnerExpiry !== null)
|
|
562
|
-
clock.clearTimeout(agentOwnerExpiry);
|
|
563
|
-
const expectedOwner = agentOwner;
|
|
564
|
-
if (!expectedOwner) {
|
|
565
|
-
agentOwnerExpiry = null;
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
const expireIfStale = () => {
|
|
569
|
-
agentOwnerExpiry = null;
|
|
570
|
-
if (agentOwner !== expectedOwner)
|
|
571
|
-
return;
|
|
572
|
-
const remaining = AGENT_CLAIM_TIMEOUT_MS - (clock.now() - agentOwnerSeenAt);
|
|
573
|
-
if (remaining > 0) {
|
|
574
|
-
agentOwnerExpiry = clock.setTimeout(expireIfStale, remaining);
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
releaseAgentOwner("The MCP adapter claim expired.");
|
|
578
|
-
};
|
|
579
|
-
agentOwnerExpiry = clock.setTimeout(expireIfStale, AGENT_CLAIM_TIMEOUT_MS);
|
|
580
|
-
};
|
|
581
|
-
const handleAgentClaim = async (request, response) => {
|
|
240
|
+
/** Authenticates the adapter before reading its body, then confirms it owns the session. */
|
|
241
|
+
const readAgentRequest = async (request) => {
|
|
242
|
+
requireAgentControl(request);
|
|
582
243
|
const body = await readJsonBody(request);
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.claim) {
|
|
645
|
-
await handleAgentClaim(request, response);
|
|
646
|
-
return;
|
|
647
|
-
}
|
|
648
|
-
if (request.method === "POST" && url.pathname === AGENT_CONTROL_ENDPOINTS.release) {
|
|
649
|
-
await handleAgentRelease(request, response);
|
|
650
|
-
return;
|
|
244
|
+
session.confirmAgent(decodeAgentId(body.agentId));
|
|
245
|
+
return body;
|
|
246
|
+
};
|
|
247
|
+
const routes = new Map([
|
|
248
|
+
route("GET", BRIDGE_ENDPOINTS.health, (request, response, url) => {
|
|
249
|
+
const origin = browserOrigin(request);
|
|
250
|
+
session.assertProject(url.searchParams.get("projectId"));
|
|
251
|
+
const status = session.getStatus();
|
|
252
|
+
sendJson(response, 200, {
|
|
253
|
+
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
254
|
+
projectId: session.projectId,
|
|
255
|
+
origin,
|
|
256
|
+
status,
|
|
257
|
+
sessionRequired: true,
|
|
258
|
+
pageUrl: status.pageUrl ?? null,
|
|
259
|
+
}, origin);
|
|
260
|
+
}),
|
|
261
|
+
route("POST", BRIDGE_ENDPOINTS.pair, async (request, response) => {
|
|
262
|
+
const { origin, pageUrl, body } = await readPairRequest(request);
|
|
263
|
+
sendJson(response, 200, session.pairBrowser(origin, pageUrl, optionalString(body.sessionToken)), origin);
|
|
264
|
+
}),
|
|
265
|
+
route("POST", BRIDGE_ENDPOINTS.takeover, async (request, response) => {
|
|
266
|
+
const { origin, pageUrl } = await readPairRequest(request);
|
|
267
|
+
sendJson(response, 200, session.takeOver(origin, pageUrl), origin);
|
|
268
|
+
}),
|
|
269
|
+
route("GET", BRIDGE_ENDPOINTS.events, (request, response, url) => {
|
|
270
|
+
session.assertProject(url.searchParams.get("projectId"));
|
|
271
|
+
const { origin } = pairedBrowser(request, url.searchParams.get("sessionToken"));
|
|
272
|
+
response.statusCode = 200;
|
|
273
|
+
response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
274
|
+
response.setHeader("Cache-Control", "no-cache, no-store");
|
|
275
|
+
response.setHeader("Connection", "keep-alive");
|
|
276
|
+
response.setHeader("X-Accel-Buffering", "no");
|
|
277
|
+
setCors(response, origin);
|
|
278
|
+
response.flushHeaders();
|
|
279
|
+
const detach = session.attachStream({
|
|
280
|
+
send: (envelope) => writeSse(response, envelope),
|
|
281
|
+
end: () => response.end(),
|
|
282
|
+
});
|
|
283
|
+
request.once("close", detach);
|
|
284
|
+
response.once("close", detach);
|
|
285
|
+
}),
|
|
286
|
+
route("GET", BRIDGE_ENDPOINTS.status, (request, response, url) => {
|
|
287
|
+
session.assertProject(url.searchParams.get("projectId"));
|
|
288
|
+
const { origin } = pairedBrowser(request, url.searchParams.get("sessionToken"));
|
|
289
|
+
sendJson(response, 200, session.getStatus(), origin);
|
|
290
|
+
}),
|
|
291
|
+
route("POST", BRIDGE_ENDPOINTS.prompt, async (request, response) => {
|
|
292
|
+
const body = await readJsonBody(request, MAX_PROMPT_BODY_BYTES);
|
|
293
|
+
const { origin } = pairedBrowser(request, optionalString(body.sessionToken));
|
|
294
|
+
session.assertProject(body.projectId);
|
|
295
|
+
sendJson(response, 202, session.dispatchPrompt(decodePromptDispatch(body)), origin);
|
|
296
|
+
}),
|
|
297
|
+
route("POST", BRIDGE_ENDPOINTS.canvasAcknowledgement, async (request, response) => {
|
|
298
|
+
const body = await readJsonBody(request);
|
|
299
|
+
const { origin } = pairedBrowser(request, optionalString(body.sessionToken));
|
|
300
|
+
session.assertProject(body.projectId);
|
|
301
|
+
// Acknowledgement payloads flow back to the agent verbatim.
|
|
302
|
+
const { projectId: _projectId, sessionToken: _sessionToken, ...result } = body;
|
|
303
|
+
if (!isCanvasCommandResult(result)) {
|
|
304
|
+
throw new BridgeRequestError(400, "invalid_acknowledgement", "The acknowledgement is not a valid Canvas command result.");
|
|
651
305
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
306
|
+
session.acknowledgeCanvas(result);
|
|
307
|
+
sendJson(response, 202, { commandId: result.commandId, accepted: true }, origin);
|
|
308
|
+
}),
|
|
309
|
+
route("POST", BRIDGE_ENDPOINTS.disconnect, async (request, response) => {
|
|
310
|
+
const body = await readJsonBody(request);
|
|
311
|
+
const grant = pairedBrowser(request, optionalString(body.sessionToken));
|
|
312
|
+
session.disconnectBrowser(grant.sessionToken);
|
|
313
|
+
sendJson(response, 200, { disconnected: true }, grant.origin);
|
|
314
|
+
}),
|
|
315
|
+
route("GET", AGENT_CONTROL_ENDPOINTS.health, (request, response) => {
|
|
316
|
+
requireAgentControl(request);
|
|
317
|
+
sendJson(response, 200, {
|
|
318
|
+
projectId: session.projectId,
|
|
319
|
+
available: true,
|
|
320
|
+
claimed: session.agentClaimed,
|
|
321
|
+
status: session.getStatus(),
|
|
322
|
+
}, null);
|
|
323
|
+
}),
|
|
324
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.claim, async (request, response) => {
|
|
325
|
+
requireAgentControl(request);
|
|
326
|
+
const agentId = decodeAgentId((await readJsonBody(request)).agentId);
|
|
327
|
+
if (!agentId)
|
|
328
|
+
throw new BridgeRequestError(400, "invalid_agent_id", "agentId is required.");
|
|
329
|
+
session.claimAgent(agentId);
|
|
330
|
+
sendJson(response, 200, { claimed: true }, null);
|
|
331
|
+
}),
|
|
332
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.release, async (request, response) => {
|
|
333
|
+
requireAgentControl(request);
|
|
334
|
+
session.releaseAgent(decodeAgentId((await readJsonBody(request)).agentId));
|
|
335
|
+
sendJson(response, 200, { released: true }, null);
|
|
336
|
+
}),
|
|
337
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.heartbeat, async (request, response) => {
|
|
338
|
+
await readAgentRequest(request);
|
|
339
|
+
sendJson(response, 200, { alive: true }, null);
|
|
340
|
+
}),
|
|
341
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.request, async (request, response) => {
|
|
342
|
+
const body = await readAgentRequest(request);
|
|
343
|
+
if (typeof body.requestId !== "string" || !body.requestId || body.requestId.length > AGENT_PROTOCOL_LIMITS.requestId) {
|
|
344
|
+
throw new BridgeRequestError(400, "invalid_request_id", "requestId must be a bounded non-empty string.");
|
|
657
345
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
346
|
+
sendJson(response, 200, session.getRequest(body.requestId), null);
|
|
347
|
+
}),
|
|
348
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.listen, async (request, response) => {
|
|
349
|
+
const body = await readAgentRequest(request);
|
|
350
|
+
const abort = new AbortController();
|
|
351
|
+
response.once("close", () => abort.abort());
|
|
352
|
+
const prompt = await session.waitForPrompt({
|
|
353
|
+
signal: abort.signal,
|
|
354
|
+
agentClientName: optionalString(body.agentClientName),
|
|
355
|
+
});
|
|
356
|
+
sendJson(response, 200, prompt, null);
|
|
357
|
+
}),
|
|
358
|
+
route("GET", AGENT_CONTROL_ENDPOINTS.status, (request, response, url) => {
|
|
359
|
+
requireAgentControl(request);
|
|
360
|
+
session.confirmAgent(decodeAgentId(url.searchParams.get("agentId")));
|
|
361
|
+
sendJson(response, 200, session.getStatus(), null);
|
|
362
|
+
}),
|
|
363
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.reportStatus, async (request, response) => {
|
|
364
|
+
const { update } = await readAgentRequest(request);
|
|
365
|
+
if (!isAgentStatusUpdate(update)) {
|
|
366
|
+
throw new BridgeRequestError(400, "invalid_status", "update must be a valid agent status update.");
|
|
661
367
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
368
|
+
session.updateRequestStatus(update);
|
|
369
|
+
sendJson(response, 200, session.getStatus(), null);
|
|
370
|
+
}),
|
|
371
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.activity, async (request, response) => {
|
|
372
|
+
const { activity } = await readAgentRequest(request);
|
|
373
|
+
if (!isAgentActivity(activity)) {
|
|
374
|
+
throw new BridgeRequestError(400, "invalid_activity", "activity must identify a workspace-relative source file.");
|
|
669
375
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
376
|
+
session.reportActivity(activity);
|
|
377
|
+
sendJson(response, 200, { accepted: true }, null);
|
|
378
|
+
}),
|
|
379
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.canvas, async (request, response) => {
|
|
380
|
+
const { command } = await readAgentRequest(request);
|
|
381
|
+
if (!isCanvasCommand(command)) {
|
|
382
|
+
throw new BridgeRequestError(400, "invalid_canvas_command", "command must be a valid Canvas command.");
|
|
673
383
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
384
|
+
sendJson(response, 200, await session.dispatchCanvasCommand(command), null);
|
|
385
|
+
}),
|
|
386
|
+
]);
|
|
387
|
+
const handleOptions = (request, response) => {
|
|
388
|
+
const origin = requestOrigin(request);
|
|
389
|
+
if (origin) {
|
|
390
|
+
try {
|
|
391
|
+
session.authorizeOrigin(origin);
|
|
677
392
|
}
|
|
678
|
-
|
|
679
|
-
|
|
393
|
+
catch (error) {
|
|
394
|
+
const { statusCode, body } = errorResponse(error);
|
|
395
|
+
sendJson(response, statusCode, body, null);
|
|
680
396
|
return;
|
|
681
397
|
}
|
|
398
|
+
setCors(response, origin);
|
|
399
|
+
}
|
|
400
|
+
response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
401
|
+
response.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
402
|
+
response.statusCode = 204;
|
|
403
|
+
response.end();
|
|
404
|
+
};
|
|
405
|
+
const httpServer = createServer((request, response) => {
|
|
406
|
+
void (async () => {
|
|
407
|
+
const url = new URL(request.url ?? "/", `http://${host}:${port}`);
|
|
682
408
|
if (request.method === "OPTIONS") {
|
|
683
409
|
handleOptions(request, response);
|
|
684
410
|
return;
|
|
685
411
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
}
|
|
690
|
-
if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.pair) {
|
|
691
|
-
await handlePair(request, response);
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
|
-
if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.takeover) {
|
|
695
|
-
await handleTakeover(request, response);
|
|
696
|
-
return;
|
|
697
|
-
}
|
|
698
|
-
if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.events) {
|
|
699
|
-
handleEvents(request, response, url);
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
if (request.method === "GET" && url.pathname === BRIDGE_ENDPOINTS.status) {
|
|
703
|
-
handleStatus(request, response, url);
|
|
412
|
+
const handler = routes.get(`${request.method ?? ""} ${url.pathname}`);
|
|
413
|
+
if (!handler) {
|
|
414
|
+
sendJson(response, 404, { error: { code: "not_found", message: "The bridge endpoint does not exist." } }, requestOrigin(request));
|
|
704
415
|
return;
|
|
705
416
|
}
|
|
706
|
-
|
|
707
|
-
await handlePrompt(request, response);
|
|
708
|
-
return;
|
|
709
|
-
}
|
|
710
|
-
if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.canvasAcknowledgement) {
|
|
711
|
-
await handleCanvasAcknowledgement(request, response);
|
|
712
|
-
return;
|
|
713
|
-
}
|
|
714
|
-
if (request.method === "POST" && url.pathname === BRIDGE_ENDPOINTS.disconnect) {
|
|
715
|
-
await handleDisconnect(request, response);
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
sendJson(response, 404, { error: { code: "not_found", message: "The bridge endpoint does not exist." } }, requestOrigin(request));
|
|
417
|
+
await handler(request, response, url);
|
|
719
418
|
})().catch((error) => {
|
|
720
|
-
|
|
419
|
+
const { statusCode, body } = errorResponse(error);
|
|
420
|
+
sendJson(response, statusCode, body, requestOrigin(request));
|
|
721
421
|
});
|
|
722
422
|
});
|
|
423
|
+
let closed = false;
|
|
424
|
+
let currentAddress = null;
|
|
425
|
+
let startPromise = null;
|
|
426
|
+
let closePromise = null;
|
|
723
427
|
const start = () => {
|
|
724
|
-
if (
|
|
428
|
+
if (closed)
|
|
725
429
|
return Promise.reject(new Error("The browser bridge is closed."));
|
|
726
|
-
if (
|
|
430
|
+
if (currentAddress)
|
|
727
431
|
return Promise.resolve(currentAddress);
|
|
728
|
-
|
|
729
|
-
return startPromise;
|
|
730
|
-
startPromise = new Promise((resolveStart, rejectStart) => {
|
|
432
|
+
startPromise ??= new Promise((resolveStart, rejectStart) => {
|
|
731
433
|
const onError = (error) => {
|
|
732
434
|
httpServer.off("listening", onListening);
|
|
733
435
|
startPromise = null;
|
|
@@ -741,7 +443,6 @@ export function createLoopbackBridge(options) {
|
|
|
741
443
|
rejectStart(new Error("The browser bridge did not expose a TCP address."));
|
|
742
444
|
return;
|
|
743
445
|
}
|
|
744
|
-
lifecycle = "listening";
|
|
745
446
|
currentAddress = {
|
|
746
447
|
host,
|
|
747
448
|
port: address.port,
|
|
@@ -755,217 +456,10 @@ export function createLoopbackBridge(options) {
|
|
|
755
456
|
});
|
|
756
457
|
return startPromise;
|
|
757
458
|
};
|
|
758
|
-
const cancelListener = (reason = "listener_cancelled") => {
|
|
759
|
-
if (!listener)
|
|
760
|
-
return;
|
|
761
|
-
const pending = listener;
|
|
762
|
-
listener = null;
|
|
763
|
-
if (pending.abortHandler && pending.signal)
|
|
764
|
-
pending.signal.removeEventListener("abort", pending.abortHandler);
|
|
765
|
-
pending.reject(new BridgeRequestError(409, "listener_cancelled", reason));
|
|
766
|
-
broadcastStatus();
|
|
767
|
-
};
|
|
768
|
-
const waitForPrompt = (signal, _sessionId, clientName) => {
|
|
769
|
-
if (lifecycle === "closed")
|
|
770
|
-
return Promise.reject(new Error("The browser bridge is closed."));
|
|
771
|
-
if (listener)
|
|
772
|
-
return Promise.reject(new Error("A listening call is already active for this project."));
|
|
773
|
-
if (currentRequest?.status === "working")
|
|
774
|
-
return Promise.reject(new Error("A request is already in flight for this project."));
|
|
775
|
-
agentClientName = clientName?.trim().slice(0, 120) || undefined;
|
|
776
|
-
// Keep the terminal outcome visible until the agent explicitly rearms its
|
|
777
|
-
// listening call. Rearming is the lifecycle boundary for the old request.
|
|
778
|
-
if (currentRequest)
|
|
779
|
-
currentRequest = null;
|
|
780
|
-
const pendingDeferred = deferred();
|
|
781
|
-
const pending = {
|
|
782
|
-
resolve: pendingDeferred.resolve,
|
|
783
|
-
reject: pendingDeferred.reject,
|
|
784
|
-
signal,
|
|
785
|
-
};
|
|
786
|
-
if (signal) {
|
|
787
|
-
if (signal.aborted)
|
|
788
|
-
return Promise.reject(new Error("The listening call was aborted."));
|
|
789
|
-
pending.abortHandler = () => {
|
|
790
|
-
if (listener !== pending)
|
|
791
|
-
return;
|
|
792
|
-
listener = null;
|
|
793
|
-
pending.reject(new Error("The listening call was aborted."));
|
|
794
|
-
broadcastStatus();
|
|
795
|
-
};
|
|
796
|
-
signal.addEventListener("abort", pending.abortHandler, { once: true });
|
|
797
|
-
}
|
|
798
|
-
listener = pending;
|
|
799
|
-
broadcastStatus();
|
|
800
|
-
if (sessionToken && lastPageUrl && sseClients.size === 0 && options.onControllerUnavailable) {
|
|
801
|
-
void Promise.resolve(options.onControllerUnavailable(lastPageUrl)).catch(() => undefined);
|
|
802
|
-
}
|
|
803
|
-
return pendingDeferred.promise;
|
|
804
|
-
};
|
|
805
|
-
const updateRequestStatus = (update) => {
|
|
806
|
-
if (!isAgentStatusUpdate(update))
|
|
807
|
-
throw new TypeError("Invalid agent status update");
|
|
808
|
-
if (!currentRequest || currentRequest.requestId !== update.requestId) {
|
|
809
|
-
throw new BridgeRequestError(409, "unknown_request", "The request is not in flight for this project.");
|
|
810
|
-
}
|
|
811
|
-
if (update.status === "working") {
|
|
812
|
-
currentRequest = { ...currentRequest, status: "working" };
|
|
813
|
-
}
|
|
814
|
-
else {
|
|
815
|
-
currentRequest = {
|
|
816
|
-
...currentRequest,
|
|
817
|
-
status: update.status,
|
|
818
|
-
...(update.summary === undefined ? {} : { summary: update.summary }),
|
|
819
|
-
...(update.error === undefined ? {} : { error: update.error }),
|
|
820
|
-
};
|
|
821
|
-
}
|
|
822
|
-
broadcastStatus();
|
|
823
|
-
};
|
|
824
|
-
const reportActivity = (activity) => {
|
|
825
|
-
if (!isAgentActivity(activity))
|
|
826
|
-
throw new TypeError("Invalid agent activity");
|
|
827
|
-
if (!currentRequest || currentRequest.requestId !== activity.requestId || currentRequest.status !== "working") {
|
|
828
|
-
throw new BridgeRequestError(409, "inactive_request", "Activity requires the currently working request.");
|
|
829
|
-
}
|
|
830
|
-
broadcast({ type: "activity", activity });
|
|
831
|
-
};
|
|
832
|
-
const dispatchCanvasCommand = (input) => {
|
|
833
|
-
if (lifecycle === "closed")
|
|
834
|
-
return Promise.reject(new Error("The browser bridge is closed."));
|
|
835
|
-
if (!sessionToken)
|
|
836
|
-
return Promise.reject(new Error("No browser is paired with this project."));
|
|
837
|
-
if (pendingCanvas)
|
|
838
|
-
return Promise.reject(new Error("A Canvas command is already in flight."));
|
|
839
|
-
const command = { ...input, commandId: input.commandId ?? idFactory() };
|
|
840
|
-
if (!isCanvasCommand(command))
|
|
841
|
-
return Promise.reject(new TypeError("Invalid Canvas command"));
|
|
842
|
-
// The browser performs the final route check too, but rejecting here keeps
|
|
843
|
-
// the agent side from dispatching a cross-origin URL into the project.
|
|
844
|
-
const routeOrigin = pairedOrigin ?? configuredOrigin;
|
|
845
|
-
if (command.type === "present-routes" && !routeOrigin) {
|
|
846
|
-
return Promise.reject(new Error("Canvas routes require a paired browser origin"));
|
|
847
|
-
}
|
|
848
|
-
if (command.type === "present-routes" && command.routes.some((route) => !isSameOriginRoute(route, routeOrigin))) {
|
|
849
|
-
return Promise.reject(new TypeError("Canvas routes must belong to the paired project origin"));
|
|
850
|
-
}
|
|
851
|
-
if (command.type === "present-routes") {
|
|
852
|
-
try {
|
|
853
|
-
validateRoutes(command.routes, routeOrigin);
|
|
854
|
-
}
|
|
855
|
-
catch (error) {
|
|
856
|
-
return Promise.reject(error);
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
const pendingDeferred = deferred();
|
|
860
|
-
const timeout = clock.setTimeout(() => {
|
|
861
|
-
if (!pendingCanvas || pendingCanvas.command.commandId !== command.commandId)
|
|
862
|
-
return;
|
|
863
|
-
pendingCanvas = null;
|
|
864
|
-
pendingDeferred.resolve({
|
|
865
|
-
commandId: command.commandId,
|
|
866
|
-
ok: false,
|
|
867
|
-
error: { code: "ack_timeout", message: "The browser did not acknowledge the Canvas command in time." },
|
|
868
|
-
});
|
|
869
|
-
}, timeoutMs);
|
|
870
|
-
pendingCanvas = { command, resolve: pendingDeferred.resolve, timeout };
|
|
871
|
-
const event = { type: "canvas-command", command };
|
|
872
|
-
broadcast(event);
|
|
873
|
-
return pendingDeferred.promise;
|
|
874
|
-
};
|
|
875
|
-
const pairBrowser = (projectId, requestOriginValue, pageUrl) => {
|
|
876
|
-
if (projectId !== options.projectId)
|
|
877
|
-
throw new Error("Project identity does not match this bridge");
|
|
878
|
-
const requestCanonicalOrigin = canonicalOrigin(requestOriginValue);
|
|
879
|
-
if (!requestCanonicalOrigin)
|
|
880
|
-
throw new Error("Browser origin does not match this bridge");
|
|
881
|
-
// This method is the explicit host-side approval path. A bridge without
|
|
882
|
-
// an HTTP allow-list can still be paired by trusted host code, while all
|
|
883
|
-
// browser-originated requests remain closed until that approval exists.
|
|
884
|
-
if (canonicalAllowedOrigins.length > 0 && !originIsAllowed(requestCanonicalOrigin)) {
|
|
885
|
-
throw new Error("Browser origin is not in the configured allow-list");
|
|
886
|
-
}
|
|
887
|
-
let requestedPageUrl;
|
|
888
|
-
if (pageUrl !== undefined) {
|
|
889
|
-
if (!isSameOriginRoute({ url: pageUrl }, requestCanonicalOrigin)) {
|
|
890
|
-
throw new Error("Browser page URL does not match this bridge");
|
|
891
|
-
}
|
|
892
|
-
requestedPageUrl = new URL(pageUrl, requestCanonicalOrigin).href;
|
|
893
|
-
}
|
|
894
|
-
if (sessionToken)
|
|
895
|
-
throw new Error("A browser is already paired with this project");
|
|
896
|
-
const nextSessionToken = tokenFactory();
|
|
897
|
-
if (!nextSessionToken || nextSessionToken.length > AGENT_PROTOCOL_LIMITS.sessionToken) {
|
|
898
|
-
throw new Error("tokenFactory returned an invalid session token");
|
|
899
|
-
}
|
|
900
|
-
sessionToken = nextSessionToken;
|
|
901
|
-
pairedOrigin = requestCanonicalOrigin;
|
|
902
|
-
lastPageUrl = requestedPageUrl ?? null;
|
|
903
|
-
const result = {
|
|
904
|
-
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
905
|
-
projectId: options.projectId,
|
|
906
|
-
origin: requestCanonicalOrigin,
|
|
907
|
-
sessionToken,
|
|
908
|
-
...(lastPageUrl === null ? {} : { pageUrl: lastPageUrl }),
|
|
909
|
-
status: getStatus(),
|
|
910
|
-
};
|
|
911
|
-
broadcast({ type: "connected", status: getStatus() });
|
|
912
|
-
broadcastStatus();
|
|
913
|
-
return result;
|
|
914
|
-
};
|
|
915
|
-
const disconnectBrowser = (token) => {
|
|
916
|
-
parseToken(token, sessionToken);
|
|
917
|
-
if (currentRequest?.status === "working") {
|
|
918
|
-
currentRequest = {
|
|
919
|
-
...currentRequest,
|
|
920
|
-
status: "interrupted",
|
|
921
|
-
error: "The paired browser disconnected while the request was in flight.",
|
|
922
|
-
};
|
|
923
|
-
broadcastStatus();
|
|
924
|
-
}
|
|
925
|
-
broadcast({ type: "disconnected", reason: "browser_disconnected" });
|
|
926
|
-
sessionToken = null;
|
|
927
|
-
pairedOrigin = null;
|
|
928
|
-
for (const response of [...sseClients])
|
|
929
|
-
response.end();
|
|
930
|
-
sseClients.clear();
|
|
931
|
-
if (pendingCanvas) {
|
|
932
|
-
clock.clearTimeout(pendingCanvas.timeout);
|
|
933
|
-
pendingCanvas.resolve({
|
|
934
|
-
commandId: pendingCanvas.command.commandId,
|
|
935
|
-
ok: false,
|
|
936
|
-
error: { code: "browser_disconnected", message: "The paired browser disconnected before acknowledging the command." },
|
|
937
|
-
});
|
|
938
|
-
pendingCanvas = null;
|
|
939
|
-
}
|
|
940
|
-
lastPageUrl = null;
|
|
941
|
-
};
|
|
942
459
|
const close = () => {
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
if (agentOwnerExpiry !== null)
|
|
947
|
-
clock.clearTimeout(agentOwnerExpiry);
|
|
948
|
-
agentOwnerExpiry = null;
|
|
949
|
-
agentOwner = null;
|
|
950
|
-
agentClientName = undefined;
|
|
951
|
-
agentOwnerSeenAt = 0;
|
|
952
|
-
cancelListener("bridge_closed");
|
|
953
|
-
if (pendingCanvas) {
|
|
954
|
-
clock.clearTimeout(pendingCanvas.timeout);
|
|
955
|
-
pendingCanvas.resolve({
|
|
956
|
-
commandId: pendingCanvas.command.commandId,
|
|
957
|
-
ok: false,
|
|
958
|
-
error: { code: "bridge_closed", message: "The browser bridge closed before acknowledging the command." },
|
|
959
|
-
});
|
|
960
|
-
pendingCanvas = null;
|
|
961
|
-
}
|
|
962
|
-
for (const response of [...sseClients])
|
|
963
|
-
response.end();
|
|
964
|
-
sseClients.clear();
|
|
965
|
-
sessionToken = null;
|
|
966
|
-
pairedOrigin = null;
|
|
967
|
-
lastPageUrl = null;
|
|
968
|
-
closePromise = new Promise((resolveClose) => {
|
|
460
|
+
closed = true;
|
|
461
|
+
session.close();
|
|
462
|
+
closePromise ??= new Promise((resolveClose) => {
|
|
969
463
|
if (!httpServer.listening) {
|
|
970
464
|
resolveClose();
|
|
971
465
|
return;
|
|
@@ -974,25 +468,18 @@ export function createLoopbackBridge(options) {
|
|
|
974
468
|
});
|
|
975
469
|
return closePromise;
|
|
976
470
|
};
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
projectId: options.projectId,
|
|
980
|
-
origin: configuredOrigin,
|
|
981
|
-
get effectiveOrigin() { return pairedOrigin ?? configuredOrigin; },
|
|
982
|
-
get lifecycle() { return lifecycle; },
|
|
471
|
+
return {
|
|
472
|
+
projectId: session.projectId,
|
|
983
473
|
get address() { return currentAddress; },
|
|
984
|
-
get sessionToken() { return sessionToken; },
|
|
985
|
-
get lastPageUrl() { return lastPageUrl; },
|
|
986
|
-
getStatus,
|
|
987
474
|
start,
|
|
988
475
|
close,
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
476
|
+
getStatus: session.getStatus,
|
|
477
|
+
getRequest: session.getRequest,
|
|
478
|
+
pairBrowser: (origin, pageUrl) => session.pairBrowser(origin, pageUrl),
|
|
479
|
+
disconnectBrowser: session.disconnectBrowser,
|
|
480
|
+
waitForPrompt: session.waitForPrompt,
|
|
481
|
+
updateRequestStatus: session.updateRequestStatus,
|
|
482
|
+
reportActivity: session.reportActivity,
|
|
483
|
+
dispatchCanvasCommand: session.dispatchCanvasCommand,
|
|
996
484
|
};
|
|
997
|
-
return bridge;
|
|
998
485
|
}
|