@agenticmail/enterprise 0.5.77 → 0.5.79

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.
Files changed (102) hide show
  1. package/dist/chunk-7RNT4O5T.js +15198 -0
  2. package/dist/chunk-AGFOJCSB.js +2191 -0
  3. package/dist/chunk-CYABMD5B.js +2191 -0
  4. package/dist/chunk-F4GSFCM3.js +898 -0
  5. package/dist/chunk-GINZ56GG.js +15035 -0
  6. package/dist/chunk-NRKB2KGD.js +898 -0
  7. package/dist/chunk-PZA7YOJE.js +898 -0
  8. package/dist/chunk-Q3V7VZFQ.js +2191 -0
  9. package/dist/chunk-RRFB6G6M.js +15198 -0
  10. package/dist/chunk-VX3VFMVB.js +409 -0
  11. package/dist/cli.js +1 -1
  12. package/dist/dashboard/pages/agent-detail.js +491 -2
  13. package/dist/index.js +4 -3
  14. package/dist/pw-ai-KPETTB25.js +2212 -0
  15. package/dist/routes-2T2ZNH3D.js +6642 -0
  16. package/dist/routes-PDHMCIXU.js +6676 -0
  17. package/dist/runtime-5ZJYB5PY.js +47 -0
  18. package/dist/runtime-7HW4GX5L.js +48 -0
  19. package/dist/runtime-XXDCZZIK.js +48 -0
  20. package/dist/server-FMP4BFGW.js +12 -0
  21. package/dist/server-JRHDUNII.js +12 -0
  22. package/dist/server-QPIMKFK4.js +12 -0
  23. package/dist/setup-NPFIX7LF.js +20 -0
  24. package/dist/setup-O5FPRLK4.js +20 -0
  25. package/dist/setup-S4Z4PPIJ.js +20 -0
  26. package/package.json +15 -2
  27. package/src/agent-tools/common.ts +25 -0
  28. package/src/agent-tools/index.ts +3 -0
  29. package/src/agent-tools/schema/typebox.ts +25 -0
  30. package/src/agent-tools/tools/browser-tool.schema.ts +112 -0
  31. package/src/agent-tools/tools/browser-tool.ts +388 -0
  32. package/src/agent-tools/tools/gateway.ts +126 -0
  33. package/src/agent-tools/tools/nodes-utils.ts +80 -0
  34. package/src/browser/bridge-auth-registry.ts +34 -0
  35. package/src/browser/bridge-server.ts +93 -0
  36. package/src/browser/cdp.helpers.ts +180 -0
  37. package/src/browser/cdp.ts +466 -0
  38. package/src/browser/chrome.executables.ts +625 -0
  39. package/src/browser/chrome.profile-decoration.ts +198 -0
  40. package/src/browser/chrome.ts +349 -0
  41. package/src/browser/client-actions-core.ts +259 -0
  42. package/src/browser/client-actions-observe.ts +184 -0
  43. package/src/browser/client-actions-state.ts +284 -0
  44. package/src/browser/client-actions-types.ts +16 -0
  45. package/src/browser/client-actions-url.ts +11 -0
  46. package/src/browser/client-actions.ts +4 -0
  47. package/src/browser/client-fetch.ts +253 -0
  48. package/src/browser/client.ts +337 -0
  49. package/src/browser/config.ts +296 -0
  50. package/src/browser/constants.ts +8 -0
  51. package/src/browser/control-auth.ts +94 -0
  52. package/src/browser/control-service.ts +81 -0
  53. package/src/browser/csrf.ts +87 -0
  54. package/src/browser/enterprise-compat.ts +518 -0
  55. package/src/browser/extension-relay.ts +834 -0
  56. package/src/browser/http-auth.ts +63 -0
  57. package/src/browser/navigation-guard.ts +50 -0
  58. package/src/browser/paths.ts +49 -0
  59. package/src/browser/profiles-service.ts +187 -0
  60. package/src/browser/profiles.ts +113 -0
  61. package/src/browser/proxy-files.ts +41 -0
  62. package/src/browser/pw-ai-module.ts +52 -0
  63. package/src/browser/pw-ai-state.ts +9 -0
  64. package/src/browser/pw-ai.ts +65 -0
  65. package/src/browser/pw-role-snapshot.ts +434 -0
  66. package/src/browser/pw-session.ts +810 -0
  67. package/src/browser/pw-tools-core.activity.ts +68 -0
  68. package/src/browser/pw-tools-core.downloads.ts +281 -0
  69. package/src/browser/pw-tools-core.interactions.ts +646 -0
  70. package/src/browser/pw-tools-core.responses.ts +124 -0
  71. package/src/browser/pw-tools-core.shared.ts +70 -0
  72. package/src/browser/pw-tools-core.snapshot.ts +213 -0
  73. package/src/browser/pw-tools-core.state.ts +209 -0
  74. package/src/browser/pw-tools-core.storage.ts +128 -0
  75. package/src/browser/pw-tools-core.trace.ts +37 -0
  76. package/src/browser/pw-tools-core.ts +8 -0
  77. package/src/browser/resolved-config-refresh.ts +59 -0
  78. package/src/browser/routes/agent.act.shared.ts +52 -0
  79. package/src/browser/routes/agent.act.ts +575 -0
  80. package/src/browser/routes/agent.debug.ts +149 -0
  81. package/src/browser/routes/agent.shared.ts +143 -0
  82. package/src/browser/routes/agent.snapshot.ts +333 -0
  83. package/src/browser/routes/agent.storage.ts +451 -0
  84. package/src/browser/routes/agent.ts +13 -0
  85. package/src/browser/routes/basic.ts +202 -0
  86. package/src/browser/routes/dispatcher.ts +126 -0
  87. package/src/browser/routes/index.ts +11 -0
  88. package/src/browser/routes/path-output.ts +1 -0
  89. package/src/browser/routes/tabs.ts +217 -0
  90. package/src/browser/routes/types.ts +26 -0
  91. package/src/browser/routes/utils.ts +73 -0
  92. package/src/browser/screenshot.ts +54 -0
  93. package/src/browser/server-context.ts +688 -0
  94. package/src/browser/server-context.types.ts +65 -0
  95. package/src/browser/server-lifecycle.ts +48 -0
  96. package/src/browser/server-middleware.ts +37 -0
  97. package/src/browser/server.ts +110 -0
  98. package/src/browser/target-id.ts +30 -0
  99. package/src/browser/trash.ts +21 -0
  100. package/src/dashboard/pages/agent-detail.js +491 -2
  101. package/src/engine/agent-routes.ts +246 -0
  102. package/src/security/external-content.ts +299 -0
