@nudge-ui/mcp 0.2.3 → 0.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/README.md +18 -0
- package/dist/bridge.d.ts +6 -52
- package/dist/bridge.js +292 -802
- package/dist/bridgeSession.d.ts +96 -0
- package/dist/bridgeSession.js +438 -0
- package/dist/cli.d.ts +3 -29
- package/dist/cli.js +54 -168
- 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 +50 -13
- package/dist/internal/agent-protocol.js +145 -19
- package/dist/project.d.ts +1 -1
- package/dist/project.js +2 -2
- package/dist/projectSessionClient.d.ts +5 -4
- package/dist/projectSessionClient.js +38 -13
- package/dist/protocol.d.ts +2 -1
- package/dist/protocol.js +2 -1
- package/dist/server.d.ts +3 -27
- package/dist/server.js +65 -136
- package/dist/sessionRegistry.js +1 -21
- 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,291 @@ 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
190
|
/**
|
|
181
191
|
* Creates the local browser bridge. It binds only to loopback and keeps every
|
|
182
192
|
* pairing, prompt, command, and acknowledgement in process memory.
|
|
183
193
|
*/
|
|
184
194
|
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
195
|
const host = options.host ?? "127.0.0.1";
|
|
193
196
|
if (!LOOPBACK_HOSTS.has(host))
|
|
194
197
|
throw new TypeError("The browser bridge must bind to a loopback host");
|
|
195
198
|
const port = options.port ?? 0;
|
|
196
199
|
if (!validPort(port))
|
|
197
200
|
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;
|
|
201
|
+
const { agentControlToken } = options;
|
|
214
202
|
if (agentControlToken !== undefined && (agentControlToken.length < 32 || agentControlToken.length > 256)) {
|
|
215
203
|
throw new TypeError("agentControlToken must contain between 32 and 256 characters");
|
|
216
204
|
}
|
|
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();
|
|
205
|
+
const session = createBridgeSession({
|
|
206
|
+
projectId: options.projectId,
|
|
207
|
+
origin: options.origin,
|
|
208
|
+
allowedOrigins: options.allowedOrigins,
|
|
209
|
+
commandTimeoutMs: options.commandTimeoutMs,
|
|
210
|
+
clock: options.clock,
|
|
211
|
+
tokenFactory: options.tokenFactory ?? (() => randomBytes(32).toString("base64url")),
|
|
212
|
+
idFactory: options.idFactory ?? randomUUID,
|
|
213
|
+
});
|
|
214
|
+
const browserOrigin = (request) => {
|
|
215
|
+
requireLoopback(request, "loopback_only", "The browser bridge accepts loopback connections only.");
|
|
216
|
+
return session.authorizeOrigin(requestOrigin(request));
|
|
296
217
|
};
|
|
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);
|
|
218
|
+
const pairedBrowser = (request, sessionToken) => {
|
|
219
|
+
requireLoopback(request, "loopback_only", "The browser bridge accepts loopback connections only.");
|
|
220
|
+
return session.authorizeBrowser(requestOrigin(request), sessionToken);
|
|
314
221
|
};
|
|
315
222
|
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
|
-
}
|
|
223
|
+
const origin = browserOrigin(request);
|
|
323
224
|
const body = await readJsonBody(request);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (!requestedOrigin || requestedOrigin !== incomingOrigin) {
|
|
225
|
+
session.assertProject(body.projectId);
|
|
226
|
+
if (canonicalOrigin(body.origin) !== origin) {
|
|
327
227
|
throw new BridgeRequestError(403, "origin_mismatch", "Pairing requires an explicitly configured project origin.");
|
|
328
228
|
}
|
|
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;
|
|
229
|
+
if (body.pageUrl !== undefined && typeof body.pageUrl !== "string") {
|
|
230
|
+
throw new BridgeRequestError(400, "invalid_page_url", "pageUrl must be a bounded same-origin URL.");
|
|
336
231
|
}
|
|
337
|
-
return {
|
|
232
|
+
return { origin, ...(body.pageUrl === undefined ? {} : { pageUrl: body.pageUrl }), body };
|
|
338
233
|
};
|
|
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)) {
|
|
234
|
+
const requireAgentControl = (request) => {
|
|
235
|
+
if (!agentControlToken) {
|
|
515
236
|
throw new BridgeRequestError(403, "agent_control_unavailable", "Private agent control is unavailable.");
|
|
516
237
|
}
|
|
238
|
+
requireLoopback(request, "agent_control_unavailable", "Private agent control is unavailable.");
|
|
517
239
|
if (headerValue(request, "authorization") !== `Bearer ${agentControlToken}`) {
|
|
518
240
|
throw new BridgeRequestError(401, "invalid_agent_credential", "The agent control credential is invalid.");
|
|
519
241
|
}
|
|
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
242
|
};
|
|
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) => {
|
|
243
|
+
/** Authenticates the adapter before reading its body, then confirms it owns the session. */
|
|
244
|
+
const readAgentRequest = async (request) => {
|
|
245
|
+
requireAgentControl(request);
|
|
582
246
|
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;
|
|
247
|
+
session.confirmAgent(decodeAgentId(body.agentId));
|
|
248
|
+
return body;
|
|
249
|
+
};
|
|
250
|
+
const routes = new Map([
|
|
251
|
+
route("GET", BRIDGE_ENDPOINTS.health, (request, response, url) => {
|
|
252
|
+
const origin = browserOrigin(request);
|
|
253
|
+
session.assertProject(url.searchParams.get("projectId"));
|
|
254
|
+
const status = session.getStatus();
|
|
255
|
+
sendJson(response, 200, {
|
|
256
|
+
protocolVersion: AGENT_PROTOCOL_VERSION,
|
|
257
|
+
projectId: session.projectId,
|
|
258
|
+
origin,
|
|
259
|
+
status,
|
|
260
|
+
sessionRequired: true,
|
|
261
|
+
pageUrl: status.pageUrl ?? null,
|
|
262
|
+
}, origin);
|
|
263
|
+
}),
|
|
264
|
+
route("POST", BRIDGE_ENDPOINTS.pair, async (request, response) => {
|
|
265
|
+
const { origin, pageUrl, body } = await readPairRequest(request);
|
|
266
|
+
sendJson(response, 200, session.pairBrowser(origin, pageUrl, optionalString(body.sessionToken)), origin);
|
|
267
|
+
}),
|
|
268
|
+
route("POST", BRIDGE_ENDPOINTS.takeover, async (request, response) => {
|
|
269
|
+
const { origin, pageUrl } = await readPairRequest(request);
|
|
270
|
+
sendJson(response, 200, session.takeOver(origin, pageUrl), origin);
|
|
271
|
+
}),
|
|
272
|
+
route("GET", BRIDGE_ENDPOINTS.events, (request, response, url) => {
|
|
273
|
+
session.assertProject(url.searchParams.get("projectId"));
|
|
274
|
+
const { origin } = pairedBrowser(request, url.searchParams.get("sessionToken"));
|
|
275
|
+
response.statusCode = 200;
|
|
276
|
+
response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
277
|
+
response.setHeader("Cache-Control", "no-cache, no-store");
|
|
278
|
+
response.setHeader("Connection", "keep-alive");
|
|
279
|
+
response.setHeader("X-Accel-Buffering", "no");
|
|
280
|
+
setCors(response, origin);
|
|
281
|
+
response.flushHeaders();
|
|
282
|
+
const detach = session.attachStream({
|
|
283
|
+
send: (envelope) => writeSse(response, envelope),
|
|
284
|
+
end: () => response.end(),
|
|
285
|
+
});
|
|
286
|
+
request.once("close", detach);
|
|
287
|
+
response.once("close", detach);
|
|
288
|
+
}),
|
|
289
|
+
route("GET", BRIDGE_ENDPOINTS.status, (request, response, url) => {
|
|
290
|
+
session.assertProject(url.searchParams.get("projectId"));
|
|
291
|
+
const { origin } = pairedBrowser(request, url.searchParams.get("sessionToken"));
|
|
292
|
+
sendJson(response, 200, session.getStatus(), origin);
|
|
293
|
+
}),
|
|
294
|
+
route("POST", BRIDGE_ENDPOINTS.prompt, async (request, response) => {
|
|
295
|
+
const body = await readJsonBody(request, MAX_PROMPT_BODY_BYTES);
|
|
296
|
+
const { origin } = pairedBrowser(request, optionalString(body.sessionToken));
|
|
297
|
+
session.assertProject(body.projectId);
|
|
298
|
+
sendJson(response, 202, session.dispatchPrompt(decodePromptDispatch(body)), origin);
|
|
299
|
+
}),
|
|
300
|
+
route("POST", BRIDGE_ENDPOINTS.canvasAcknowledgement, async (request, response) => {
|
|
301
|
+
const body = await readJsonBody(request);
|
|
302
|
+
const { origin } = pairedBrowser(request, optionalString(body.sessionToken));
|
|
303
|
+
session.assertProject(body.projectId);
|
|
304
|
+
// Acknowledgement payloads flow back to the agent verbatim.
|
|
305
|
+
const { projectId: _projectId, sessionToken: _sessionToken, ...result } = body;
|
|
306
|
+
if (!isCanvasCommandResult(result)) {
|
|
307
|
+
throw new BridgeRequestError(400, "invalid_acknowledgement", "The acknowledgement is not a valid Canvas command result.");
|
|
651
308
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
309
|
+
session.acknowledgeCanvas(result);
|
|
310
|
+
sendJson(response, 202, { commandId: result.commandId, accepted: true }, origin);
|
|
311
|
+
}),
|
|
312
|
+
route("POST", BRIDGE_ENDPOINTS.disconnect, async (request, response) => {
|
|
313
|
+
const body = await readJsonBody(request);
|
|
314
|
+
const grant = pairedBrowser(request, optionalString(body.sessionToken));
|
|
315
|
+
session.disconnectBrowser(grant.sessionToken);
|
|
316
|
+
sendJson(response, 200, { disconnected: true }, grant.origin);
|
|
317
|
+
}),
|
|
318
|
+
route("GET", AGENT_CONTROL_ENDPOINTS.health, (request, response) => {
|
|
319
|
+
requireAgentControl(request);
|
|
320
|
+
sendJson(response, 200, {
|
|
321
|
+
projectId: session.projectId,
|
|
322
|
+
available: true,
|
|
323
|
+
claimed: session.agentClaimed,
|
|
324
|
+
status: session.getStatus(),
|
|
325
|
+
}, null);
|
|
326
|
+
}),
|
|
327
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.claim, async (request, response) => {
|
|
328
|
+
requireAgentControl(request);
|
|
329
|
+
const agentId = decodeAgentId((await readJsonBody(request)).agentId);
|
|
330
|
+
if (!agentId)
|
|
331
|
+
throw new BridgeRequestError(400, "invalid_agent_id", "agentId is required.");
|
|
332
|
+
session.claimAgent(agentId);
|
|
333
|
+
sendJson(response, 200, { claimed: true }, null);
|
|
334
|
+
}),
|
|
335
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.release, async (request, response) => {
|
|
336
|
+
requireAgentControl(request);
|
|
337
|
+
session.releaseAgent(decodeAgentId((await readJsonBody(request)).agentId));
|
|
338
|
+
sendJson(response, 200, { released: true }, null);
|
|
339
|
+
}),
|
|
340
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.heartbeat, async (request, response) => {
|
|
341
|
+
await readAgentRequest(request);
|
|
342
|
+
sendJson(response, 200, { alive: true }, null);
|
|
343
|
+
}),
|
|
344
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.request, async (request, response) => {
|
|
345
|
+
const body = await readAgentRequest(request);
|
|
346
|
+
if (typeof body.requestId !== "string" || !body.requestId || body.requestId.length > AGENT_PROTOCOL_LIMITS.requestId) {
|
|
347
|
+
throw new BridgeRequestError(400, "invalid_request_id", "requestId must be a bounded non-empty string.");
|
|
657
348
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
349
|
+
sendJson(response, 200, session.getRequest(body.requestId), null);
|
|
350
|
+
}),
|
|
351
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.listen, async (request, response) => {
|
|
352
|
+
const body = await readAgentRequest(request);
|
|
353
|
+
const abort = new AbortController();
|
|
354
|
+
response.once("close", () => abort.abort());
|
|
355
|
+
const prompt = await session.waitForPrompt({
|
|
356
|
+
signal: abort.signal,
|
|
357
|
+
agentClientName: optionalString(body.agentClientName),
|
|
358
|
+
});
|
|
359
|
+
sendJson(response, 200, prompt, null);
|
|
360
|
+
}),
|
|
361
|
+
route("GET", AGENT_CONTROL_ENDPOINTS.status, (request, response, url) => {
|
|
362
|
+
requireAgentControl(request);
|
|
363
|
+
session.confirmAgent(decodeAgentId(url.searchParams.get("agentId")));
|
|
364
|
+
sendJson(response, 200, session.getStatus(), null);
|
|
365
|
+
}),
|
|
366
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.reportStatus, async (request, response) => {
|
|
367
|
+
const { update } = await readAgentRequest(request);
|
|
368
|
+
if (!isAgentStatusUpdate(update)) {
|
|
369
|
+
throw new BridgeRequestError(400, "invalid_status", "update must be a valid agent status update.");
|
|
661
370
|
}
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
371
|
+
session.updateRequestStatus(update);
|
|
372
|
+
sendJson(response, 200, session.getStatus(), null);
|
|
373
|
+
}),
|
|
374
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.activity, async (request, response) => {
|
|
375
|
+
const { activity } = await readAgentRequest(request);
|
|
376
|
+
if (!isAgentActivity(activity)) {
|
|
377
|
+
throw new BridgeRequestError(400, "invalid_activity", "activity must identify a workspace-relative source file.");
|
|
669
378
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
379
|
+
session.reportActivity(activity);
|
|
380
|
+
sendJson(response, 200, { accepted: true }, null);
|
|
381
|
+
}),
|
|
382
|
+
route("POST", AGENT_CONTROL_ENDPOINTS.canvas, async (request, response) => {
|
|
383
|
+
const { command } = await readAgentRequest(request);
|
|
384
|
+
if (!isCanvasCommand(command)) {
|
|
385
|
+
throw new BridgeRequestError(400, "invalid_canvas_command", "command must be a valid Canvas command.");
|
|
673
386
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
387
|
+
sendJson(response, 200, await session.dispatchCanvasCommand(command), null);
|
|
388
|
+
}),
|
|
389
|
+
]);
|
|
390
|
+
const handleOptions = (request, response) => {
|
|
391
|
+
const origin = requestOrigin(request);
|
|
392
|
+
if (origin) {
|
|
393
|
+
try {
|
|
394
|
+
session.authorizeOrigin(origin);
|
|
677
395
|
}
|
|
678
|
-
|
|
679
|
-
|
|
396
|
+
catch (error) {
|
|
397
|
+
const { statusCode, body } = errorResponse(error);
|
|
398
|
+
sendJson(response, statusCode, body, null);
|
|
680
399
|
return;
|
|
681
400
|
}
|
|
401
|
+
setCors(response, origin);
|
|
402
|
+
}
|
|
403
|
+
response.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
404
|
+
response.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
405
|
+
response.statusCode = 204;
|
|
406
|
+
response.end();
|
|
407
|
+
};
|
|
408
|
+
const httpServer = createServer((request, response) => {
|
|
409
|
+
void (async () => {
|
|
410
|
+
const url = new URL(request.url ?? "/", `http://${host}:${port}`);
|
|
682
411
|
if (request.method === "OPTIONS") {
|
|
683
412
|
handleOptions(request, response);
|
|
684
413
|
return;
|
|
685
414
|
}
|
|
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);
|
|
415
|
+
const handler = routes.get(`${request.method ?? ""} ${url.pathname}`);
|
|
416
|
+
if (!handler) {
|
|
417
|
+
sendJson(response, 404, { error: { code: "not_found", message: "The bridge endpoint does not exist." } }, requestOrigin(request));
|
|
704
418
|
return;
|
|
705
419
|
}
|
|
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));
|
|
420
|
+
await handler(request, response, url);
|
|
719
421
|
})().catch((error) => {
|
|
720
|
-
|
|
422
|
+
const { statusCode, body } = errorResponse(error);
|
|
423
|
+
sendJson(response, statusCode, body, requestOrigin(request));
|
|
721
424
|
});
|
|
722
425
|
});
|
|
426
|
+
let closed = false;
|
|
427
|
+
let currentAddress = null;
|
|
428
|
+
let startPromise = null;
|
|
429
|
+
let closePromise = null;
|
|
723
430
|
const start = () => {
|
|
724
|
-
if (
|
|
431
|
+
if (closed)
|
|
725
432
|
return Promise.reject(new Error("The browser bridge is closed."));
|
|
726
|
-
if (
|
|
433
|
+
if (currentAddress)
|
|
727
434
|
return Promise.resolve(currentAddress);
|
|
728
|
-
|
|
729
|
-
return startPromise;
|
|
730
|
-
startPromise = new Promise((resolveStart, rejectStart) => {
|
|
435
|
+
startPromise ??= new Promise((resolveStart, rejectStart) => {
|
|
731
436
|
const onError = (error) => {
|
|
732
437
|
httpServer.off("listening", onListening);
|
|
733
438
|
startPromise = null;
|
|
@@ -741,7 +446,6 @@ export function createLoopbackBridge(options) {
|
|
|
741
446
|
rejectStart(new Error("The browser bridge did not expose a TCP address."));
|
|
742
447
|
return;
|
|
743
448
|
}
|
|
744
|
-
lifecycle = "listening";
|
|
745
449
|
currentAddress = {
|
|
746
450
|
host,
|
|
747
451
|
port: address.port,
|
|
@@ -755,217 +459,10 @@ export function createLoopbackBridge(options) {
|
|
|
755
459
|
});
|
|
756
460
|
return startPromise;
|
|
757
461
|
};
|
|
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
462
|
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) => {
|
|
463
|
+
closed = true;
|
|
464
|
+
session.close();
|
|
465
|
+
closePromise ??= new Promise((resolveClose) => {
|
|
969
466
|
if (!httpServer.listening) {
|
|
970
467
|
resolveClose();
|
|
971
468
|
return;
|
|
@@ -974,25 +471,18 @@ export function createLoopbackBridge(options) {
|
|
|
974
471
|
});
|
|
975
472
|
return closePromise;
|
|
976
473
|
};
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
projectId: options.projectId,
|
|
980
|
-
origin: configuredOrigin,
|
|
981
|
-
get effectiveOrigin() { return pairedOrigin ?? configuredOrigin; },
|
|
982
|
-
get lifecycle() { return lifecycle; },
|
|
474
|
+
return {
|
|
475
|
+
projectId: session.projectId,
|
|
983
476
|
get address() { return currentAddress; },
|
|
984
|
-
get sessionToken() { return sessionToken; },
|
|
985
|
-
get lastPageUrl() { return lastPageUrl; },
|
|
986
|
-
getStatus,
|
|
987
477
|
start,
|
|
988
478
|
close,
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
479
|
+
getStatus: session.getStatus,
|
|
480
|
+
getRequest: session.getRequest,
|
|
481
|
+
pairBrowser: (origin, pageUrl) => session.pairBrowser(origin, pageUrl),
|
|
482
|
+
disconnectBrowser: session.disconnectBrowser,
|
|
483
|
+
waitForPrompt: session.waitForPrompt,
|
|
484
|
+
updateRequestStatus: session.updateRequestStatus,
|
|
485
|
+
reportActivity: session.reportActivity,
|
|
486
|
+
dispatchCanvasCommand: session.dispatchCanvasCommand,
|
|
996
487
|
};
|
|
997
|
-
return bridge;
|
|
998
488
|
}
|