Files
oh-my-pi/packages/coding-agent/src/tools/browser/relay/server.ts
T
can1357 4a946e2cfc fix(coding-agent/tools): serialized tab grouping operations in the relay bridge
- Serialized group and ungroup operations to prevent duplicate tab group creation races.
- Queued and serially drained tab grouping requests in the relay bridge to prevent overlapping RPCs.
- Mirrored tab group titles to session storage and healed duplicate groups during background service worker recovery.
- Renamed run-cancellation utility to run-scope and updated corresponding module and test references.
2026-08-02 18:07:07 +02:00

142 lines
5.0 KiB
TypeScript

/**
* HTTP + WebSocket server for the browser relay.
*
* Impersonates Chrome's CDP discovery endpoint so the omp browser tool (and
* any puppeteer client) can connect with a plain `browserURL`:
* - `GET /json/version` → 200 with `webSocketDebuggerUrl` once the extension
* is connected, 503 before that (clients like `waitForCdp` keep polling).
* - `GET /json` / `/json/list` → attachable page targets (debugging aid).
* - `WS /cdp` → downstream CDP clients (puppeteer).
* - `WS /ext` → the Chrome extension (token-gated when configured).
*
* Binds loopback only: anything that can reach this port can drive the
* user's logged-in browser.
*/
import { RelayBridge } from "./bridge";
/** Options for {@link startRelayServer}. */
export interface RelayServerOptions {
port: number;
/** Shared secret the extension must present as `?token=`; unset disables the check. */
token?: string;
/** Group tabs the agent actively drives under one per-window Chrome tab group (default on); `false` disables. */
group?: boolean | { title: string; color: string };
log?: (message: string, data?: Record<string, unknown>) => void;
}
/** A running relay server. */
export interface RelayServer {
bridge: RelayBridge;
port: number;
stop(): void;
}
interface SocketData {
role: "cdp" | "ext";
connId?: number;
}
type RelayWebSocket = Bun.ServerWebSocket<SocketData>;
const WS_KEEPALIVE_MS = 30_000;
/** Screenshots travel base64-encoded through both websocket legs. */
const MAX_PAYLOAD_BYTES = 256 * 1024 * 1024;
/** Default appearance of the omp tab group. */
const DEFAULT_GROUP = { title: "omp", color: "cyan" } as const;
/** Start the relay server on 127.0.0.1. Throws if the port is taken. */
export function startRelayServer(opts: RelayServerOptions): RelayServer {
const log = opts.log ?? (() => {});
const group =
opts.group === false ? null : opts.group === true || opts.group === undefined ? DEFAULT_GROUP : opts.group;
const bridge = new RelayBridge({ log, group });
const sockets = new Set<RelayWebSocket>();
const server = Bun.serve({
hostname: "127.0.0.1",
port: opts.port,
fetch(req, srv): Response | undefined {
const url = new URL(req.url);
const path = url.pathname.replace(/\/+$/, "") || "/";
if (path === "/cdp") {
// Browsers set Origin on websocket upgrades; native CDP clients
// don't. Reject any Origin so a web page can't drive the relay.
if (req.headers.get("origin")) return new Response("Forbidden", { status: 403 });
const data: SocketData = { role: "cdp" };
if (srv.upgrade(req, { data })) return undefined;
return new Response("websocket upgrade required", { status: 426 });
}
if (path === "/ext") {
const origin = req.headers.get("origin");
if (origin && !origin.startsWith("chrome-extension://")) {
return new Response("Forbidden", { status: 403 });
}
if (opts.token && url.searchParams.get("token") !== opts.token) {
return new Response("Unauthorized", { status: 401 });
}
const data: SocketData = { role: "ext" };
if (srv.upgrade(req, { data })) return undefined;
return new Response("websocket upgrade required", { status: 426 });
}
if (req.method !== "GET") return new Response("Method not allowed", { status: 405 });
if (path === "/json/version") {
if (!bridge.ready) {
return Response.json({ error: "relay extension is not connected" }, { status: 503 });
}
return Response.json(bridge.versionInfo(`ws://127.0.0.1:${opts.port}/cdp`));
}
if (path === "/json" || path === "/json/list") {
return Response.json(bridge.listTargets());
}
return new Response("Not found", { status: 404 });
},
websocket: {
maxPayloadLength: MAX_PAYLOAD_BYTES,
// Disabled: Bun caps idleTimeout at 255s, and the keepalive pings
// below already detect dead peers via the websocket close path.
idleTimeout: 0,
open(ws: RelayWebSocket): void {
sockets.add(ws);
if (ws.data.role === "ext") {
bridge.extConnected(ws);
} else {
ws.data.connId = bridge.cdpConnected(ws);
}
},
message(ws: RelayWebSocket, message: string | Buffer): void {
const text = typeof message === "string" ? message : new TextDecoder().decode(message);
if (ws.data.role === "ext") {
bridge.extMessage(ws, text);
} else if (ws.data.connId !== undefined) {
bridge.cdpMessage(ws.data.connId, text);
}
},
close(ws: RelayWebSocket): void {
sockets.delete(ws);
if (ws.data.role === "ext") {
bridge.extClosed(ws);
} else if (ws.data.connId !== undefined) {
bridge.cdpClosed(ws.data.connId);
}
},
},
});
// Puppeteer connections go silent while the agent is idle; protocol-level
// pings count as activity and keep them under the idle timeout.
const keepalive = setInterval(() => {
for (const ws of sockets) ws.ping();
}, WS_KEEPALIVE_MS);
keepalive.unref();
log("relay listening", { port: opts.port });
return {
bridge,
port: opts.port,
stop() {
clearInterval(keepalive);
server.stop(true);
},
};
}