@@ -0,0 +1,834 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import { createServer } from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import type { Duplex } from "node:stream";
5
+ import WebSocket, { WebSocketServer } from "ws";
6
+ import { isLoopbackAddress, isLoopbackHost, loadConfig, rawDataToString } from "./enterprise-compat.js";
7
+
8
+ type CdpCommand = {
9
+ id: number;
10
+ method: string;
11
+ params?: unknown;
12
+ sessionId?: string;
13
+ };
14
+
15
+ type CdpResponse = {
16
+ id: number;
17
+ result?: unknown;
18
+ error?: { message: string };
19
+ sessionId?: string;
20
+ };
21
+
22
+ type CdpEvent = {
23
+ method: string;
24
+ params?: unknown;
25
+ sessionId?: string;
26
+ };
27
+
28
+ type ExtensionForwardCommandMessage = {
29
+ id: number;
30
+ method: "forwardCDPCommand";
31
+ params: { method: string; params?: unknown; sessionId?: string };
32
+ };
33
+
34
+ type ExtensionResponseMessage = {
35
+ id: number;
36
+ result?: unknown;
37
+ error?: string;
38
+ };
39
+
40
+ type ExtensionForwardEventMessage = {
41
+ method: "forwardCDPEvent";
42
+ params: { method: string; params?: unknown; sessionId?: string };
43
+ };
44
+
45
+ type ExtensionPingMessage = { method: "ping" };
46
+ type ExtensionPongMessage = { method: "pong" };
47
+
48
+ type ExtensionMessage =
49
+ | ExtensionResponseMessage
50
+ | ExtensionForwardEventMessage
51
+ | ExtensionPongMessage;
52
+
53
+ type TargetInfo = {
54
+ targetId: string;
55
+ type?: string;
56
+ title?: string;
57
+ url?: string;
58
+ attached?: boolean;
59
+ };
60
+
61
+ type AttachedToTargetEvent = {
62
+ sessionId: string;
63
+ targetInfo: TargetInfo;
64
+ waitingForDebugger?: boolean;
65
+ };
66
+
67
+ type DetachedFromTargetEvent = {
68
+ sessionId: string;
69
+ targetId?: string;
70
+ };
71
+
72
+ type ConnectedTarget = {
73
+ sessionId: string;
74
+ targetId: string;
75
+ targetInfo: TargetInfo;
76
+ };
77
+
78
+ const RELAY_AUTH_HEADER = "x-openclaw-relay-token";
79
+
80
+ function headerValue(value: string | string[] | undefined): string | undefined {
81
+ if (!value) {
82
+ return undefined;
83
+ }
84
+ if (Array.isArray(value)) {
85
+ return value[0];
86
+ }
87
+ return value;
88
+ }
89
+
90
+ function getHeader(req: IncomingMessage, name: string): string | undefined {
91
+ return headerValue(req.headers[name.toLowerCase()]);
92
+ }
93
+
94
+ function getRelayAuthTokenFromRequest(req: IncomingMessage, url?: URL): string | undefined {
95
+ const headerToken = getHeader(req, RELAY_AUTH_HEADER)?.trim();
96
+ if (headerToken) {
97
+ return headerToken;
98
+ }
99
+ const queryToken = url?.searchParams.get("token")?.trim();
100
+ if (queryToken) {
101
+ return queryToken;
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ export type ChromeExtensionRelayServer = {
107
+ host: string;
108
+ port: number;
109
+ baseUrl: string;
110
+ cdpWsUrl: string;
111
+ extensionConnected: () => boolean;
112
+ stop: () => Promise<void>;
113
+ };
114
+
115
+ function parseBaseUrl(raw: string): {
116
+ host: string;
117
+ port: number;
118
+ baseUrl: string;
119
+ } {
120
+ const parsed = new URL(raw.trim().replace(/\/$/, ""));
121
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
122
+ throw new Error(`extension relay cdpUrl must be http(s), got ${parsed.protocol}`);
123
+ }
124
+ const host = parsed.hostname;
125
+ const port =
126
+ parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
127
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) {
128
+ throw new Error(`extension relay cdpUrl has invalid port: ${parsed.port || "(empty)"}`);
129
+ }
130
+ return { host, port, baseUrl: parsed.toString().replace(/\/$/, "") };
131
+ }
132
+
133
+ function text(res: Duplex, status: number, bodyText: string) {
134
+ const body = Buffer.from(bodyText);
135
+ res.write(
136
+ `HTTP/1.1 ${status} ${status === 200 ? "OK" : "ERR"}\r\n` +
137
+ "Content-Type: text/plain; charset=utf-8\r\n" +
138
+ `Content-Length: ${body.length}\r\n` +
139
+ "Connection: close\r\n" +
140
+ "\r\n",
141
+ );
142
+ res.write(body);
143
+ res.end();
144
+ }
145
+
146
+ function rejectUpgrade(socket: Duplex, status: number, bodyText: string) {
147
+ text(socket, status, bodyText);
148
+ try {
149
+ socket.destroy();
150
+ } catch {
151
+ // ignore
152
+ }
153
+ }
154
+
155
+ const serversByPort = new Map<number, ChromeExtensionRelayServer>();
156
+
157
+ function resolveGatewayAuthToken(): string | null {
158
+ const envToken =
159
+ process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || process.env.CLAWDBOT_GATEWAY_TOKEN?.trim();
160
+ if (envToken) {
161
+ return envToken;
162
+ }
163
+ try {
164
+ const cfg = loadConfig();
165
+ const configToken = cfg.gateway?.auth?.token?.trim();
166
+ if (configToken) {
167
+ return configToken;
168
+ }
169
+ } catch {
170
+ // ignore config read failures; caller can fallback to per-process random token
171
+ }
172
+ return null;
173
+ }
174
+
175
+ function resolveRelayAuthToken(): string {
176
+ const gatewayToken = resolveGatewayAuthToken();
177
+ if (gatewayToken) {
178
+ return gatewayToken;
179
+ }
180
+ throw new Error(
181
+ "extension relay requires gateway auth token (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
182
+ );
183
+ }
184
+
185
+ function isAddrInUseError(err: unknown): boolean {
186
+ return (
187
+ typeof err === "object" &&
188
+ err !== null &&
189
+ "code" in err &&
190
+ (err as { code?: unknown }).code === "EADDRINUSE"
191
+ );
192
+ }
193
+
194
+ async function looksLikeOpenClawRelay(baseUrl: string): Promise<boolean> {
195
+ const ctrl = new AbortController();
196
+ const timer = setTimeout(() => ctrl.abort(), 500);
197
+ try {
198
+ const statusUrl = new URL("/extension/status", `${baseUrl}/`).toString();
199
+ const res = await fetch(statusUrl, { signal: ctrl.signal });
200
+ if (!res.ok) {
201
+ return false;
202
+ }
203
+ const body = (await res.json()) as { connected?: unknown };
204
+ return typeof body.connected === "boolean";
205
+ } catch {
206
+ return false;
207
+ } finally {
208
+ clearTimeout(timer);
209
+ }
210
+ }
211
+
212
+ function relayAuthTokenForUrl(url: string): string | null {
213
+ try {
214
+ const parsed = new URL(url);
215
+ if (!isLoopbackHost(parsed.hostname)) {
216
+ return null;
217
+ }
218
+ return resolveGatewayAuthToken();
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
223
+
224
+ export function getChromeExtensionRelayAuthHeaders(url: string): Record<string, string> {
225
+ const token = relayAuthTokenForUrl(url);
226
+ if (!token) {
227
+ return {};
228
+ }
229
+ return { [RELAY_AUTH_HEADER]: token };
230
+ }
231
+
232
+ export async function ensureChromeExtensionRelayServer(opts: {
233
+ cdpUrl: string;
234
+ }): Promise<ChromeExtensionRelayServer> {
235
+ const info = parseBaseUrl(opts.cdpUrl);
236
+ if (!isLoopbackHost(info.host)) {
237
+ throw new Error(`extension relay requires loopback cdpUrl host (got ${info.host})`);
238
+ }
239
+
240
+ const existing = serversByPort.get(info.port);
241
+ if (existing) {
242
+ return existing;
243
+ }
244
+
245
+ const relayAuthToken = resolveRelayAuthToken();
246
+
247
+ let extensionWs: WebSocket | null = null;
248
+ const cdpClients = new Set<WebSocket>();
249
+ const connectedTargets = new Map<string, ConnectedTarget>();
250
+
251
+ const pendingExtension = new Map<
252
+ number,
253
+ {
254
+ resolve: (v: unknown) => void;
255
+ reject: (e: Error) => void;
256
+ timer: NodeJS.Timeout;
257
+ }
258
+ >();
259
+ let nextExtensionId = 1;
260
+
261
+ const sendToExtension = async (payload: ExtensionForwardCommandMessage): Promise<unknown> => {
262
+ const ws = extensionWs;
263
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
264
+ throw new Error("Chrome extension not connected");
265
+ }
266
+ ws.send(JSON.stringify(payload));
267
+ return await new Promise<unknown>((resolve, reject) => {
268
+ const timer = setTimeout(() => {
269
+ pendingExtension.delete(payload.id);
270
+ reject(new Error(`extension request timeout: ${payload.params.method}`));
271
+ }, 30_000);
272
+ pendingExtension.set(payload.id, { resolve, reject, timer });
273
+ });
274
+ };
275
+
276
+ const broadcastToCdpClients = (evt: CdpEvent) => {
277
+ const msg = JSON.stringify(evt);
278
+ for (const ws of cdpClients) {
279
+ if (ws.readyState !== WebSocket.OPEN) {
280
+ continue;
281
+ }
282
+ ws.send(msg);
283
+ }
284
+ };
285
+
286
+ const sendResponseToCdp = (ws: WebSocket, res: CdpResponse) => {
287
+ if (ws.readyState !== WebSocket.OPEN) {
288
+ return;
289
+ }
290
+ ws.send(JSON.stringify(res));
291
+ };
292
+
293
+ const ensureTargetEventsForClient = (ws: WebSocket, mode: "autoAttach" | "discover") => {
294
+ for (const target of connectedTargets.values()) {
295
+ if (mode === "autoAttach") {
296
+ ws.send(
297
+ JSON.stringify({
298
+ method: "Target.attachedToTarget",
299
+ params: {
300
+ sessionId: target.sessionId,
301
+ targetInfo: { ...target.targetInfo, attached: true },
302
+ waitingForDebugger: false,
303
+ },
304
+ } satisfies CdpEvent),
305
+ );
306
+ } else {
307
+ ws.send(
308
+ JSON.stringify({
309
+ method: "Target.targetCreated",
310
+ params: { targetInfo: { ...target.targetInfo, attached: true } },
311
+ } satisfies CdpEvent),
312
+ );
313
+ }
314
+ }
315
+ };
316
+
317
+ const routeCdpCommand = async (cmd: CdpCommand): Promise<unknown> => {
318
+ switch (cmd.method) {
319
+ case "Browser.getVersion":
320
+ return {
321
+ protocolVersion: "1.3",
322
+ product: "Chrome/OpenClaw-Extension-Relay",
323
+ revision: "0",
324
+ userAgent: "OpenClaw-Extension-Relay",
325
+ jsVersion: "V8",
326
+ };
327
+ case "Browser.setDownloadBehavior":
328
+ return {};
329
+ case "Target.setAutoAttach":
330
+ case "Target.setDiscoverTargets":
331
+ return {};
332
+ case "Target.getTargets":
333
+ return {
334
+ targetInfos: Array.from(connectedTargets.values()).map((t) => ({
335
+ ...t.targetInfo,
336
+ attached: true,
337
+ })),
338
+ };
339
+ case "Target.getTargetInfo": {
340
+ const params = (cmd.params ?? {}) as { targetId?: string };
341
+ const targetId = typeof params.targetId === "string" ? params.targetId : undefined;
342
+ if (targetId) {
343
+ for (const t of connectedTargets.values()) {
344
+ if (t.targetId === targetId) {
345
+ return { targetInfo: t.targetInfo };
346
+ }
347
+ }
348
+ }
349
+ if (cmd.sessionId && connectedTargets.has(cmd.sessionId)) {
350
+ const t = connectedTargets.get(cmd.sessionId);
351
+ if (t) {
352
+ return { targetInfo: t.targetInfo };
353
+ }
354
+ }
355
+ const first = Array.from(connectedTargets.values())[0];
356
+ return { targetInfo: first?.targetInfo };
357
+ }
358
+ case "Target.attachToTarget": {
359
+ const params = (cmd.params ?? {}) as { targetId?: string };
360
+ const targetId = typeof params.targetId === "string" ? params.targetId : undefined;
361
+ if (!targetId) {
362
+ throw new Error("targetId required");
363
+ }
364
+ for (const t of connectedTargets.values()) {
365
+ if (t.targetId === targetId) {
366
+ return { sessionId: t.sessionId };
367
+ }
368
+ }
369
+ throw new Error("target not found");
370
+ }
371
+ default: {
372
+ const id = nextExtensionId++;
373
+ return await sendToExtension({
374
+ id,
375
+ method: "forwardCDPCommand",
376
+ params: {
377
+ method: cmd.method,
378
+ sessionId: cmd.sessionId,
379
+ params: cmd.params,
380
+ },
381
+ });
382
+ }
383
+ }
384
+ };
385
+
386
+ const server = createServer((req, res) => {
387
+ const url = new URL(req.url ?? "/", info.baseUrl);
388
+ const path = url.pathname;
389
+
390
+ if (path.startsWith("/json")) {
391
+ const token = getHeader(req, RELAY_AUTH_HEADER);
392
+ if (!token || token !== relayAuthToken) {
393
+ res.writeHead(401);
394
+ res.end("Unauthorized");
395
+ return;
396
+ }
397
+ }
398
+
399
+ if (req.method === "HEAD" && path === "/") {
400
+ res.writeHead(200);
401
+ res.end();
402
+ return;
403
+ }
404
+
405
+ if (path === "/") {
406
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
407
+ res.end("OK");
408
+ return;
409
+ }
410
+
411
+ if (path === "/extension/status") {
412
+ res.writeHead(200, { "Content-Type": "application/json" });
413
+ res.end(JSON.stringify({ connected: Boolean(extensionWs) }));
414
+ return;
415
+ }
416
+
417
+ const hostHeader = req.headers.host?.trim() || `${info.host}:${info.port}`;
418
+ const wsHost = `ws://${hostHeader}`;
419
+ const cdpWsUrl = `${wsHost}/cdp`;
420
+
421
+ if (
422
+ (path === "/json/version" || path === "/json/version/") &&
423
+ (req.method === "GET" || req.method === "PUT")
424
+ ) {
425
+ const payload: Record<string, unknown> = {
426
+ Browser: "OpenClaw/extension-relay",
427
+ "Protocol-Version": "1.3",
428
+ };
429
+ // Only advertise the WS URL if a real extension is connected.
430
+ if (extensionWs) {
431
+ payload.webSocketDebuggerUrl = cdpWsUrl;
432
+ }
433
+ res.writeHead(200, { "Content-Type": "application/json" });
434
+ res.end(JSON.stringify(payload));
435
+ return;
436
+ }
437
+
438
+ const listPaths = new Set(["/json", "/json/", "/json/list", "/json/list/"]);
439
+ if (listPaths.has(path) && (req.method === "GET" || req.method === "PUT")) {
440
+ const list = Array.from(connectedTargets.values()).map((t) => ({
441
+ id: t.targetId,
442
+ type: t.targetInfo.type ?? "page",
443
+ title: t.targetInfo.title ?? "",
444
+ description: t.targetInfo.title ?? "",
445
+ url: t.targetInfo.url ?? "",
446
+ webSocketDebuggerUrl: cdpWsUrl,
447
+ devtoolsFrontendUrl: `/devtools/inspector.html?ws=${cdpWsUrl.replace("ws://", "")}`,
448
+ }));
449
+ res.writeHead(200, { "Content-Type": "application/json" });
450
+ res.end(JSON.stringify(list));
451
+ return;
452
+ }
453
+
454
+ const activateMatch = path.match(/^\/json\/activate\/(.+)$/);
455
+ if (activateMatch && (req.method === "GET" || req.method === "PUT")) {
456
+ const targetId = decodeURIComponent(activateMatch[1] ?? "").trim();
457
+ if (!targetId) {
458
+ res.writeHead(400);
459
+ res.end("targetId required");
460
+ return;
461
+ }
462
+ void (async () => {
463
+ try {
464
+ await sendToExtension({
465
+ id: nextExtensionId++,
466
+ method: "forwardCDPCommand",
467
+ params: { method: "Target.activateTarget", params: { targetId } },
468
+ });
469
+ } catch {
470
+ // ignore
471
+ }
472
+ })();
473
+ res.writeHead(200);
474
+ res.end("OK");
475
+ return;
476
+ }
477
+
478
+ const closeMatch = path.match(/^\/json\/close\/(.+)$/);
479
+ if (closeMatch && (req.method === "GET" || req.method === "PUT")) {
480
+ const targetId = decodeURIComponent(closeMatch[1] ?? "").trim();
481
+ if (!targetId) {
482
+ res.writeHead(400);
483
+ res.end("targetId required");
484
+ return;
485
+ }
486
+ void (async () => {
487
+ try {
488
+ await sendToExtension({
489
+ id: nextExtensionId++,
490
+ method: "forwardCDPCommand",
491
+ params: { method: "Target.closeTarget", params: { targetId } },
492
+ });
493
+ } catch {
494
+ // ignore
495
+ }
496
+ })();
497
+ res.writeHead(200);
498
+ res.end("OK");
499
+ return;
500
+ }
501
+
502
+ res.writeHead(404);
503
+ res.end("not found");
504
+ });
505
+
506
+ const wssExtension = new WebSocketServer({ noServer: true });
507
+ const wssCdp = new WebSocketServer({ noServer: true });
508
+
509
+ server.on("upgrade", (req, socket, head) => {
510
+ const url = new URL(req.url ?? "/", info.baseUrl);
511
+ const pathname = url.pathname;
512
+ const remote = req.socket.remoteAddress;
513
+
514
+ if (!isLoopbackAddress(remote)) {
515
+ rejectUpgrade(socket, 403, "Forbidden");
516
+ return;
517
+ }
518
+
519
+ const origin = headerValue(req.headers.origin);
520
+ if (origin && !origin.startsWith("chrome-extension://")) {
521
+ rejectUpgrade(socket, 403, "Forbidden: invalid origin");
522
+ return;
523
+ }
524
+
525
+ if (pathname === "/extension") {
526
+ const token = getRelayAuthTokenFromRequest(req, url);
527
+ if (!token || token !== relayAuthToken) {
528
+ rejectUpgrade(socket, 401, "Unauthorized");
529
+ return;
530
+ }
531
+ if (extensionWs) {
532
+ rejectUpgrade(socket, 409, "Extension already connected");
533
+ return;
534
+ }
535
+ wssExtension.handleUpgrade(req, socket, head, (ws) => {
536
+ wssExtension.emit("connection", ws, req);
537
+ });
538
+ return;
539
+ }
540
+
541
+ if (pathname === "/cdp") {
542
+ const token = getRelayAuthTokenFromRequest(req, url);
543
+ if (!token || token !== relayAuthToken) {
544
+ rejectUpgrade(socket, 401, "Unauthorized");
545
+ return;
546
+ }
547
+ if (!extensionWs) {
548
+ rejectUpgrade(socket, 503, "Extension not connected");
549
+ return;
550
+ }
551
+ wssCdp.handleUpgrade(req, socket, head, (ws) => {
552
+ wssCdp.emit("connection", ws, req);
553
+ });
554
+ return;
555
+ }
556
+
557
+ rejectUpgrade(socket, 404, "Not Found");
558
+ });
559
+
560
+ wssExtension.on("connection", (ws) => {
561
+ extensionWs = ws;
562
+
563
+ const ping = setInterval(() => {
564
+ if (ws.readyState !== WebSocket.OPEN) {
565
+ return;
566
+ }
567
+ ws.send(JSON.stringify({ method: "ping" } satisfies ExtensionPingMessage));
568
+ }, 5000);
569
+
570
+ ws.on("message", (data) => {
571
+ let parsed: ExtensionMessage | null = null;
572
+ try {
573
+ parsed = JSON.parse(rawDataToString(data)) as ExtensionMessage;
574
+ } catch {
575
+ return;
576
+ }
577
+
578
+ if (parsed && typeof parsed === "object" && "id" in parsed && typeof parsed.id === "number") {
579
+ const pending = pendingExtension.get(parsed.id);
580
+ if (!pending) {
581
+ return;
582
+ }
583
+ pendingExtension.delete(parsed.id);
584
+ clearTimeout(pending.timer);
585
+ if ("error" in parsed && typeof parsed.error === "string" && parsed.error.trim()) {
586
+ pending.reject(new Error(parsed.error));
587
+ } else {
588
+ pending.resolve(parsed.result);
589
+ }
590
+ return;
591
+ }
592
+
593
+ if (parsed && typeof parsed === "object" && "method" in parsed) {
594
+ if ((parsed as ExtensionPongMessage).method === "pong") {
595
+ return;
596
+ }
597
+ if ((parsed as ExtensionForwardEventMessage).method !== "forwardCDPEvent") {
598
+ return;
599
+ }
600
+ const evt = parsed as ExtensionForwardEventMessage;
601
+ const method = evt.params?.method;
602
+ const params = evt.params?.params;
603
+ const sessionId = evt.params?.sessionId;
604
+ if (!method || typeof method !== "string") {
605
+ return;
606
+ }
607
+
608
+ if (method === "Target.attachedToTarget") {
609
+ const attached = (params ?? {}) as AttachedToTargetEvent;
610
+ const targetType = attached?.targetInfo?.type ?? "page";
611
+ if (targetType !== "page") {
612
+ return;
613
+ }
614
+ if (attached?.sessionId && attached?.targetInfo?.targetId) {
615
+ const prev = connectedTargets.get(attached.sessionId);
616
+ const nextTargetId = attached.targetInfo.targetId;
617
+ const prevTargetId = prev?.targetId;
618
+ const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId);
619
+ connectedTargets.set(attached.sessionId, {
620
+ sessionId: attached.sessionId,
621
+ targetId: nextTargetId,
622
+ targetInfo: attached.targetInfo,
623
+ });
624
+ if (changedTarget && prevTargetId) {
625
+ broadcastToCdpClients({
626
+ method: "Target.detachedFromTarget",
627
+ params: { sessionId: attached.sessionId, targetId: prevTargetId },
628
+ sessionId: attached.sessionId,
629
+ });
630
+ }
631
+ if (!prev || changedTarget) {
632
+ broadcastToCdpClients({ method, params, sessionId });
633
+ }
634
+ return;
635
+ }
636
+ }
637
+
638
+ if (method === "Target.detachedFromTarget") {
639
+ const detached = (params ?? {}) as DetachedFromTargetEvent;
640
+ if (detached?.sessionId) {
641
+ connectedTargets.delete(detached.sessionId);
642
+ }
643
+ broadcastToCdpClients({ method, params, sessionId });
644
+ return;
645
+ }
646
+
647
+ // Keep cached tab metadata fresh for /json/list.
648
+ // After navigation, Chrome updates URL/title via Target.targetInfoChanged.
649
+ if (method === "Target.targetInfoChanged") {
650
+ const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } };
651
+ const targetInfo = changed?.targetInfo;
652
+ const targetId = targetInfo?.targetId;
653
+ if (targetId && (targetInfo?.type ?? "page") === "page") {
654
+ for (const [sid, target] of connectedTargets) {
655
+ if (target.targetId !== targetId) {
656
+ continue;
657
+ }
658
+ connectedTargets.set(sid, {
659
+ ...target,
660
+ targetInfo: { ...target.targetInfo, ...(targetInfo as object) },
661
+ });
662
+ }
663
+ }
664
+ }
665
+
666
+ broadcastToCdpClients({ method, params, sessionId });
667
+ }
668
+ });
669
+
670
+ ws.on("close", () => {
671
+ clearInterval(ping);
672
+ extensionWs = null;
673
+ for (const [, pending] of pendingExtension) {
674
+ clearTimeout(pending.timer);
675
+ pending.reject(new Error("extension disconnected"));
676
+ }
677
+ pendingExtension.clear();
678
+ connectedTargets.clear();
679
+
680
+ for (const client of cdpClients) {
681
+ try {
682
+ client.close(1011, "extension disconnected");
683
+ } catch {
684
+ // ignore
685
+ }
686
+ }
687
+ cdpClients.clear();
688
+ });
689
+ });
690
+
691
+ wssCdp.on("connection", (ws) => {
692
+ cdpClients.add(ws);
693
+
694
+ ws.on("message", async (data) => {
695
+ let cmd: CdpCommand | null = null;
696
+ try {
697
+ cmd = JSON.parse(rawDataToString(data)) as CdpCommand;
698
+ } catch {
699
+ return;
700
+ }
701
+ if (!cmd || typeof cmd !== "object") {
702
+ return;
703
+ }
704
+ if (typeof cmd.id !== "number" || typeof cmd.method !== "string") {
705
+ return;
706
+ }
707
+
708
+ if (!extensionWs) {
709
+ sendResponseToCdp(ws, {
710
+ id: cmd.id,
711
+ sessionId: cmd.sessionId,
712
+ error: { message: "Extension not connected" },
713
+ });
714
+ return;
715
+ }
716
+
717
+ try {
718
+ const result = await routeCdpCommand(cmd);
719
+
720
+ if (cmd.method === "Target.setAutoAttach" && !cmd.sessionId) {
721
+ ensureTargetEventsForClient(ws, "autoAttach");
722
+ }
723
+ if (cmd.method === "Target.setDiscoverTargets") {
724
+ const discover = (cmd.params ?? {}) as { discover?: boolean };
725
+ if (discover.discover === true) {
726
+ ensureTargetEventsForClient(ws, "discover");
727
+ }
728
+ }
729
+ if (cmd.method === "Target.attachToTarget") {
730
+ const params = (cmd.params ?? {}) as { targetId?: string };
731
+ const targetId = typeof params.targetId === "string" ? params.targetId : undefined;
732
+ if (targetId) {
733
+ const target = Array.from(connectedTargets.values()).find(
734
+ (t) => t.targetId === targetId,
735
+ );
736
+ if (target) {
737
+ ws.send(
738
+ JSON.stringify({
739
+ method: "Target.attachedToTarget",
740
+ params: {
741
+ sessionId: target.sessionId,
742
+ targetInfo: { ...target.targetInfo, attached: true },
743
+ waitingForDebugger: false,
744
+ },
745
+ } satisfies CdpEvent),
746
+ );
747
+ }
748
+ }
749
+ }
750
+
751
+ sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result });
752
+ } catch (err) {
753
+ sendResponseToCdp(ws, {
754
+ id: cmd.id,
755
+ sessionId: cmd.sessionId,
756
+ error: { message: err instanceof Error ? err.message : String(err) },
757
+ });
758
+ }
759
+ });
760
+
761
+ ws.on("close", () => {
762
+ cdpClients.delete(ws);
763
+ });
764
+ });
765
+
766
+ try {
767
+ await new Promise<void>((resolve, reject) => {
768
+ server.listen(info.port, info.host, () => resolve());
769
+ server.once("error", reject);
770
+ });
771
+ } catch (err) {
772
+ if (isAddrInUseError(err) && (await looksLikeOpenClawRelay(info.baseUrl))) {
773
+ const existingRelay: ChromeExtensionRelayServer = {
774
+ host: info.host,
775
+ port: info.port,
776
+ baseUrl: info.baseUrl,
777
+ cdpWsUrl: `ws://${info.host}:${info.port}/cdp`,
778
+ extensionConnected: () => false,
779
+ stop: async () => {
780
+ serversByPort.delete(info.port);
781
+ },
782
+ };
783
+ serversByPort.set(info.port, existingRelay);
784
+ return existingRelay;
785
+ }
786
+ throw err;
787
+ }
788
+
789
+ const addr = server.address() as AddressInfo | null;
790
+ const port = addr?.port ?? info.port;
791
+ const host = info.host;
792
+ const baseUrl = `${new URL(info.baseUrl).protocol}//${host}:${port}`;
793
+
794
+ const relay: ChromeExtensionRelayServer = {
795
+ host,
796
+ port,
797
+ baseUrl,
798
+ cdpWsUrl: `ws://${host}:${port}/cdp`,
799
+ extensionConnected: () => Boolean(extensionWs),
800
+ stop: async () => {
801
+ serversByPort.delete(port);
802
+ try {
803
+ extensionWs?.close(1001, "server stopping");
804
+ } catch {
805
+ // ignore
806
+ }
807
+ for (const ws of cdpClients) {
808
+ try {
809
+ ws.close(1001, "server stopping");
810
+ } catch {
811
+ // ignore
812
+ }
813
+ }
814
+ await new Promise<void>((resolve) => {
815
+ server.close(() => resolve());
816
+ });
817
+ wssExtension.close();
818
+ wssCdp.close();
819
+ },
820
+ };
821
+
822
+ serversByPort.set(port, relay);
823
+ return relay;
824
+ }
825
+
826
+ export async function stopChromeExtensionRelayServer(opts: { cdpUrl: string }): Promise<boolean> {
827
+ const info = parseBaseUrl(opts.cdpUrl);
828
+ const existing = serversByPort.get(info.port);
829
+ if (!existing) {
830
+ return false;
831
+ }
832
+ await existing.stop();
833
+ return true;
834
+ }