feat: introduced OMP Browser Relay extension with CDP RPC execution

- Implement the OMP Browser Relay extension with WebSocket communication and CDP RPC execution.
- Add browser relay server, daemon management, and bridge multiplexing in the coding agent.
- Introduce CLI commands and settings schema options for configuring and installing the relay.
- Add utility functions and test suites supporting environment parsing and relay lifecycle handling.
This commit is contained in:
can1357
2026-08-02 05:17:14 +02:00
parent 06343fef42
commit 92c79d80c7
44 changed files with 2833 additions and 46 deletions
+5
View File
@@ -747,12 +747,17 @@ jobs:
pattern: omp-binary-*
path: packages/coding-agent/binaries
merge-multiple: true
# Dependency-free build (bun APIs + zip only), so no `bun install`
# is needed on this runner.
- name: Build browser relay artifacts
run: bun run --cwd packages/browser-relay build
- name: Create GitHub Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
tag_name: ${{ needs.release_metadata.outputs.release-tag }}
files: |
packages/coding-agent/binaries/omp-*
packages/browser-relay/dist/omp-browser-relay-extension.zip
body_path: release-notes.md
generate_release_notes: true
+13
View File
@@ -53,6 +53,17 @@
"@types/bun": "catalog:",
},
},
"packages/browser-relay": {
"name": "@oh-my-pi/browser-relay",
"version": "0.1.0",
"bin": {
"omp-browser-relay": "./src/cli.ts",
},
"devDependencies": {
"@types/bun": "catalog:",
"puppeteer-core": "catalog:",
},
},
"packages/catalog": {
"name": "@oh-my-pi/pi-catalog",
"version": "17.2.4",
@@ -772,6 +783,8 @@
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@oh-my-pi/browser-relay": ["@oh-my-pi/browser-relay@workspace:packages/browser-relay"],
"@oh-my-pi/collab-web": ["@oh-my-pi/collab-web@workspace:packages/collab-web"],
"@oh-my-pi/hashline": ["@oh-my-pi/hashline@workspace:packages/hashline"],
+7
View File
@@ -0,0 +1,7 @@
# Changelog
## [Unreleased]
### Added
- Initial release: Chrome MV3 extension that lets the omp browser tool attach to and drive the user's existing tabs through `chrome.debugger`. The companion CDP relay lives in the omp CLI (`omp browser-relay`); this package builds the extension zip for GitHub releases and generates the embedded install assets consumed by `omp browser-relay install`. Controllable tabs are gathered into a per-window "omp" tab group while the relay is connected.
+26
View File
@@ -0,0 +1,26 @@
# @oh-my-pi/browser-relay
Chrome extension that lets the omp `browser` tool drive **your existing Chrome tabs** — logged-in sessions included — without relaunching Chrome with `--remote-debugging-port` (which Chrome 136+ refuses on the default profile anyway).
The companion relay server lives in the omp CLI (`omp browser-relay`, see `packages/coding-agent/src/tools/browser/relay/`). It impersonates Chrome's CDP discovery endpoint, synthesizes the browser target and `Target.*` hierarchy that `chrome.debugger` doesn't expose, and multiplexes any number of downstream puppeteer connections (omp opens one per tab worker) over the single debugger attachment Chrome allows per tab.
## Setup
1. `omp browser-relay install` — writes the bundled extension to `~/.omp/browser-relay/extension`, then load it via `chrome://extensions` → Developer mode → *Load unpacked*. (Or grab `omp-browser-relay-extension.zip` from GitHub releases.)
2. `omp config set browser.relay true` — routes the browser tool through the relay. Per-call `app.relay: true` works without the setting.
That's it: the relay server auto-starts under omp's profile-independent global daemon broker the first time the browser tool needs it. Every relay consumer holds a broker lease, so one project exiting cannot interrupt another; the server stops after the last consumer across all projects exits. The extension badge turns **on** when connected. Run `omp browser-relay` manually only for `--token`, `--no-group`, or a non-default port — a relay already serving the port is adopted, never fought over.
`app.target` picks a specific tab by URL/title substring; without it, omp adopts the visible tab without stealing focus. While connected, every tab omp can control is gathered into a per-window **"omp" tab group** (cyan) — dissolved on disconnect; pinned tabs, tabs in your own groups, and tabs you drag out are left alone. Disable with `omp browser-relay --no-group`.
## Development
- `bun run build` — bundles the extension into `dist/extension/`, zips it for GH releases, and regenerates the embedded CLI install assets under `packages/coding-agent/src/tools/browser/relay/extension-assets/` (**commit those**).
- `bun scripts/smoke.ts [relay-url] [target-substring]` — end-to-end smoke replicating omp's supervisor + tab-worker double-connection pattern against a live relay.
## Limitations
- `chrome://`, DevTools, Web Store, and other-extension pages are not attachable and are hidden from the agent.
- Chrome shows its "is debugging this browser" infobar while any tab is attached; dismissing it detaches that tab until it navigates again.
- A tab with DevTools open can't be attached (one debugger per tab — the constraint the relay multiplexes around for its own clients).
- Anything that can reach the relay port can drive your logged-in browser. The relay binds loopback only; use `omp browser-relay --token <secret>` (mirrored in the extension options) if untrusted local processes are a concern.
@@ -0,0 +1,258 @@
/**
* OMP Browser Relay — MV3 service worker.
*
* Dumb pipe by design: all CDP orchestration lives in the relay server. This
* worker (1) keeps a websocket to the relay, (2) executes its RPCs against
* `chrome.debugger`/`chrome.tabs`, and (3) streams tab + debugger events back.
*
* Service-worker lifetime: the open websocket plus a periodic ping keeps the
* worker alive while connected (Chrome 116+); a chrome.alarms tick revives it
* and re-dials after Chrome reaps it while disconnected.
*/
import type { ExtToRelayMessage, RelayToExtMessage, TabSnapshot } from "../../coding-agent/src/tools/browser/relay/protocol";
const DEFAULT_PORT = 9224;
const PING_INTERVAL_MS = 20_000;
const RECONNECT_MIN_MS = 1_000;
const RECONNECT_MAX_MS = 10_000;
let ws: WebSocket | null = null;
let reconnectDelay = RECONNECT_MIN_MS;
let pingTimer: NodeJS.Timeout | null = null;
interface RelaySettings {
port: number;
token: string;
}
async function loadSettings(): Promise<RelaySettings> {
const stored = await chrome.storage.local.get({ port: DEFAULT_PORT, token: "" });
const port = Number(stored.port);
return {
port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : DEFAULT_PORT,
token: typeof stored.token === "string" ? stored.token : "",
};
}
function snapshot(tab: ChromeTab): TabSnapshot | null {
if (tab.id === undefined) return null;
return {
tabId: tab.id,
url: tab.url ?? tab.pendingUrl ?? "",
title: tab.title ?? "",
active: tab.active,
windowId: tab.windowId,
pinned: tab.pinned,
groupId: tab.groupId,
};
}
/** Title of the omp tab group, remembered so a relay disconnect can dissolve it. */
let ompGroupTitle: string | null = null;
/** Move tabs into the per-window omp group, creating or reusing it by title. */
async function groupTabs(tabIds: number[], title: string, color: string): Promise<{ grouped: Record<string, number> }> {
ompGroupTitle = title;
const byWindow = new Map<number, number[]>();
for (const tabId of tabIds) {
try {
const tab = await chrome.tabs.get(tabId);
// Grouping silently unpins; never touch pinned tabs.
if (tab.pinned || tab.id === undefined) continue;
const bucket = byWindow.get(tab.windowId) ?? [];
bucket.push(tab.id);
byWindow.set(tab.windowId, bucket);
} catch {
// Tab already closed.
}
}
const grouped: Record<string, number> = {};
for (const [windowId, ids] of byWindow) {
const existing = await chrome.tabGroups.query({ title, windowId });
const groupId = await chrome.tabs.group(existing[0] ? { tabIds: ids, groupId: existing[0].id } : { tabIds: ids });
await chrome.tabGroups.update(groupId, { title, color });
for (const id of ids) grouped[String(id)] = groupId;
}
return { grouped };
}
/** Dissolve every omp-titled group (relay disconnected or asked us to release tabs). */
async function restoreGroups(): Promise<void> {
if (!ompGroupTitle) return;
const groups = await chrome.tabGroups.query({ title: ompGroupTitle }).catch(() => []);
for (const group of groups) {
const tabs = await chrome.tabs.query({ groupId: group.id }).catch(() => []);
const ids = tabs.map(tab => tab.id).filter(id => id !== undefined);
if (ids.length > 0) await chrome.tabs.ungroup(ids).catch(() => {});
}
}
function post(msg: ExtToRelayMessage): void {
if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
async function setBadge(connected: boolean): Promise<void> {
try {
await chrome.action.setBadgeText({ text: connected ? "on" : "off" });
await chrome.action.setBadgeBackgroundColor({ color: connected ? "#1a7f37" : "#8b8b8b" });
} catch {
// Badge is cosmetic; never let it break the relay loop.
}
}
async function buildHello(): Promise<ExtToRelayMessage> {
const [tabs, targets] = await Promise.all([chrome.tabs.query({}), chrome.debugger.getTargets()]);
const snapshots: TabSnapshot[] = [];
for (const tab of tabs) {
const snap = snapshot(tab);
if (snap) snapshots.push(snap);
}
const attachedTabIds: number[] = [];
for (const target of targets) {
if (target.attached && target.tabId !== undefined) attachedTabIds.push(target.tabId);
}
const versionMatch = /Chrome\/[\d.]+/.exec(navigator.userAgent);
return {
t: "hello",
userAgent: navigator.userAgent,
browserVersion: versionMatch?.[0] ?? "Chrome/unknown",
tabs: snapshots,
attachedTabIds,
};
}
async function runRpc(msg: Extract<RelayToExtMessage, { t: "rpc" }>): Promise<unknown> {
switch (msg.op) {
case "attach":
await chrome.debugger.attach({ tabId: msg.tabId }, "1.3");
return {};
case "detach":
await chrome.debugger.detach({ tabId: msg.tabId });
return {};
case "send":
return await chrome.debugger.sendCommand(
msg.sessionId ? { tabId: msg.tabId, sessionId: msg.sessionId } : { tabId: msg.tabId },
msg.method,
msg.params,
);
case "createTab": {
const tab = await chrome.tabs.create({ url: msg.url });
const snap = snapshot(tab);
if (!snap) throw new Error("created tab has no id");
return { tab: snap };
}
case "removeTab":
await chrome.tabs.remove(msg.tabId);
return {};
case "activateTab": {
const tab = await chrome.tabs.get(msg.tabId);
await chrome.windows.update(tab.windowId, { focused: true });
await chrome.tabs.update(msg.tabId, { active: true });
return {};
}
case "group":
return await groupTabs(msg.tabIds, msg.title, msg.color);
case "ungroup":
await chrome.tabs.ungroup(msg.tabIds).catch(() => {});
return {};
}
}
function handleRelayMessage(raw: string): void {
let msg: RelayToExtMessage;
try {
msg = JSON.parse(raw) as RelayToExtMessage;
} catch {
return;
}
if (msg.t === "pong") return;
void runRpc(msg)
.then(result => post({ t: "rpcResult", id: msg.id, ok: true, result }))
.catch((err: unknown) => {
post({ t: "rpcResult", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
});
}
function scheduleReconnect(): void {
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
setTimeout(() => void connect(), delay);
}
async function connect(): Promise<void> {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
const settings = await loadSettings();
const url = `ws://127.0.0.1:${settings.port}/ext${settings.token ? `?token=${encodeURIComponent(settings.token)}` : ""}`;
const socket = new WebSocket(url);
ws = socket;
socket.onopen = () => {
reconnectDelay = RECONNECT_MIN_MS;
void setBadge(true);
void buildHello().then(hello => post(hello));
clearInterval(pingTimer ?? undefined);
pingTimer = setInterval(() => post({ t: "ping" }), PING_INTERVAL_MS);
};
socket.onmessage = event => {
if (typeof event.data === "string") handleRelayMessage(event.data);
};
socket.onclose = () => {
if (ws !== socket) return;
ws = null;
if (pingTimer !== null) {
clearInterval(pingTimer);
pingTimer = null;
}
void setBadge(false);
void restoreGroups();
scheduleReconnect();
};
socket.onerror = () => {
socket.close();
};
}
// ---- event streaming ---------------------------------------------------------
chrome.debugger.onEvent.addListener((source, method, params) => {
if (source.tabId === undefined) return;
post({ t: "cdpEvent", tabId: source.tabId, sessionId: source.sessionId, method, params });
});
chrome.debugger.onDetach.addListener((source, reason) => {
if (source.tabId === undefined) return;
post({ t: "detached", tabId: source.tabId, reason });
});
chrome.tabs.onCreated.addListener(tab => {
const snap = snapshot(tab);
if (snap) post({ t: "tabCreated", tab: snap });
});
chrome.tabs.onUpdated.addListener((_tabId, _changeInfo, tab) => {
const snap = snapshot(tab);
if (snap) post({ t: "tabUpdated", tab: snap });
});
chrome.tabs.onRemoved.addListener(tabId => {
post({ t: "tabRemoved", tabId });
});
// ---- lifecycle ----------------------------------------------------------------
chrome.alarms.create("omp-relay-keepalive", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener(alarm => {
if (alarm.name === "omp-relay-keepalive") void connect();
});
chrome.storage.onChanged.addListener((_changes, areaName) => {
if (areaName !== "local") return;
// Settings changed: drop the current connection and re-dial with new ones.
ws?.close();
void connect();
});
chrome.action.onClicked.addListener(() => void chrome.runtime.openOptionsPage());
chrome.runtime.onInstalled.addListener(() => void connect());
chrome.runtime.onStartup.addListener(() => void connect());
void connect();
+100
View File
@@ -0,0 +1,100 @@
/**
* Minimal ambient declarations for the Chrome extension APIs the relay
* extension uses (promise-based MV3 forms only). Declared as a typed const —
* not namespaces — because `debugger` is a reserved namespace name. Kept
* local so the package stays dependency-free; extend as the worker grows.
*/
/** Chrome extension event surface (subset). */
interface ChromeEvent<T extends (...args: never[]) => void> {
addListener(callback: T): void;
removeListener(callback: T): void;
}
interface ChromeTab {
id?: number;
url?: string;
pendingUrl?: string;
title?: string;
active: boolean;
windowId: number;
pinned: boolean;
/** -1 when ungrouped. */
groupId: number;
}
interface ChromeTabChangeInfo {
url?: string;
title?: string;
status?: string;
}
/** Debuggee with the Chrome 125+ flat-session extension. */
interface ChromeDebuggerSession {
tabId?: number;
sessionId?: string;
}
interface ChromeDebuggerTargetInfo {
id: string;
type: string;
attached: boolean;
tabId?: number;
title?: string;
url?: string;
}
declare const chrome: {
tabs: {
query(queryInfo: { url?: string; groupId?: number }): Promise<ChromeTab[]>;
get(tabId: number): Promise<ChromeTab>;
create(createProperties: { url?: string; active?: boolean }): Promise<ChromeTab>;
remove(tabId: number): Promise<void>;
update(tabId: number, updateProperties: { active?: boolean }): Promise<ChromeTab>;
group(options: { tabIds: number[]; groupId?: number }): Promise<number>;
ungroup(tabIds: number[]): Promise<void>;
onCreated: ChromeEvent<(tab: ChromeTab) => void>;
onUpdated: ChromeEvent<(tabId: number, changeInfo: ChromeTabChangeInfo, tab: ChromeTab) => void>;
onRemoved: ChromeEvent<(tabId: number, removeInfo: { windowId: number }) => void>;
};
tabGroups: {
query(queryInfo: { title?: string; windowId?: number }): Promise<Array<{ id: number; windowId: number; title?: string }>>;
update(groupId: number, updateProperties: { title?: string; color?: string; collapsed?: boolean }): Promise<unknown>;
};
windows: {
update(windowId: number, updateInfo: { focused?: boolean }): Promise<unknown>;
};
debugger: {
attach(target: ChromeDebuggerSession, requiredVersion: string): Promise<void>;
detach(target: ChromeDebuggerSession): Promise<void>;
sendCommand(
target: ChromeDebuggerSession,
method: string,
commandParams?: Record<string, unknown>,
): Promise<Record<string, unknown> | undefined>;
getTargets(): Promise<ChromeDebuggerTargetInfo[]>;
onEvent: ChromeEvent<(source: ChromeDebuggerSession, method: string, params?: Record<string, unknown>) => void>;
onDetach: ChromeEvent<(source: ChromeDebuggerSession, reason: string) => void>;
};
storage: {
local: {
get(keys: Record<string, unknown>): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
};
onChanged: ChromeEvent<(changes: Record<string, unknown>, areaName: string) => void>;
};
alarms: {
create(name: string, alarmInfo: { periodInMinutes?: number; delayInMinutes?: number }): void;
onAlarm: ChromeEvent<(alarm: { name: string }) => void>;
};
action: {
setBadgeText(details: { text: string }): Promise<void>;
setBadgeBackgroundColor(details: { color: string }): Promise<void>;
onClicked: ChromeEvent<(tab: ChromeTab) => void>;
};
runtime: {
openOptionsPage(): Promise<void>;
onInstalled: ChromeEvent<() => void>;
onStartup: ChromeEvent<() => void>;
};
};
@@ -0,0 +1,14 @@
{
"manifest_version": 3,
"name": "OMP Browser Relay",
"version": "0.1.0",
"description": "Lets the omp coding agent drive your existing tabs through a local CDP relay.",
"permissions": ["debugger", "tabs", "tabGroups", "storage", "alarms"],
"background": {
"service_worker": "background.js"
},
"options_page": "options.html",
"action": {
"default_title": "OMP Browser Relay (click for settings)"
}
}
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>OMP Browser Relay settings</title>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 28rem;
margin: 2rem auto;
padding: 0 1rem;
color: #1f2328;
}
label {
display: block;
margin: 1rem 0 0.25rem;
font-weight: 600;
}
input {
width: 100%;
padding: 0.4rem 0.5rem;
font: inherit;
box-sizing: border-box;
}
button {
margin-top: 1.25rem;
padding: 0.45rem 1.1rem;
font: inherit;
}
#status {
margin-left: 0.75rem;
color: #1a7f37;
}
p.hint {
color: #57606a;
font-size: 0.85rem;
}
</style>
</head>
<body>
<h1>OMP Browser Relay</h1>
<p class="hint">
The extension connects to a local <code>omp-browser-relay</code> process. Values here must match the flags the
relay was started with.
</p>
<label for="port">Relay port</label>
<input id="port" type="number" min="1" max="65535" />
<label for="token">Token (optional)</label>
<input id="token" type="text" autocomplete="off" />
<button id="save">Save</button><span id="status"></span>
<script src="options.js"></script>
</body>
</html>
@@ -0,0 +1,23 @@
// Options page for the OMP Browser Relay extension (plain JS: shipped as-is).
const DEFAULT_PORT = 9224;
const portInput = document.getElementById("port");
const tokenInput = document.getElementById("token");
const status = document.getElementById("status");
chrome.storage.local.get({ port: DEFAULT_PORT, token: "" }).then(stored => {
portInput.value = String(stored.port);
tokenInput.value = String(stored.token);
});
document.getElementById("save").addEventListener("click", async () => {
const port = Number(portInput.value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
status.textContent = "invalid port";
return;
}
await chrome.storage.local.set({ port, token: tokenInput.value });
status.textContent = "saved";
setTimeout(() => {
status.textContent = "";
}, 1500);
});
+29
View File
@@ -0,0 +1,29 @@
{
"type": "module",
"name": "@oh-my-pi/browser-relay",
"version": "0.1.0",
"private": true,
"description": "Chrome extension that lets the omp browser tool drive your existing tabs (relay server lives in the omp CLI: `omp browser-relay`)",
"homepage": "https://omp.sh",
"author": "Can Boluk",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/can1357/oh-my-pi.git",
"directory": "packages/browser-relay"
},
"scripts": {
"build": "bun scripts/build-extension.ts",
"check": "biome check . && tsgo -p tsconfig.json --noEmit",
"lint": "biome lint .",
"fix": "biome check --write --unsafe .",
"fmt": "biome format --write ."
},
"devDependencies": {
"@types/bun": "catalog:",
"puppeteer-core": "catalog:"
},
"engines": {
"bun": ">=1.3.14"
}
}
@@ -0,0 +1,54 @@
/**
* Builds the browser relay extension and its distribution artifacts:
* - `dist/extension/` — unpacked extension (load via chrome://extensions)
* - `dist/omp-browser-relay-extension.zip` — packaged extension for GH releases
* - `../coding-agent/src/tools/browser/relay/extension-assets/*.txt` —
* generated text assets embedded into the omp CLI so `omp browser-relay
* install` works from the compiled binary (same committed-generated-output
* pattern as tool-views.generated.js). Re-run this script after touching
* anything under `extension/` and commit the regenerated assets.
*
* Dependency-free on purpose: CI runs this without `bun install`.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { $ } from "bun";
const root = path.resolve(import.meta.dir, "..");
const dist = path.join(root, "dist");
const distExtension = path.join(dist, "extension");
const assetsDir = path.resolve(root, "../coding-agent/src/tools/browser/relay/extension-assets");
await fs.rm(dist, { recursive: true, force: true });
await fs.mkdir(distExtension, { recursive: true });
const bundle = await Bun.build({
entrypoints: [path.join(root, "extension/background.ts")],
outdir: distExtension,
target: "browser",
sourcemap: "none",
});
if (!bundle.success) {
for (const log of bundle.logs) console.error(log);
process.exit(1);
}
for (const file of ["manifest.json", "options.html", "options.js"]) {
await Bun.write(path.join(distExtension, file), Bun.file(path.join(root, "extension", file)));
}
const zip = await $`zip -qr ../omp-browser-relay-extension.zip .`.cwd(distExtension).nothrow();
if (zip.exitCode !== 0) {
console.error("zip failed:", zip.stderr.toString());
process.exit(1);
}
await fs.rm(assetsDir, { recursive: true, force: true });
for (const file of ["background.js", "manifest.json", "options.html", "options.js"]) {
await Bun.write(path.join(assetsDir, `${file}.txt`), Bun.file(path.join(distExtension, file)));
}
console.log("built:");
console.log(` ${distExtension}`);
console.log(` ${path.join(dist, "omp-browser-relay-extension.zip")}`);
console.log(` ${assetsDir} (embedded CLI assets — commit these)`);
+99
View File
@@ -0,0 +1,99 @@
/**
* Manual end-to-end smoke for the relay: replicates the omp browser tool's
* connection pattern against a live relay + extension.
*
* The tool opens TWO puppeteer connections per driven tab — the supervisor
* (`puppeteer.connect({browserURL})`, picks a target) and a tab worker
* (`puppeteer.connect({browserWSEndpoint})`, adopts that target by id) — so
* this script does exactly that, then exercises evaluate/navigate/screenshot,
* an extra CDP session, and the createTarget/closeTarget path.
*
* Usage: bun scripts/smoke.ts [relay-url] [target-substring]
*/
import type { Target } from "puppeteer-core";
import puppeteer from "puppeteer-core";
const relayUrl = Bun.argv[2] ?? "http://127.0.0.1:9224";
const matcher = Bun.argv[3] ?? "Relay Smoke Page";
/** Puppeteer keeps the CDP target id on an internal field; same access the omp tab supervisor uses (`targetIdForPage`). */
function targetIdOf(target: Target): string {
// Internal puppeteer field, not on the public type.
const raw = target as unknown as { _targetId: string };
return raw._targetId;
}
function step(name: string): void {
console.log(`\n== ${name}`);
}
step("supervisor: connect via browserURL");
const supervisor = await puppeteer.connect({ browserURL: relayUrl, defaultViewport: null, protocolTimeout: 20_000 });
console.log("version:", await supervisor.version());
step("supervisor: discover targets");
const pages = (
await Promise.all(
supervisor
.targets()
.map(async target => (String(target.type()) === "page" ? await target.page().catch(() => null) : null)),
)
).filter(page => page !== null);
console.log(
"pages:",
pages.map(page => page.url()),
);
if (pages.length === 0) throw new Error("no page targets discovered");
let picked = null;
for (const page of pages) {
const title = await page.title().catch(() => "");
if (page.url().includes(matcher) || title.includes(matcher)) picked = page;
}
if (!picked) throw new Error(`no page matching ${JSON.stringify(matcher)}`);
const targetId = targetIdOf(picked.target());
const wsEndpoint = supervisor.wsEndpoint();
console.log("picked target:", targetId, "ws:", wsEndpoint);
step("worker: second connection via browserWSEndpoint");
const worker = await puppeteer.connect({
browserWSEndpoint: wsEndpoint,
defaultViewport: null,
protocolTimeout: 20_000,
});
const workerTarget = await worker.waitForTarget(target => targetIdOf(target) === targetId, { timeout: 10_000 });
const page = await workerTarget.page();
if (!page) throw new Error("worker could not adopt page");
step("worker: evaluate on existing tab");
console.log("title:", await page.title());
// String form: the callback body runs in the page; a function literal would need the DOM lib.
console.log("hero:", await page.evaluate(`document.querySelector("#hero")?.textContent ?? "(no hero)"`));
step("worker: extra CDP session (screenshot path)");
const session = await page.createCDPSession();
const frameTree = (await session.send("Page.getFrameTree")) as { frameTree: { frame: { url: string } } };
console.log("frame url:", frameTree.frameTree.frame.url);
await session.detach();
step("worker: navigate existing tab");
await page.goto("https://example.com/?relay-smoke", { waitUntil: "load", timeout: 20_000 });
console.log("navigated:", page.url(), "/", await page.title());
step("worker: screenshot");
const shot = await page.screenshot({ type: "png" });
console.log("screenshot bytes:", shot.byteLength);
step("supervisor: newPage (Target.createTarget) + close (Target.closeTarget)");
const fresh = await supervisor.newPage();
await fresh.goto("about:blank");
console.log("new page url:", fresh.url());
await fresh.close();
console.log("closed");
step("disconnect both");
await worker.disconnect();
await supervisor.disconnect();
console.log("\nSMOKE OK");
process.exit(0);
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.workspace.json",
"include": ["extension", "scripts"]
}
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added a `relay` browser mode that drives the user's own Chrome tabs through a local CDP relay plus the OMP Browser Relay extension: `omp browser-relay install` writes the bundled extension to disk, and `browser.relay` / `browser.relayUrl` (or per-call `app.relay`) route the browser tool through it. The relay server auto-starts under a profile-independent global daemon broker when the browser tool needs it; every relay consumer holds a broker lease, so the fixed-port singleton stops only after its last consumer across all projects exits. `omp browser-relay` remains available for `--token`/`--no-group`/custom ports, and a relay already serving the port is adopted. It multiplexes the supervisor and per-tab worker puppeteer connections over the single `chrome.debugger` attachment Chrome allows per tab, and gathers controllable tabs into a per-window "omp" tab group (dissolved on disconnect, never re-grouping tabs the user pulls out).
## [17.2.4] - 2026-08-01
### Added
@@ -18,6 +18,7 @@ export const commands: CommandEntry[] = [
{ name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) },
{ name: "agents", load: () => import("./commands/agents").then(m => m.default) },
{ name: "bench", load: () => import("./commands/bench").then(m => m.default) },
{ name: "browser-relay", load: () => import("./commands/browser-relay").then(m => m.default) },
{ name: "cleanse", load: () => import("./commands/cleanse").then(m => m.default) },
{ name: "commit", load: () => import("./commands/commit").then(m => m.default) },
{ name: "completions", load: () => import("./commands/completions").then(m => m.default) },
@@ -0,0 +1,119 @@
/**
* `omp browser-relay` implementation: serve the local CDP relay and install
* its Chrome extension. Standalone CLI command — console output here is
* intentional user-facing output.
*/
import * as path from "node:path";
import { getBrowserRelayDir } from "@oh-my-pi/pi-utils";
import { probeRelayServer } from "../tools/browser/relay/daemon";
import backgroundJs from "../tools/browser/relay/extension-assets/background.js.txt" with { type: "text" };
import manifestJson from "../tools/browser/relay/extension-assets/manifest.json.txt" with { type: "text" };
import optionsHtml from "../tools/browser/relay/extension-assets/options.html.txt" with { type: "text" };
import optionsJs from "../tools/browser/relay/extension-assets/options.js.txt" with { type: "text" };
import { DEFAULT_RELAY_URL } from "../tools/browser/relay/kind";
import { type RelayServer, startRelayServer } from "../tools/browser/relay/server";
export const BROWSER_RELAY_ACTIONS = ["serve", "install"] as const;
export type BrowserRelayAction = (typeof BROWSER_RELAY_ACTIONS)[number];
export interface BrowserRelayCommandArgs {
action: BrowserRelayAction;
port: number;
token?: string;
/** Install target directory; defaults to ~/.omp/browser-relay/extension. */
dir?: string;
/** Gather controllable tabs into an 'omp' Chrome tab group (default true). */
group?: boolean;
verbose?: boolean;
}
const EXTENSION_FILES: Record<string, string> = {
"background.js": backgroundJs,
"manifest.json": manifestJson,
"options.html": optionsHtml,
"options.js": optionsJs,
};
/** Default port of the relay endpoint (kept in sync with DEFAULT_RELAY_URL). */
export const DEFAULT_RELAY_PORT = Number(new URL(DEFAULT_RELAY_URL).port);
export async function runBrowserRelayCommand(args: BrowserRelayCommandArgs): Promise<void> {
if (args.action === "install") {
await runInstall(args.dir);
return;
}
await runServe(args);
}
async function runInstall(dirOverride: string | undefined): Promise<void> {
const dir = dirOverride ? path.resolve(dirOverride) : path.join(getBrowserRelayDir(), "extension");
for (const name in EXTENSION_FILES) {
await Bun.write(path.join(dir, name), EXTENSION_FILES[name]!);
}
console.log(`Installed the OMP Browser Relay extension to ${dir}`);
console.log("");
console.log("Finish setup in Chrome:");
console.log(" 1. Open chrome://extensions and enable Developer mode.");
console.log(` 2. Click "Load unpacked" and select: ${dir}`);
console.log(" 3. Enable the mode: omp config set browser.relay true");
console.log("");
console.log("omp starts the relay automatically when the browser tool needs it;");
console.log("run `omp browser-relay` yourself only for --token or --no-group.");
console.log("The extension badge shows 'on' once it reaches a relay.");
}
async function runServe(args: BrowserRelayCommandArgs): Promise<void> {
const log = args.verbose
? (message: string, data?: Record<string, unknown>) => {
console.error(`[relay] ${message}${data ? ` ${JSON.stringify(data)}` : ""}`);
}
: undefined;
let relay: RelayServer;
try {
relay = startRelayServer({ port: args.port, token: args.token, group: args.group !== false, log });
} catch (err) {
// The port is machine-global while relays can be started by any project's
// broker (or by hand): losing the bind to a live relay is success.
if (err instanceof Error && "code" in err && err.code === "EADDRINUSE") {
if (await probeRelayServer(`http://127.0.0.1:${args.port}`)) {
console.log(`omp browser relay already running on http://127.0.0.1:${args.port}; nothing to do.`);
return;
}
console.error(`Port ${args.port} is in use by something that is not an omp browser relay.`);
process.exit(1);
}
throw err;
}
console.log(`omp browser relay listening on http://127.0.0.1:${args.port}`);
console.log(` extension endpoint ws://127.0.0.1:${args.port}/ext${args.token ? "?token=***" : ""}`);
if (args.port === DEFAULT_RELAY_PORT) {
console.log(" enable with omp config set browser.relay true");
} else {
console.log(
` enable with omp config set browser.relay true && omp config set browser.relayUrl http://127.0.0.1:${args.port}`,
);
}
console.log("Waiting for the OMP Browser Relay extension to connect (omp browser-relay install)...");
let announced = false;
const readiness = setInterval(() => {
if (relay.bridge.ready && !announced) {
announced = true;
console.log("Extension connected. The omp browser tool can now drive your tabs.");
} else if (!relay.bridge.ready && announced) {
announced = false;
console.log("Extension disconnected; waiting for it to reconnect...");
}
}, 500);
const shutdown = () => {
clearInterval(readiness);
relay.stop();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
// Serve runs until SIGINT/SIGTERM; keep the process alive.
await new Promise<never>(() => {});
}
@@ -0,0 +1,53 @@
/**
* `omp browser-relay` — drive the user's own Chrome tabs.
*/
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
import {
BROWSER_RELAY_ACTIONS,
type BrowserRelayAction,
DEFAULT_RELAY_PORT,
runBrowserRelayCommand,
} from "../cli/browser-relay-cli";
export default class BrowserRelay extends Command {
static description = "Run the local CDP relay that lets the browser tool drive your own Chrome tabs";
static args = {
action: Args.string({
description: `Action: ${BROWSER_RELAY_ACTIONS.join(" | ")} (default serve)`,
options: [...BROWSER_RELAY_ACTIONS],
required: false,
}),
};
static flags = {
port: Flags.integer({ char: "p", description: "Port to listen on", default: DEFAULT_RELAY_PORT }),
token: Flags.string({ description: "Require the extension to present this token" }),
dir: Flags.string({
description: "Extension install directory (install; default ~/.omp/browser-relay/extension)",
}),
"no-group": Flags.boolean({
description: "Don't gather controllable tabs into an 'omp' tab group",
default: false,
}),
verbose: Flags.boolean({ char: "v", description: "Log relay traffic summaries to stderr", default: false }),
};
static examples = [
"omp browser-relay install # write the Chrome extension to disk + setup steps",
"omp browser-relay # serve the relay on the default port",
"omp browser-relay -p 9333 --token s3cret",
];
async run(): Promise<void> {
const { args, flags } = await this.parse(BrowserRelay);
await runBrowserRelayCommand({
action: (args.action as BrowserRelayAction | undefined) ?? "serve",
port: flags.port,
token: flags.token,
dir: flags.dir,
group: !flags["no-group"],
verbose: flags.verbose,
});
}
}
@@ -4108,6 +4108,29 @@ export const SETTINGS_SCHEMA = {
},
},
"browser.relay": {
type: "boolean",
default: false,
ui: {
tab: "tools",
group: "Grep & Browser",
label: "Browser Relay",
description:
"Drive your own Chrome tabs through the omp browser relay. Install the extension once (`omp browser-relay install`); the relay server auto-starts when the browser tool needs it. Takes precedence over Browser CDP URL; set PI_BROWSER_RELAY=0 or PI_BROWSER_RELAY=1 to override.",
},
},
"browser.relayUrl": {
type: "string",
default: undefined,
ui: {
tab: "tools",
group: "Grep & Browser",
label: "Browser Relay URL",
description: "omp browser relay endpoint (default http://127.0.0.1:9224).",
},
},
"browser.headless": {
type: "boolean",
default: true,
+1 -1
View File
@@ -1124,7 +1124,7 @@ class DaemonBroker {
}
}
/** Start the detached per-project daemon broker selected by the CLI worker host. */
/** Start the detached project or global daemon broker selected by the CLI worker host. */
export async function startDaemonBrokerFromEnvironment(): Promise<void> {
const projectDir = process.env[DAEMON_PROJECT_DIR_ENV];
const runtimeDir = process.env[DAEMON_RUNTIME_DIR_ENV];
+32 -13
View File
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
import * as net from "node:net";
import * as os from "node:os";
import * as path from "node:path";
import { isEexist, isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
import { getGlobalDaemonRuntimeDir, isEexist, isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
import { hostHasInheritableConsole } from "../eval/py/spawn-options";
import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
@@ -43,8 +43,9 @@ export interface DaemonBrokerClientOptions {
idleGraceMs?: number;
}
/** Persistent per-process connection to one project's daemon broker. */
/** Persistent per-process connection to one project or global daemon broker. */
export interface DaemonBrokerClient {
/** Canonical project directory or synthetic directory identifying a global scope. */
readonly projectDir: string;
request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult>;
close(): void;
@@ -299,7 +300,19 @@ class SocketDaemonClient implements DaemonBrokerClient {
const sharedClients = new Map<string, Promise<DaemonBrokerClient>>();
let cancelExitCleanup: (() => void) | undefined;
/** Create an independent socket connection to one project's shared daemon broker. */
function sharedDaemonClient(key: string, create: () => Promise<DaemonBrokerClient>): Promise<DaemonBrokerClient> {
let pending = sharedClients.get(key);
if (!pending) {
pending = create();
sharedClients.set(key, pending);
if (!cancelExitCleanup) {
cancelExitCleanup = postmortem.register("daemon-broker-clients", () => closeDaemonClients());
}
}
return pending;
}
/** Create an independent socket connection to one daemon broker scope. */
export async function createDaemonBrokerClient(
projectDir: string,
options: DaemonBrokerClientOptions = {},
@@ -313,18 +326,24 @@ export async function createDaemonBrokerClient(
/** Get the process-shared daemon broker client for one canonical project directory. */
export async function daemonClientForProject(projectDir: string): Promise<DaemonBrokerClient> {
const canonical = await canonicalProjectDir(projectDir);
let pending = sharedClients.get(canonical);
if (!pending) {
pending = createDaemonBrokerClient(canonical);
sharedClients.set(canonical, pending);
if (!cancelExitCleanup) {
cancelExitCleanup = postmortem.register("daemon-broker-clients", () => closeDaemonClients());
}
}
return pending;
return sharedDaemonClient(`project:${canonical}`, () => createDaemonBrokerClient(canonical));
}
/** Close every project broker connection held by this omp process. */
/** Get the process-shared client that leases one profile-independent, machine-global daemon broker. */
export async function daemonClientForGlobal(service: string): Promise<DaemonBrokerClient> {
const runtimeDir = getGlobalDaemonRuntimeDir(service);
// Canonicalize only after creation so the first caller and later callers
// derive the same Windows pipe key even when an ancestor is a symlink.
await fs.mkdir(runtimeDir, { recursive: true, mode: 0o700 });
const canonical = await fs.realpath(runtimeDir);
return sharedDaemonClient(`global:${canonical}`, () =>
createDaemonBrokerClient(canonical, {
runtimeDir: canonical,
}),
);
}
/** Close every project and machine-global broker connection held by this omp process. */
export async function closeDaemonClients(): Promise<void> {
const pending = [...sharedClients.values()];
sharedClients.clear();
+1 -1
View File
@@ -4,7 +4,7 @@ import { getDaemonRuntimeDir } from "@oh-my-pi/pi-utils";
/** Resolve the private runtime directory shared by omp processes in one project directory. */
export { getDaemonRuntimeDir as daemonRuntimeDir };
/** Resolve the Unix socket or Windows named pipe used by one project broker. */
/** Resolve the Unix socket or Windows named pipe used by one daemon broker scope. */
export function daemonBrokerEndpoint(projectDir: string, runtimeDir: string): string {
if (process.platform === "win32") {
const key = Bun.hash.wyhash(path.resolve(projectDir)).toString(16).padStart(16, "0");
+2 -2
View File
@@ -8,7 +8,7 @@ export const DAEMON_BROKER_WORKER_ARG = "__omp_worker_daemon_broker";
export const DAEMON_PTY_COLUMNS = 120;
export const DAEMON_PTY_ROWS = 40;
/** Environment key carrying the broker's canonical project directory. */
/** Environment key carrying the broker's canonical project or synthetic global scope directory. */
export const DAEMON_PROJECT_DIR_ENV = "OMP_DAEMON_PROJECT_DIR";
/** Environment key carrying the broker's private runtime directory. */
@@ -45,7 +45,7 @@ export interface DaemonSpec {
detached: boolean;
}
/** Serializable daemon state visible to every client in one project directory. */
/** Serializable daemon state visible to every client in one broker scope. */
export interface DaemonSnapshot {
name: string;
id: string;
@@ -20,6 +20,7 @@ Drives real Chromium tab; full puppeteer access via JS.
- Raw request interception is run-scoped: run end removes `request` handlers, disables interception, releases held requests.
- `app.path` → NEVER tamper with a real desktop app (no stealth patches).
- `app.relay: true` → drive the user's own Chrome tabs via the omp browser relay (auto-started; needs the OMP Browser Relay extension installed). `app.target` picks a tab by URL/title substring; without it the visible tab is adopted without stealing focus.
- Selectors: CSS + puppeteer `aria/…`, `text/…`, `xpath/…`, `pierce/…`. Playwright-only pseudos (`:has-text()`, `:visible`) are REJECTED.
</instruction>
+26 -1
View File
@@ -15,6 +15,7 @@ import {
holdBrowser,
releaseBrowser,
} from "./browser/registry";
import { resolveRelayKind } from "./browser/relay/kind";
import type { Observation, ScreenshotResult } from "./browser/tab-protocol";
import {
type AcquireTabResult,
@@ -39,6 +40,7 @@ export {
export { cmuxSnapshotToObservation, mapWaitUntil, resolveCmuxKind, serializeEval } from "./browser/cmux/rpc";
export { CmuxSocketClient } from "./browser/cmux/socket-client";
export { extractReadableFromHtml, type ReadableFormat, type ReadableResult } from "./browser/readable";
export { DEFAULT_RELAY_URL, type RelayKind, resolveRelayKind } from "./browser/relay/kind";
export type { Observation, ObservationEntry } from "./browser/tab-protocol";
const DEFAULT_TAB_NAME = "main";
@@ -46,6 +48,7 @@ const DEFAULT_TAB_NAME = "main";
const appSchema = type({
"path?": type("string").describe("binary path to spawn"),
"cdp_url?": type("string").describe("existing cdp endpoint"),
"relay?": type("boolean").describe("drive the user's own tabs via the omp browser relay"),
"args?": type("string[]").describe("extra cli args"),
"target?": type("string").describe("substring to pick a window"),
});
@@ -95,7 +98,24 @@ function resolveBrowserKind(params: BrowserParams, session: ToolSession): Browse
const exe = resolveToCwd(app.path, session.cwd);
return { kind: "spawned", path: exe };
}
// A configured endpoint is a default, not an override: explicit app options win.
const relayUrl = session.settings.get("browser.relayUrl") as string | undefined;
// Explicit app.relay wins over every setting; PI_BROWSER_RELAY stays the
// final kill switch (a relay that is down would otherwise brick the tool).
if (app?.relay) {
const relayKind = resolveRelayKind({ settingEnabled: true, url: relayUrl });
if (relayKind) return relayKind;
}
// Relay before cdpUrl among settings: enabling the opt-out-by-default relay
// is a deliberate mode selection, while cdpUrl is a standing fallback
// endpoint. A configured endpoint is a default, not an override: explicit
// app options win.
if (app?.relay !== false) {
const relayKind = resolveRelayKind({
settingEnabled: session.settings.get("browser.relay") as boolean | undefined,
url: relayUrl,
});
if (relayKind) return relayKind;
}
const configuredCdpUrl = (session.settings.get("browser.cdpUrl") as string | undefined)?.trim();
if (configuredCdpUrl) {
return { kind: "connected", cdpUrl: configuredCdpUrl.replace(/\/+$/, "") };
@@ -427,6 +447,8 @@ function describeBrowser(handle: BrowserHandle): string {
return `spawned ${handle.kind.path} (pid ${handle.pid ?? "?"})`;
case "connected":
return `connected ${handle.cdpUrl ?? handle.kind.cdpUrl}`;
case "relay":
return `relay ${handle.cdpUrl ?? handle.kind.cdpUrl}`;
}
}
@@ -438,6 +460,8 @@ function describeKind(kind: BrowserKind): string {
return `spawned:${kind.path}`;
case "connected":
return `connected:${kind.cdpUrl}`;
case "relay":
return `relay:${kind.cdpUrl}`;
case "cmux":
return `cmux:${kind.surface ?? "split"}`;
}
@@ -448,6 +472,7 @@ function sameBrowserKind(a: BrowserKind, b: BrowserKind): boolean {
if (a.kind === "headless" && b.kind === "headless") return a.headless === b.headless;
if (a.kind === "spawned" && b.kind === "spawned") return a.path === b.path;
if (a.kind === "connected" && b.kind === "connected") return a.cdpUrl === b.cdpUrl;
if (a.kind === "relay" && b.kind === "relay") return a.cdpUrl === b.cdpUrl;
if (a.kind === "cmux" && b.kind === "cmux") return a.socketPath === b.socketPath;
return false;
}
@@ -1,3 +1,4 @@
import { parseFlag } from "@oh-my-pi/pi-utils";
import { ToolError } from "../../tool-errors";
import type { Observation, ObservationEntry } from "../tab-protocol";
@@ -174,13 +175,6 @@ export function mapWaitUntil(waitUntil: string | undefined): "interactive" | "co
return waitUntil === "domcontentloaded" ? "interactive" : "complete";
}
const TRUTHY_ENV_VALUES = new Set(["1", "Y", "y", "TRUE", "true", "YES", "yes", "ON", "on"]);
function resolveCmuxEnabled(envValue: string | undefined, settingEnabled: boolean): boolean {
if (!envValue) return settingEnabled;
return TRUTHY_ENV_VALUES.has(envValue);
}
export interface ResolveCmuxKindOptions {
surface?: string;
settingEnabled?: boolean;
@@ -190,7 +184,7 @@ export function resolveCmuxKind(
options?: ResolveCmuxKindOptions | null,
env: Record<string, string | undefined> = process.env,
): CmuxKind | null {
if (!resolveCmuxEnabled(env.PI_BROWSER_CMUX, options?.settingEnabled ?? true)) {
if (!parseFlag(env.PI_BROWSER_CMUX, options?.settingEnabled ?? true)) {
return null;
}
const socketPath = env.CMUX_SOCKET_PATH;
@@ -14,12 +14,15 @@ import {
removeUserDataDir,
type UserAgentOverride,
} from "./launch";
import { ensureRelayDaemon, isLoopbackRelayUrl } from "./relay/daemon";
import type { RelayKind } from "./relay/kind";
import { ensureSharedBrowser } from "./shared-daemon";
export type PuppeteerBrowserKind =
| { kind: "headless"; headless: boolean }
| { kind: "spawned"; path: string }
| { kind: "connected"; cdpUrl: string };
| { kind: "connected"; cdpUrl: string }
| RelayKind;
export type BrowserKind = PuppeteerBrowserKind | CmuxKind;
@@ -31,6 +34,12 @@ export type BrowserKindTag = BrowserKind["kind"];
* forever (issue #5260), so we cap the wait and force-kill on timeout.
*/
const HEADLESS_CLOSE_TIMEOUT_MS = 5_000;
/**
* How long a relay open waits for the extension handshake (503 → 200). A
* reaped extension service worker is revived by its 30s keepalive alarm, so
* the wait must cover one full alarm period plus the dial.
*/
const RELAY_EXTENSION_WAIT_MS = 35_000;
interface BrowserHandleCommon {
key: string;
@@ -78,6 +87,8 @@ function browserKey(kind: BrowserKind): string {
return `spawned:${kind.path}`;
case "connected":
return `connected:${kind.cdpUrl}`;
case "relay":
return `relay:${kind.cdpUrl}`;
case "cmux":
return `cmux:${kind.socketPath}`;
}
@@ -201,6 +212,45 @@ async function openBrowserHandle(kind: BrowserKind, opts: AcquireBrowserOptions)
stealth: { browserSession: null, override: null },
};
}
if (kind.kind === "relay") {
const cdpUrl = normalizeConnectedCdpUrl(kind.cdpUrl);
// Loopback relays are owned by a machine-global broker and auto-started
// on demand (the extension dials in on its own). Hosts without a CLI
// worker entry (bun test, SDK embedding) never spawn brokers. Remote
// relay URLs must already be serving.
let autoStarted = false;
if (isLoopbackRelayUrl(cdpUrl) && (isCompiledBinary() || workerHostEntry() !== null)) {
autoStarted = await ensureRelayDaemon({ cdpUrl, signal: opts.signal });
}
// The relay answers /json/version with 503 until its extension dials in.
// A freshly revived extension service worker can take up to ~30s (its
// keepalive alarm) to reconnect, so give the handshake that long.
try {
await waitForCdp(cdpUrl, RELAY_EXTENSION_WAIT_MS, opts.signal);
} catch (err) {
if (err instanceof ToolAbortError) throw err;
if (err instanceof Error && err.name === "AbortError") throw err;
throw new ToolError(
autoStarted
? `omp browser relay is serving at ${cdpUrl} but its extension never connected. Install it with \`omp browser-relay install\` and check the toolbar badge shows "on".`
: `omp browser relay is not reachable at ${cdpUrl}. Start it with \`omp browser-relay\` (or check the endpoint), and make sure the OMP Browser Relay extension is loaded in Chrome.`,
);
}
const puppeteer = await loadPuppeteer();
const browser = await puppeteer.connect({
browserURL: cdpUrl,
defaultViewport: null,
protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
});
return {
key: browserKey(kind),
kind,
browser,
cdpUrl,
refCount: 0,
stealth: { browserSession: null, override: null },
};
}
const exe = kind.path;
if (!path.isAbsolute(exe)) {
@@ -320,7 +370,8 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: ReleaseBrowserO
if (handle.userDataDir) await removeUserDataDir(handle.userDataDir);
return;
}
if (handle.kind.kind === "connected") {
// Connected and relay browsers belong to the user: drop our CDP link, never kill.
if (handle.kind.kind === "connected" || handle.kind.kind === "relay") {
if (handle.browser.connected) {
try {
handle.browser.disconnect();
@@ -0,0 +1,867 @@
/**
* CDP façade over `chrome.debugger`.
*
* Puppeteer clients (the omp browser tool: one supervisor connection plus one
* per tab worker) connect to this bridge as if it were Chrome's browser
* debugging endpoint. Chrome only allows a single debugger attachment per tab,
* so the bridge owns ONE `chrome.debugger` attachment per tab (via the
* extension) and multiplexes every downstream connection over it with minted
* per-connection session ids.
*
* Emulated surface (everything else is forwarded to `chrome.debugger`):
* - the browser target (`/json/version` handshake, `Browser.getVersion`)
* - the `Target.*` domain, including puppeteer's tab → page auto-attach
* hierarchy (see puppeteer-core `cdp/ExtensionTransport.ts`, the reference
* implementation for this emulation)
*
* Session id namespaces seen by a downstream connection:
* - minted tab pseudo-sessions (`ST<tab>.<conn>.<n>`) — Target emulation only
* - minted page pseudo-sessions (`SP<tab>.<conn>.<n>`) — forwarded to the
* tab's root debugger session
* - real child session ids (OOPIFs, workers) — created by Chrome under the
* shared root session and passed through verbatim
*/
import type { ExtToRelayMessage, RelayRpcRequest, RelayToExtMessage, TabSnapshot } from "./protocol";
/** Transport-agnostic websocket surface the bridge writes to. */
export interface RelaySocket {
send(text: string): void;
close(): void;
}
interface CdpCommand {
id: number;
method: string;
params?: Record<string, unknown>;
sessionId?: string;
}
interface SessionRef {
kind: "tab" | "page";
tabId: number;
}
interface TargetInfo {
targetId: string;
type: "tab" | "page" | "browser";
title: string;
url: string;
attached: boolean;
canAccessOpener: boolean;
}
class CdpConnection {
discover = false;
autoAttach = false;
/** Minted pseudo-sessions owned by this connection. */
readonly sessions = new Map<string, SessionRef>();
constructor(
readonly id: number,
readonly socket: RelaySocket,
) {}
sessionsForTab(tabId: number, kind?: "tab" | "page"): string[] {
const out: string[] = [];
for (const [sessionId, ref] of this.sessions) {
if (ref.tabId === tabId && (!kind || ref.kind === kind)) out.push(sessionId);
}
return out;
}
}
class TabState {
url: string;
title: string;
active: boolean;
windowId: number;
pinned: boolean;
/** Chrome tab group id from the last snapshot; -1 when ungrouped. */
groupId: number;
/** Whether `chrome.debugger` is currently attached to this tab. */
attached = false;
/** Set when attach failed or the user cancelled the debugger; cleared on navigation. */
banned = false;
/** Whether targets for this tab were announced to discovering connections. */
announced = false;
attaching: Promise<boolean> | null = null;
/** True after the relay put this tab in the omp group; `ompGroupId` holds that group. */
grouped = false;
/** Group RPC in flight — suppresses duplicate requests from load-time tabUpdated bursts. */
grouping = false;
ompGroupId: number | undefined;
/** User pulled the tab out of the omp group — never re-group it. */
groupOptOut = false;
/** Real Chrome session ids (OOPIF/worker children) living under this tab's root session. */
readonly realSessions = new Set<string>();
constructor(
readonly tabId: number,
snap: TabSnapshot,
) {
this.url = snap.url;
this.title = snap.title;
this.active = snap.active;
this.windowId = snap.windowId;
this.pinned = snap.pinned;
this.groupId = snap.groupId;
}
update(snap: TabSnapshot): void {
this.url = snap.url;
this.title = snap.title;
this.active = snap.active;
this.windowId = snap.windowId;
this.pinned = snap.pinned;
this.groupId = snap.groupId;
}
}
/** URLs `chrome.debugger` cannot attach to; hidden from downstream discovery entirely. */
const INELIGIBLE_URL = /^(chrome|devtools|edge|view-source|chrome-extension|chrome-untrusted|chrome-search):/i;
const RPC_TIMEOUT_MS = 20_000;
const CDP_ERROR_METHOD_NOT_FOUND = -32601;
const CDP_ERROR_SERVER = -32000;
function tabTargetId(tabId: number): string {
return `TAB${tabId}`;
}
function pageTargetId(tabId: number): string {
return `PAGE${tabId}`;
}
/** Reverse of {@link tabTargetId}/{@link pageTargetId}; null for foreign ids. */
function parseTargetId(targetId: string): { kind: "tab" | "page"; tabId: number } | null {
const match = /^(TAB|PAGE)(\d+)$/.exec(targetId);
if (!match) return null;
return { kind: match[1] === "TAB" ? "tab" : "page", tabId: Number(match[2]) };
}
/**
* Multiplexing CDP bridge between downstream puppeteer connections and the
* relay extension. One instance per relay server; all state lives here so an
* extension service-worker restart only has to re-handshake.
*/
export class RelayBridge {
#tabs = new Map<number, TabState>();
#conns = new Map<number, CdpConnection>();
#connSeq = 0;
#sessionSeq = 0;
#rpcSeq = 0;
#ext: RelaySocket | null = null;
#extInfo: { userAgent: string; browserVersion: string } | null = null;
#pendingRpc = new Map<
number,
{ resolve: (value: unknown) => void; reject: (err: Error) => void; timer: NodeJS.Timeout }
>();
/** Real child session id → owning tab, learned from `Target.attachedToTarget` events. */
#realSessionTabs = new Map<string, number>();
#log: (message: string, data?: Record<string, unknown>) => void;
/** Tab-group appearance for controllable tabs; null disables grouping. */
#group: { title: string; color: string } | null;
constructor(
opts: {
log?: (message: string, data?: Record<string, unknown>) => void;
/** Group controllable tabs under one per-window Chrome tab group. */
group?: { title: string; color: string } | null;
} = {},
) {
this.#log = opts.log ?? (() => {});
this.#group = opts.group ?? null;
}
/** True once the extension has completed its hello handshake. */
get ready(): boolean {
return this.#ext !== null && this.#extInfo !== null;
}
/** Payload for `GET /json/version`. */
versionInfo(wsUrl: string): Record<string, string> {
const ua = this.#extInfo?.userAgent ?? "";
return {
Browser: this.#extInfo?.browserVersion ?? "Chrome/unknown",
"Protocol-Version": "1.3",
"User-Agent": ua,
"V8-Version": "",
"WebKit-Version": "",
webSocketDebuggerUrl: wsUrl,
};
}
/** Payload for `GET /json/list` (debugging aid; per-target endpoints are not served). */
listTargets(): Array<Record<string, string>> {
const out: Array<Record<string, string>> = [];
for (const tab of this.#tabs.values()) {
if (!this.#eligible(tab)) continue;
out.push({ id: pageTargetId(tab.tabId), type: "page", title: tab.title, url: tab.url });
}
return out;
}
// ---- extension lifecycle -------------------------------------------------
/** A new extension socket connected; replaces any previous one. */
extConnected(socket: RelaySocket): void {
if (this.#ext && this.#ext !== socket) {
this.#log("replacing extension socket");
this.#ext.close();
}
this.#ext = socket;
}
extClosed(socket: RelaySocket): void {
if (this.#ext !== socket) return;
this.#ext = null;
this.#extInfo = null;
for (const pending of this.#pendingRpc.values()) {
clearTimeout(pending.timer);
pending.reject(new Error("relay extension disconnected"));
}
this.#pendingRpc.clear();
for (const tab of this.#tabs.values()) {
tab.attached = false;
tab.attaching = null;
}
}
extMessage(socket: RelaySocket, raw: string): void {
if (socket !== this.#ext) return;
let msg: ExtToRelayMessage;
try {
msg = JSON.parse(raw) as ExtToRelayMessage;
} catch {
this.#log("dropping malformed extension message");
return;
}
switch (msg.t) {
case "hello":
this.#onHello(msg);
return;
case "rpcResult": {
const pending = this.#pendingRpc.get(msg.id);
if (!pending) return;
this.#pendingRpc.delete(msg.id);
clearTimeout(pending.timer);
if (msg.ok) pending.resolve(msg.result);
else pending.reject(new Error(msg.error ?? "extension rpc failed"));
return;
}
case "cdpEvent":
this.#onCdpEvent(msg.tabId, msg.sessionId, msg.method, msg.params);
return;
case "detached":
this.#onTabDetached(msg.tabId, msg.reason);
return;
case "tabCreated":
this.#onTabUpsert(msg.tab);
return;
case "tabUpdated":
this.#onTabUpsert(msg.tab);
return;
case "tabRemoved":
this.#onTabRemoved(msg.tabId);
return;
case "ping":
socket.send(JSON.stringify({ t: "pong" } satisfies RelayToExtMessage));
return;
}
}
#onHello(msg: Extract<ExtToRelayMessage, { t: "hello" }>): void {
this.#extInfo = { userAgent: msg.userAgent, browserVersion: msg.browserVersion };
const seen = new Set<number>();
const attachedNow = new Set(msg.attachedTabIds);
for (const snap of msg.tabs) {
seen.add(snap.tabId);
this.#onTabUpsert(snap, { silent: true });
}
for (const tabId of [...this.#tabs.keys()]) {
if (!seen.has(tabId)) this.#onTabRemoved(tabId);
}
for (const tab of this.#tabs.values()) {
const wasAttached = tab.attached;
tab.attached = attachedNow.has(tab.tabId);
tab.attaching = null;
// A service-worker restart can drop attachments while downstream
// connections still hold sessions: restore them best-effort.
if (wasAttached && !tab.attached && this.#sessionHolders(tab.tabId).length > 0) {
void this.#ensureAttached(tab).then(ok => {
if (!ok) this.#onTabDetached(tab.tabId, "reattach_failed");
});
}
}
this.#syncGrouping();
this.#log("extension connected", { tabs: this.#tabs.size, version: msg.browserVersion });
}
// ---- downstream (puppeteer) lifecycle -------------------------------------
/** Register a downstream CDP websocket; returns the connection id. */
cdpConnected(socket: RelaySocket): number {
const conn = new CdpConnection(++this.#connSeq, socket);
this.#conns.set(conn.id, conn);
this.#log("cdp client connected", { conn: conn.id });
return conn.id;
}
cdpClosed(connId: number): void {
const conn = this.#conns.get(connId);
if (!conn) return;
this.#conns.delete(connId);
const touched = new Set<number>();
for (const ref of conn.sessions.values()) touched.add(ref.tabId);
conn.sessions.clear();
// Drop the debugger (and its infobar) from tabs nobody drives anymore.
for (const tabId of touched) {
if (this.#sessionHolders(tabId).length > 0) continue;
const tab = this.#tabs.get(tabId);
if (tab?.attached) {
tab.attached = false;
void this.#rpc({ op: "detach", tabId }).catch(() => {});
}
}
this.#log("cdp client closed", { conn: connId });
}
cdpMessage(connId: number, raw: string): void {
const conn = this.#conns.get(connId);
if (!conn) return;
let msg: CdpCommand;
try {
msg = JSON.parse(raw) as CdpCommand;
} catch {
return;
}
if (typeof msg.id !== "number" || typeof msg.method !== "string") return;
void this.#handleCdpCommand(conn, msg).catch(err => {
this.#replyError(conn, msg, err instanceof Error ? err.message : String(err));
});
}
// ---- command routing -------------------------------------------------------
async #handleCdpCommand(conn: CdpConnection, msg: CdpCommand): Promise<void> {
const sessionId = msg.sessionId;
if (!sessionId) {
await this.#handleBrowserCommand(conn, msg);
return;
}
const ref = conn.sessions.get(sessionId);
if (ref?.kind === "tab") {
this.#handleTabSessionCommand(conn, msg, ref);
return;
}
if (ref?.kind === "page") {
await this.#forwardToTab(conn, msg, ref.tabId, undefined);
return;
}
const realTab = this.#realSessionTabs.get(sessionId);
if (realTab !== undefined) {
await this.#forwardToTab(conn, msg, realTab, sessionId);
return;
}
this.#replyError(conn, msg, `Unknown session id ${sessionId}`);
}
async #forwardToTab(
conn: CdpConnection,
msg: CdpCommand,
tabId: number,
realSessionId: string | undefined,
): Promise<void> {
// Guard rail: a page session must never take the whole browser down.
if (msg.method === "Browser.close") {
this.#reply(conn, msg, {});
return;
}
try {
const result = await this.#rpc({
op: "send",
tabId,
sessionId: realSessionId,
method: msg.method,
params: msg.params,
});
this.#reply(conn, msg, (result as Record<string, unknown> | undefined) ?? {});
} catch (err) {
this.#replyError(conn, msg, err instanceof Error ? err.message : String(err));
}
}
/** Tab pseudo-sessions only exist to satisfy puppeteer's Target hierarchy. */
#handleTabSessionCommand(conn: CdpConnection, msg: CdpCommand, ref: SessionRef): void {
switch (msg.method) {
case "Target.setAutoAttach": {
const tab = this.#tabs.get(ref.tabId);
if (!tab) {
this.#replyError(conn, msg, `Tab ${ref.tabId} is gone`);
return;
}
// Emit before replying: puppeteer's TargetManager counts page
// children attached before the setAutoAttach response resolves.
const pageSession = this.#mintSession(conn, "page", tab.tabId);
this.#emit(
conn,
"Target.attachedToTarget",
{
sessionId: pageSession,
targetInfo: this.#pageInfo(tab, true),
waitingForDebugger: false,
},
msg.sessionId,
);
this.#reply(conn, msg, {});
return;
}
case "Runtime.runIfWaitingForDebugger":
this.#reply(conn, msg, {});
return;
case "Target.detachFromTarget": {
const child = typeof msg.params?.sessionId === "string" ? msg.params.sessionId : undefined;
if (child) this.#releaseSession(conn, child, msg.sessionId);
this.#reply(conn, msg, {});
return;
}
default:
this.#replyError(conn, msg, `'${msg.method}' is not supported on a tab target`, CDP_ERROR_METHOD_NOT_FOUND);
}
}
async #handleBrowserCommand(conn: CdpConnection, msg: CdpCommand): Promise<void> {
switch (msg.method) {
case "Browser.getVersion": {
this.#reply(conn, msg, {
protocolVersion: "1.3",
product: this.#extInfo?.browserVersion ?? "Chrome/unknown",
revision: "",
userAgent: this.#extInfo?.userAgent ?? "",
jsVersion: "",
});
return;
}
case "Target.getBrowserContexts":
this.#reply(conn, msg, { browserContextIds: [] });
return;
case "Target.setDiscoverTargets": {
conn.discover = true;
for (const tab of this.#tabs.values()) {
if (!this.#eligible(tab)) continue;
tab.announced = true;
this.#emit(conn, "Target.targetCreated", { targetInfo: this.#tabInfo(tab, tab.attached) });
this.#emit(conn, "Target.targetCreated", { targetInfo: this.#pageInfo(tab, tab.attached) });
}
this.#reply(conn, msg, {});
return;
}
case "Target.setAutoAttach": {
conn.autoAttach = true;
const tabs = [...this.#tabs.values()].filter(tab => this.#eligible(tab));
await Promise.all(tabs.map(tab => this.#ensureAttached(tab)));
for (const tab of tabs) {
if (!tab.attached) {
// Attach failed (DevTools open, another debugger, …): retract
// the target so puppeteer's init never waits on it.
this.#retractTab(tab);
continue;
}
this.#emitTabAttached(conn, tab);
}
this.#reply(conn, msg, {});
return;
}
case "Target.attachToTarget": {
const parsed = typeof msg.params?.targetId === "string" ? parseTargetId(msg.params.targetId) : null;
const tab = parsed ? this.#tabs.get(parsed.tabId) : undefined;
if (!parsed || !tab) {
this.#replyError(conn, msg, `No target with id ${String(msg.params?.targetId)}`);
return;
}
if (!(await this.#ensureAttached(tab))) {
this.#replyError(conn, msg, `Cannot attach to tab ${tab.tabId} (${tab.url})`);
return;
}
const sessionId = this.#mintSession(conn, parsed.kind, tab.tabId);
const info = parsed.kind === "tab" ? this.#tabInfo(tab, true) : this.#pageInfo(tab, true);
this.#emit(conn, "Target.attachedToTarget", { sessionId, targetInfo: info, waitingForDebugger: false });
this.#reply(conn, msg, { sessionId });
return;
}
case "Target.detachFromTarget": {
const sessionId = typeof msg.params?.sessionId === "string" ? msg.params.sessionId : undefined;
if (sessionId) this.#releaseSession(conn, sessionId, undefined);
this.#reply(conn, msg, {});
return;
}
case "Target.createTarget": {
const url =
typeof msg.params?.url === "string" && msg.params.url.length > 0 ? msg.params.url : "about:blank";
const result = (await this.#rpc({ op: "createTab", url })) as { tab: TabSnapshot };
this.#onTabUpsert(result.tab);
this.#reply(conn, msg, { targetId: pageTargetId(result.tab.tabId) });
return;
}
case "Target.closeTarget": {
const parsed = typeof msg.params?.targetId === "string" ? parseTargetId(msg.params.targetId) : null;
if (!parsed) {
this.#replyError(conn, msg, `No target with id ${String(msg.params?.targetId)}`);
return;
}
await this.#rpc({ op: "removeTab", tabId: parsed.tabId });
this.#reply(conn, msg, { success: true });
return;
}
case "Target.activateTarget": {
const parsed = typeof msg.params?.targetId === "string" ? parseTargetId(msg.params.targetId) : null;
if (parsed) await this.#rpc({ op: "activateTab", tabId: parsed.tabId });
this.#reply(conn, msg, {});
return;
}
case "Target.getTargetInfo": {
const raw = typeof msg.params?.targetId === "string" ? msg.params.targetId : undefined;
const parsed = raw ? parseTargetId(raw) : null;
const tab = parsed ? this.#tabs.get(parsed.tabId) : undefined;
if (parsed && tab) {
const info =
parsed.kind === "tab" ? this.#tabInfo(tab, tab.attached) : this.#pageInfo(tab, tab.attached);
this.#reply(conn, msg, { targetInfo: info });
return;
}
this.#reply(conn, msg, {
targetInfo: {
targetId: "relay-browser",
type: "browser",
title: "",
url: "",
attached: true,
canAccessOpener: false,
} satisfies TargetInfo,
});
return;
}
case "Browser.close":
// Never close the user's browser; acknowledge and ignore.
this.#log("refusing Browser.close from downstream client", { conn: conn.id });
this.#reply(conn, msg, {});
return;
case "Browser.setDownloadBehavior":
this.#reply(conn, msg, {});
return;
case "Target.createBrowserContext":
this.#replyError(conn, msg, "Browser contexts are not supported by the omp browser relay");
return;
default:
this.#replyError(conn, msg, `'${msg.method}' wasn't found`, CDP_ERROR_METHOD_NOT_FOUND);
}
}
// ---- extension events -------------------------------------------------------
#onCdpEvent(
tabId: number,
sourceSessionId: string | undefined,
method: string,
params?: Record<string, unknown>,
): void {
const tab = this.#tabs.get(tabId);
if (!tab) return;
// Track real child sessions so downstream commands can route back.
if (method === "Target.attachedToTarget") {
const child = params?.sessionId;
if (typeof child === "string") {
tab.realSessions.add(child);
this.#realSessionTabs.set(child, tabId);
}
} else if (method === "Target.detachedFromTarget") {
const child = params?.sessionId;
if (typeof child === "string") {
tab.realSessions.delete(child);
this.#realSessionTabs.delete(child);
}
}
if (sourceSessionId) {
// Event from a real child session: pass through verbatim to every
// connection that observes this tab.
const payload = JSON.stringify({ sessionId: sourceSessionId, method, params });
for (const conn of this.#conns.values()) {
if (conn.sessionsForTab(tabId, "page").length > 0) conn.socket.send(payload);
}
return;
}
// Root-session event: fan out once per minted page session.
for (const conn of this.#conns.values()) {
for (const pageSession of conn.sessionsForTab(tabId, "page")) {
conn.socket.send(JSON.stringify({ sessionId: pageSession, method, params }));
}
}
}
#onTabDetached(tabId: number, reason: string): void {
const tab = this.#tabs.get(tabId);
if (!tab) return;
this.#log("tab detached", { tabId, reason });
tab.attached = false;
tab.attaching = null;
tab.banned = true;
this.#retractTab(tab);
}
#onTabRemoved(tabId: number): void {
const tab = this.#tabs.get(tabId);
if (!tab) return;
this.#retractTab(tab);
this.#tabs.delete(tabId);
}
#onTabUpsert(snap: TabSnapshot, opts: { silent?: boolean } = {}): void {
let tab = this.#tabs.get(snap.tabId);
if (!tab) {
tab = new TabState(snap.tabId, snap);
this.#tabs.set(snap.tabId, tab);
} else {
if (tab.url !== snap.url) tab.banned = false;
// The user dragging a tab out of the omp group is an opt-out; the
// relay never fights the user over grouping.
if (tab.grouped && tab.ompGroupId !== undefined && snap.groupId !== tab.ompGroupId) {
tab.grouped = false;
tab.groupOptOut = true;
}
tab.update(snap);
}
if (opts.silent) return;
const eligible = this.#eligible(tab);
this.#syncTabGrouping(tab, eligible);
if (eligible && !tab.announced) {
tab.announced = true;
for (const conn of this.#conns.values()) {
if (!conn.discover) continue;
this.#emit(conn, "Target.targetCreated", { targetInfo: this.#tabInfo(tab, tab.attached) });
this.#emit(conn, "Target.targetCreated", { targetInfo: this.#pageInfo(tab, tab.attached) });
}
for (const conn of this.#conns.values()) {
if (!conn.autoAttach) continue;
void this.#ensureAttached(tab).then(ok => {
if (ok) this.#emitTabAttached(conn, tab);
});
}
return;
}
if (!eligible && tab.announced) {
this.#retractTab(tab);
return;
}
if (eligible && tab.announced) {
for (const conn of this.#conns.values()) {
if (!conn.discover) continue;
this.#emit(conn, "Target.targetInfoChanged", { targetInfo: this.#tabInfo(tab, tab.attached) });
this.#emit(conn, "Target.targetInfoChanged", { targetInfo: this.#pageInfo(tab, tab.attached) });
}
}
}
// ---- tab grouping -----------------------------------------------------------
/** A tab belongs in the omp group when controllable, unpinned, not user-opted-out, and not already in a user group. */
#groupWorthy(tab: TabState): boolean {
if (!this.#eligible(tab) || tab.pinned || tab.groupOptOut) return false;
return tab.grouped || tab.groupId === -1;
}
/** Group every currently worthy tab (extension hello / reconnect). */
#syncGrouping(): void {
if (!this.#group) return;
const worthy = [...this.#tabs.values()].filter(tab => this.#groupWorthy(tab) && !tab.grouped && !tab.grouping);
if (worthy.length > 0) this.#requestGroup(worthy);
}
/** Reconcile one tab's group membership after a lifecycle event. */
#syncTabGrouping(tab: TabState, eligible: boolean): void {
if (!this.#group) return;
if (eligible && this.#groupWorthy(tab) && !tab.grouped && !tab.grouping) {
this.#requestGroup([tab]);
return;
}
if (!eligible && tab.grouped) {
tab.grouped = false;
tab.ompGroupId = undefined;
void this.#rpc({ op: "ungroup", tabIds: [tab.tabId] }).catch(() => {});
}
}
#requestGroup(tabs: TabState[]): void {
const group = this.#group;
if (!group) return;
const tabIds = tabs.map(tab => tab.tabId);
for (const tab of tabs) tab.grouping = true;
void this.#rpc({ op: "group", tabIds, title: group.title, color: group.color })
.then(result => {
// Extension replies { grouped: { [tabId]: groupId } }; validate per entry.
const grouped: Record<string, unknown> =
result &&
typeof result === "object" &&
"grouped" in result &&
result.grouped &&
typeof result.grouped === "object"
? (result.grouped as Record<string, unknown>)
: {};
for (const tab of tabs) {
const groupId = grouped[String(tab.tabId)];
if (typeof groupId !== "number") continue;
tab.grouped = true;
tab.ompGroupId = groupId;
}
this.#log("grouped tabs", { tabIds, grouped });
})
.catch(err => {
this.#log("tab grouping failed", { error: err instanceof Error ? err.message : String(err) });
})
.finally(() => {
for (const tab of tabs) tab.grouping = false;
});
}
/** Tear a tab out of every downstream connection (closed, detached, or now ineligible). */
#retractTab(tab: TabState): void {
for (const realSession of tab.realSessions) this.#realSessionTabs.delete(realSession);
tab.realSessions.clear();
for (const conn of this.#conns.values()) {
const tabSessions = conn.sessionsForTab(tab.tabId, "tab");
for (const pageSession of conn.sessionsForTab(tab.tabId, "page")) {
conn.sessions.delete(pageSession);
this.#emit(
conn,
"Target.detachedFromTarget",
{ sessionId: pageSession, targetId: pageTargetId(tab.tabId) },
tabSessions[0],
);
}
for (const tabSession of tabSessions) {
conn.sessions.delete(tabSession);
this.#emit(conn, "Target.detachedFromTarget", { sessionId: tabSession, targetId: tabTargetId(tab.tabId) });
}
if (conn.discover && tab.announced) {
this.#emit(conn, "Target.targetDestroyed", { targetId: pageTargetId(tab.tabId) });
this.#emit(conn, "Target.targetDestroyed", { targetId: tabTargetId(tab.tabId) });
}
}
tab.announced = false;
}
// ---- session + attach bookkeeping --------------------------------------------
#mintSession(conn: CdpConnection, kind: "tab" | "page", tabId: number): string {
const sessionId = `S${kind === "tab" ? "T" : "P"}${tabId}.${conn.id}.${++this.#sessionSeq}`;
conn.sessions.set(sessionId, { kind, tabId });
return sessionId;
}
#releaseSession(conn: CdpConnection, sessionId: string, parentSessionId: string | undefined): void {
const ref = conn.sessions.get(sessionId);
if (!ref) return;
conn.sessions.delete(sessionId);
const targetId = ref.kind === "tab" ? tabTargetId(ref.tabId) : pageTargetId(ref.tabId);
this.#emit(conn, "Target.detachedFromTarget", { sessionId, targetId }, parentSessionId);
}
/** Connections currently holding any session on a tab. */
#sessionHolders(tabId: number): CdpConnection[] {
const out: CdpConnection[] = [];
for (const conn of this.#conns.values()) {
if (conn.sessionsForTab(tabId).length > 0) out.push(conn);
}
return out;
}
#emitTabAttached(conn: CdpConnection, tab: TabState): void {
if (conn.sessionsForTab(tab.tabId, "tab").length > 0) return;
const sessionId = this.#mintSession(conn, "tab", tab.tabId);
this.#emit(conn, "Target.attachedToTarget", {
sessionId,
targetInfo: this.#tabInfo(tab, true),
waitingForDebugger: false,
});
}
async #ensureAttached(tab: TabState): Promise<boolean> {
if (tab.attached) return true;
if (tab.banned || !this.#ext) return false;
if (tab.attaching) return await tab.attaching;
const attempt = this.#rpc({ op: "attach", tabId: tab.tabId })
.then(() => {
tab.attached = true;
return true;
})
.catch(err => {
this.#log("attach failed", {
tabId: tab.tabId,
url: tab.url,
error: err instanceof Error ? err.message : String(err),
});
tab.banned = true;
return false;
})
.finally(() => {
tab.attaching = null;
});
tab.attaching = attempt;
return await attempt;
}
#eligible(tab: TabState): boolean {
if (tab.banned) return false;
if (!tab.url) return true;
return !INELIGIBLE_URL.test(tab.url);
}
#tabInfo(tab: TabState, attached: boolean): TargetInfo {
return {
targetId: tabTargetId(tab.tabId),
type: "tab",
title: tab.title,
url: tab.url || "about:blank",
attached,
canAccessOpener: false,
};
}
#pageInfo(tab: TabState, attached: boolean): TargetInfo {
return {
targetId: pageTargetId(tab.tabId),
type: "page",
title: tab.title,
url: tab.url || "about:blank",
attached,
canAccessOpener: false,
};
}
// ---- plumbing ---------------------------------------------------------------
#reply(conn: CdpConnection, msg: CdpCommand, result: Record<string, unknown>): void {
conn.socket.send(JSON.stringify({ id: msg.id, sessionId: msg.sessionId, result }));
}
#replyError(conn: CdpConnection, msg: CdpCommand, message: string, code = CDP_ERROR_SERVER): void {
conn.socket.send(JSON.stringify({ id: msg.id, sessionId: msg.sessionId, error: { code, message } }));
}
#emit(conn: CdpConnection, method: string, params: Record<string, unknown>, sessionId?: string): void {
conn.socket.send(JSON.stringify({ sessionId, method, params }));
}
#rpc(req: RelayRpcRequest, timeoutMs = RPC_TIMEOUT_MS): Promise<unknown> {
const ext = this.#ext;
if (!ext) return Promise.reject(new Error("relay extension is not connected"));
const id = ++this.#rpcSeq;
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
const timer = setTimeout(() => {
this.#pendingRpc.delete(id);
reject(new Error(`extension rpc '${req.op}' timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.#pendingRpc.set(id, { resolve, reject, timer });
ext.send(JSON.stringify({ t: "rpc", id, ...req } satisfies RelayToExtMessage));
return promise;
}
}
@@ -0,0 +1,117 @@
/**
* Broker-owned browser relay daemon.
*
* The MV3 extension can only dial OUT (service workers cannot listen on
* sockets), so a native process must own the relay port. Instead of making
* the user run `omp browser-relay` by hand, the relay kind lazily starts one
* under a profile-independent, machine-global daemon broker. Every relay
* consumer holds a connection to that broker, so one project exiting cannot
* tear down the fixed-port singleton while another project still uses it.
*
* A manually started relay may already own the port. Consumers still acquire
* the global broker lease before probing, then adopt that external server
* without attempting another bind.
*/
import { logger } from "@oh-my-pi/pi-utils";
import { daemonClientForGlobal } from "../../../launch/client";
import { resolveWorkerSpawnCmd } from "../../../subprocess/worker-client";
import { throwIfAborted } from "../../tool-errors";
import { describeQuietly, stopQuietly, waitReady } from "../shared-daemon";
/** Stable broker daemon name for the relay server. */
export const RELAY_DAEMON_NAME = "omp.browser.relay";
const RELAY_BROKER_SCOPE = "browser-relay";
/** Matches the serve banner (`omp browser relay listening on http://…`). */
const READY_LOG_PATTERN = String.raw`browser relay listening on http://\S+`;
const READY_TIMEOUT_MS = 15_000;
const PROBE_TIMEOUT_MS = 1_500;
/** probe→describe→start rounds; bounds cross-process races and wedged-relay replacement. */
const ENSURE_ATTEMPTS = 3;
/** True when the relay HTTP server answers /json/version at all (200 = extension connected, 503 = waiting for it). */
export async function probeRelayServer(cdpUrl: string): Promise<boolean> {
try {
const res = await fetch(`${cdpUrl}/json/version`, { signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
await res.body?.cancel();
return res.ok || res.status === 503;
} catch {
return false;
}
}
/** Auto-start is only safe for endpoints this machine can own. */
export function isLoopbackRelayUrl(cdpUrl: string): boolean {
try {
const { hostname } = new URL(cdpUrl);
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]" || hostname === "::1";
} catch {
return false;
}
}
/**
* Ensure a relay server answers at `cdpUrl`, starting the broker-owned daemon
* when nothing is serving. Returns true once the HTTP endpoint responds — the
* extension handshake (503 → 200) is the caller's wait. False when the relay
* could not be started (broker unavailable or start rounds exhausted).
*/
export async function ensureRelayDaemon(opts: { cdpUrl: string; signal?: AbortSignal }): Promise<boolean> {
let port: string;
try {
port = String(new URL(opts.cdpUrl).port || 80);
} catch {
return false;
}
// Open the lazy client before probing. Merely caching SocketDaemonClient
// would not create the broker connection (and therefore would hold no lease).
const client = await daemonClientForGlobal(RELAY_BROKER_SCOPE);
throwIfAborted(opts.signal);
await client.request({ op: "ping" }, opts.signal);
if (await probeRelayServer(opts.cdpUrl)) return true;
const spawn = resolveWorkerSpawnCmd("browser-relay");
for (let attempt = 0; attempt < ENSURE_ATTEMPTS; attempt++) {
throwIfAborted(opts.signal);
// A manual serve or concurrent global-broker start may have won the
// port since the last round; adopt it instead of fighting the bind.
if (await probeRelayServer(opts.cdpUrl)) return true;
const existing = await describeQuietly(client, RELAY_DAEMON_NAME, "Browser relay", opts.signal);
if (existing && existing.state !== "exited" && existing.state !== "failed") {
if (existing.readyAt === undefined) await waitReady(client, RELAY_DAEMON_NAME, "Browser relay", opts.signal);
if (await probeRelayServer(opts.cdpUrl)) return true;
// Live record but nothing listening: replace the wedged daemon.
await stopQuietly(client, RELAY_DAEMON_NAME, "Browser relay", opts.signal);
continue;
}
try {
const started = await client.request(
{
op: "start",
spec: {
name: RELAY_DAEMON_NAME,
application: spawn.cmd[0]!,
args: [...spawn.cmd.slice(1), "--port", port],
env: {},
cwd: spawn.cwd ?? client.projectDir,
pty: false,
ready: { log: READY_LOG_PATTERN, timeoutMs: READY_TIMEOUT_MS },
restart: "no",
persist: false,
detached: false,
},
},
opts.signal,
);
if (started.op !== "start") continue;
if (await probeRelayServer(opts.cdpUrl)) return true;
await stopQuietly(client, RELAY_DAEMON_NAME, "Browser relay", opts.signal);
} catch (error) {
throwIfAborted(opts.signal);
// Lost a cross-process start race; the next round adopts the winner.
logger.debug("Browser relay start contention", {
name: RELAY_DAEMON_NAME,
error: error instanceof Error ? error.message : String(error),
});
}
}
return false;
}
@@ -0,0 +1,219 @@
// packages/browser-relay/extension/background.ts
var DEFAULT_PORT = 9224;
var PING_INTERVAL_MS = 20000;
var RECONNECT_MIN_MS = 1000;
var RECONNECT_MAX_MS = 1e4;
var ws = null;
var reconnectDelay = RECONNECT_MIN_MS;
var pingTimer = null;
async function loadSettings() {
const stored = await chrome.storage.local.get({ port: DEFAULT_PORT, token: "" });
const port = Number(stored.port);
return {
port: Number.isInteger(port) && port > 0 && port <= 65535 ? port : DEFAULT_PORT,
token: typeof stored.token === "string" ? stored.token : ""
};
}
function snapshot(tab) {
if (tab.id === undefined)
return null;
return {
tabId: tab.id,
url: tab.url ?? tab.pendingUrl ?? "",
title: tab.title ?? "",
active: tab.active,
windowId: tab.windowId,
pinned: tab.pinned,
groupId: tab.groupId
};
}
var ompGroupTitle = null;
async function groupTabs(tabIds, title, color) {
ompGroupTitle = title;
const byWindow = new Map;
for (const tabId of tabIds) {
try {
const tab = await chrome.tabs.get(tabId);
if (tab.pinned || tab.id === undefined)
continue;
const bucket = byWindow.get(tab.windowId) ?? [];
bucket.push(tab.id);
byWindow.set(tab.windowId, bucket);
} catch {}
}
const grouped = {};
for (const [windowId, ids] of byWindow) {
const existing = await chrome.tabGroups.query({ title, windowId });
const groupId = await chrome.tabs.group(existing[0] ? { tabIds: ids, groupId: existing[0].id } : { tabIds: ids });
await chrome.tabGroups.update(groupId, { title, color });
for (const id of ids)
grouped[String(id)] = groupId;
}
return { grouped };
}
async function restoreGroups() {
if (!ompGroupTitle)
return;
const groups = await chrome.tabGroups.query({ title: ompGroupTitle }).catch(() => []);
for (const group of groups) {
const tabs = await chrome.tabs.query({ groupId: group.id }).catch(() => []);
const ids = tabs.map((tab) => tab.id).filter((id) => id !== undefined);
if (ids.length > 0)
await chrome.tabs.ungroup(ids).catch(() => {});
}
}
function post(msg) {
if (ws?.readyState === WebSocket.OPEN)
ws.send(JSON.stringify(msg));
}
async function setBadge(connected) {
try {
await chrome.action.setBadgeText({ text: connected ? "on" : "off" });
await chrome.action.setBadgeBackgroundColor({ color: connected ? "#1a7f37" : "#8b8b8b" });
} catch {}
}
async function buildHello() {
const [tabs, targets] = await Promise.all([chrome.tabs.query({}), chrome.debugger.getTargets()]);
const snapshots = [];
for (const tab of tabs) {
const snap = snapshot(tab);
if (snap)
snapshots.push(snap);
}
const attachedTabIds = [];
for (const target of targets) {
if (target.attached && target.tabId !== undefined)
attachedTabIds.push(target.tabId);
}
const versionMatch = /Chrome\/[\d.]+/.exec(navigator.userAgent);
return {
t: "hello",
userAgent: navigator.userAgent,
browserVersion: versionMatch?.[0] ?? "Chrome/unknown",
tabs: snapshots,
attachedTabIds
};
}
async function runRpc(msg) {
switch (msg.op) {
case "attach":
await chrome.debugger.attach({ tabId: msg.tabId }, "1.3");
return {};
case "detach":
await chrome.debugger.detach({ tabId: msg.tabId });
return {};
case "send":
return await chrome.debugger.sendCommand(msg.sessionId ? { tabId: msg.tabId, sessionId: msg.sessionId } : { tabId: msg.tabId }, msg.method, msg.params);
case "createTab": {
const tab = await chrome.tabs.create({ url: msg.url });
const snap = snapshot(tab);
if (!snap)
throw new Error("created tab has no id");
return { tab: snap };
}
case "removeTab":
await chrome.tabs.remove(msg.tabId);
return {};
case "activateTab": {
const tab = await chrome.tabs.get(msg.tabId);
await chrome.windows.update(tab.windowId, { focused: true });
await chrome.tabs.update(msg.tabId, { active: true });
return {};
}
case "group":
return await groupTabs(msg.tabIds, msg.title, msg.color);
case "ungroup":
await chrome.tabs.ungroup(msg.tabIds).catch(() => {});
return {};
}
}
function handleRelayMessage(raw) {
let msg;
try {
msg = JSON.parse(raw);
} catch {
return;
}
if (msg.t === "pong")
return;
runRpc(msg).then((result) => post({ t: "rpcResult", id: msg.id, ok: true, result })).catch((err) => {
post({ t: "rpcResult", id: msg.id, ok: false, error: err instanceof Error ? err.message : String(err) });
});
}
function scheduleReconnect() {
const delay = reconnectDelay;
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
setTimeout(() => void connect(), delay);
}
async function connect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING))
return;
const settings = await loadSettings();
const url = `ws://127.0.0.1:${settings.port}/ext${settings.token ? `?token=${encodeURIComponent(settings.token)}` : ""}`;
const socket = new WebSocket(url);
ws = socket;
socket.onopen = () => {
reconnectDelay = RECONNECT_MIN_MS;
setBadge(true);
buildHello().then((hello) => post(hello));
clearInterval(pingTimer ?? undefined);
pingTimer = setInterval(() => post({ t: "ping" }), PING_INTERVAL_MS);
};
socket.onmessage = (event) => {
if (typeof event.data === "string")
handleRelayMessage(event.data);
};
socket.onclose = () => {
if (ws !== socket)
return;
ws = null;
if (pingTimer !== null) {
clearInterval(pingTimer);
pingTimer = null;
}
setBadge(false);
restoreGroups();
scheduleReconnect();
};
socket.onerror = () => {
socket.close();
};
}
chrome.debugger.onEvent.addListener((source, method, params) => {
if (source.tabId === undefined)
return;
post({ t: "cdpEvent", tabId: source.tabId, sessionId: source.sessionId, method, params });
});
chrome.debugger.onDetach.addListener((source, reason) => {
if (source.tabId === undefined)
return;
post({ t: "detached", tabId: source.tabId, reason });
});
chrome.tabs.onCreated.addListener((tab) => {
const snap = snapshot(tab);
if (snap)
post({ t: "tabCreated", tab: snap });
});
chrome.tabs.onUpdated.addListener((_tabId, _changeInfo, tab) => {
const snap = snapshot(tab);
if (snap)
post({ t: "tabUpdated", tab: snap });
});
chrome.tabs.onRemoved.addListener((tabId) => {
post({ t: "tabRemoved", tabId });
});
chrome.alarms.create("omp-relay-keepalive", { periodInMinutes: 0.5 });
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === "omp-relay-keepalive")
connect();
});
chrome.storage.onChanged.addListener((_changes, areaName) => {
if (areaName !== "local")
return;
ws?.close();
connect();
});
chrome.action.onClicked.addListener(() => void chrome.runtime.openOptionsPage());
chrome.runtime.onInstalled.addListener(() => void connect());
chrome.runtime.onStartup.addListener(() => void connect());
connect();
@@ -0,0 +1,14 @@
{
"manifest_version": 3,
"name": "OMP Browser Relay",
"version": "0.1.0",
"description": "Lets the omp coding agent drive your existing tabs through a local CDP relay.",
"permissions": ["debugger", "tabs", "tabGroups", "storage", "alarms"],
"background": {
"service_worker": "background.js"
},
"options_page": "options.html",
"action": {
"default_title": "OMP Browser Relay (click for settings)"
}
}
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>OMP Browser Relay settings</title>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 28rem;
margin: 2rem auto;
padding: 0 1rem;
color: #1f2328;
}
label {
display: block;
margin: 1rem 0 0.25rem;
font-weight: 600;
}
input {
width: 100%;
padding: 0.4rem 0.5rem;
font: inherit;
box-sizing: border-box;
}
button {
margin-top: 1.25rem;
padding: 0.45rem 1.1rem;
font: inherit;
}
#status {
margin-left: 0.75rem;
color: #1a7f37;
}
p.hint {
color: #57606a;
font-size: 0.85rem;
}
</style>
</head>
<body>
<h1>OMP Browser Relay</h1>
<p class="hint">
The extension connects to a local <code>omp-browser-relay</code> process. Values here must match the flags the
relay was started with.
</p>
<label for="port">Relay port</label>
<input id="port" type="number" min="1" max="65535" />
<label for="token">Token (optional)</label>
<input id="token" type="text" autocomplete="off" />
<button id="save">Save</button><span id="status"></span>
<script src="options.js"></script>
</body>
</html>
@@ -0,0 +1,23 @@
// Options page for the OMP Browser Relay extension (plain JS: shipped as-is).
const DEFAULT_PORT = 9224;
const portInput = document.getElementById("port");
const tokenInput = document.getElementById("token");
const status = document.getElementById("status");
chrome.storage.local.get({ port: DEFAULT_PORT, token: "" }).then(stored => {
portInput.value = String(stored.port);
tokenInput.value = String(stored.token);
});
document.getElementById("save").addEventListener("click", async () => {
const port = Number(portInput.value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
status.textContent = "invalid port";
return;
}
await chrome.storage.local.set({ port, token: tokenInput.value });
status.textContent = "saved";
setTimeout(() => {
status.textContent = "";
}, 1500);
});
@@ -0,0 +1,41 @@
/**
* Browser relay mode: drive the user's own Chrome tabs through the local CDP
* relay served by `omp browser-relay` (sibling `server.ts`/`bridge.ts`) plus
* its companion extension (`packages/browser-relay`, installed via
* `omp browser-relay install`). The relay impersonates Chrome's CDP discovery
* endpoint, so beyond kind resolution the entire connected-browser machinery
* (registry, tab supervisor, tab workers) applies unchanged.
*/
import { parseFlag } from "@oh-my-pi/pi-utils";
/** Browser kind selecting the omp browser relay. */
export interface RelayKind {
kind: "relay";
cdpUrl: string;
}
/** Default endpoint of the `omp-browser-relay` CLI. */
export const DEFAULT_RELAY_URL = "http://127.0.0.1:9224";
export interface ResolveRelayKindOptions {
/** `browser.relay` setting; `PI_BROWSER_RELAY=0|1` overrides it. */
settingEnabled?: boolean;
/** `browser.relayUrl` setting; falls back to {@link DEFAULT_RELAY_URL}. */
url?: string;
}
/**
* Resolve the relay browser kind, or null when relay mode is disabled.
* Mirrors `resolveCmuxKind`: the setting opts in, the env var is the final
* override in both directions.
*/
export function resolveRelayKind(
options?: ResolveRelayKindOptions | null,
env: Record<string, string | undefined> = process.env,
): RelayKind | null {
if (!parseFlag(env.PI_BROWSER_RELAY, options?.settingEnabled ?? false)) {
return null;
}
const url = options?.url?.trim() || DEFAULT_RELAY_URL;
return { kind: "relay", cdpUrl: url.replace(/\/+$/, "") };
}
@@ -0,0 +1,54 @@
/**
* Wire protocol between the relay server and the Chrome extension.
*
* The extension dials out to `ws://127.0.0.1:<port>/ext` and exchanges JSON
* messages. The relay drives the extension with numbered RPCs; the extension
* pushes tab lifecycle and `chrome.debugger` events as they happen.
*/
/** Minimal view of a Chrome tab shared between extension and relay. */
export interface TabSnapshot {
tabId: number;
url: string;
title: string;
active: boolean;
windowId: number;
/** Pinned tabs are never grouped (Chrome would silently unpin them). */
pinned: boolean;
/** Chrome tab group id; -1 when ungrouped. */
groupId: number;
}
/** RPCs the relay may ask the extension to perform. */
export type RelayRpcRequest =
| { op: "attach"; tabId: number }
| { op: "detach"; tabId: number }
| { op: "send"; tabId: number; sessionId?: string; method: string; params?: Record<string, unknown> }
| { op: "createTab"; url: string }
| { op: "removeTab"; tabId: number }
| { op: "activateTab"; tabId: number }
/** Add tabs to the per-window omp group (created/reused by title), remembering prior membership. */
| { op: "group"; tabIds: number[]; title: string; color: string }
/** Return tabs to their pre-omp group (or ungroup); no-op for tabs the relay never grouped. */
| { op: "ungroup"; tabIds: number[] };
/** Messages sent relay → extension. */
export type RelayToExtMessage = ({ t: "rpc"; id: number } & RelayRpcRequest) | { t: "pong" };
/** Messages sent extension → relay. */
export type ExtToRelayMessage =
| {
t: "hello";
userAgent: string;
browserVersion: string;
tabs: TabSnapshot[];
/** Tabs that already have a `chrome.debugger` attachment (relay reconciles after a service-worker restart). */
attachedTabIds: number[];
}
| { t: "cdpEvent"; tabId: number; sessionId?: string; method: string; params?: Record<string, unknown> }
| { t: "detached"; tabId: number; reason: string }
| { t: "tabCreated"; tab: TabSnapshot }
| { t: "tabUpdated"; tab: TabSnapshot }
| { t: "tabRemoved"; tabId: number }
| { t: "rpcResult"; id: number; ok: boolean; result?: unknown; error?: string }
| { t: "ping" };
@@ -0,0 +1,141 @@
/**
* 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 controllable tabs 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);
},
};
}
@@ -24,7 +24,7 @@ interface BrowserRenderArgs {
code?: string;
all?: boolean;
kill?: boolean;
app?: { path?: string; cdp_url?: string; target?: string; cmux?: boolean; surface?: string };
app?: { path?: string; cdp_url?: string; relay?: boolean; target?: string; cmux?: boolean; surface?: string };
viewport?: { width: number; height: number; scale?: number };
timeout?: number;
}
@@ -39,6 +39,7 @@ function describeBrowser(args: BrowserRenderArgs, details: BrowserToolDetails |
if (cdpUrl) return `connected ${cdpUrl}`;
const appPath = typeof args.app?.path === "string" ? args.app.path : "";
if (appPath) return `spawned ${shortenPath(appPath)}`;
if (args.app?.relay) return "relay";
if (args.app?.cmux !== false && (args.app?.cmux === true || args.app?.surface)) {
return args.app.surface ? `cmux ${args.app.surface}` : "cmux";
}
@@ -49,6 +50,8 @@ function describeBrowser(args: BrowserRenderArgs, details: BrowserToolDetails |
return "spawned";
case "connected":
return "connected";
case "relay":
return "relay";
case "cmux":
return "cmux";
default:
@@ -59,10 +59,11 @@ async function probeEndpoint(wsEndpoint: string): Promise<boolean> {
}
}
/** Snapshot the daemon, treating "unknown daemon" as absent. */
async function describeQuietly(
/** Snapshot a broker daemon, treating "unknown daemon" and broker errors as absent. */
export async function describeQuietly(
client: DaemonBrokerClient,
name: string,
label: string,
signal?: AbortSignal,
): Promise<DaemonSnapshot | undefined> {
try {
@@ -70,7 +71,7 @@ async function describeQuietly(
return result.op === "describe" ? result.daemon : undefined;
} catch (error) {
throwIfAborted(signal);
logger.debug("Shared browser describe failed", {
logger.debug(`${label} describe failed`, {
name,
error: error instanceof Error ? error.message : String(error),
});
@@ -79,9 +80,10 @@ async function describeQuietly(
}
/** Block until the daemon reports ready; undefined on timeout or pre-ready exit. */
async function waitReady(
export async function waitReady(
client: DaemonBrokerClient,
name: string,
label: string,
signal?: AbortSignal,
): Promise<DaemonSnapshot | undefined> {
try {
@@ -90,7 +92,7 @@ async function waitReady(
return result.daemon;
} catch (error) {
throwIfAborted(signal);
logger.debug("Shared browser ready wait failed", {
logger.debug(`${label} ready wait failed`, {
name,
error: error instanceof Error ? error.message : String(error),
});
@@ -99,12 +101,17 @@ async function waitReady(
}
/** Best-effort stop before replacing a wedged or endpoint-less daemon. */
async function stopQuietly(client: DaemonBrokerClient, name: string, signal?: AbortSignal): Promise<void> {
export async function stopQuietly(
client: DaemonBrokerClient,
name: string,
label: string,
signal?: AbortSignal,
): Promise<void> {
try {
await client.request({ op: "stop", name, timeoutMs: STOP_TIMEOUT_MS }, signal);
} catch (error) {
throwIfAborted(signal);
logger.debug("Shared browser stop failed", {
logger.debug(`${label} stop failed`, {
name,
error: error instanceof Error ? error.message : String(error),
});
@@ -139,16 +146,17 @@ export async function ensureSharedBrowser(opts: {
await fs.mkdir(userDataDir, { recursive: true });
for (let attempt = 0; attempt < ENSURE_ATTEMPTS; attempt++) {
throwIfAborted(opts.signal);
const existing = await describeQuietly(client, name, opts.signal);
const existing = await describeQuietly(client, name, "Shared browser", opts.signal);
if (existing && existing.state !== "exited" && existing.state !== "failed") {
const settled = existing.readyAt !== undefined ? existing : await waitReady(client, name, opts.signal);
const settled =
existing.readyAt !== undefined ? existing : await waitReady(client, name, "Shared browser", opts.signal);
const wsEndpoint = wsEndpointOf(settled);
if (wsEndpoint && (await probeEndpoint(wsEndpoint))) {
return { wsEndpoint, daemonName: name, projectDir: client.projectDir };
}
// Live record but unreachable Chrome (wedged, or readiness never
// matched): replace it rather than handing out a dead endpoint.
await stopQuietly(client, name, opts.signal);
await stopQuietly(client, name, "Shared browser", opts.signal);
continue;
}
try {
@@ -175,7 +183,7 @@ export async function ensureSharedBrowser(opts: {
if (wsEndpoint && (await probeEndpoint(wsEndpoint))) {
return { wsEndpoint, daemonName: name, projectDir: client.projectDir };
}
await stopQuietly(client, name, opts.signal);
await stopQuietly(client, name, "Shared browser", opts.signal);
} catch (error) {
throwIfAborted(opts.signal);
// Lost a cross-process start race ("already starting/ready"); the next
@@ -698,10 +698,11 @@ async function buildInitPayload(browser: PuppeteerBrowserHandle, opts: AcquireTa
timeoutMs: opts.timeoutMs,
};
}
// A connected browser is user-driven. When no target is requested, adopt its
// visible tab and avoid raising it before screenshots. An explicit target may
// be backgrounded, so retain activation to guarantee target-correct pixels.
const activateForScreenshot = browser.kind.kind !== "connected" || !shouldPreserveConnectedBrowserFocus(opts.target);
// Connected and relay browsers are user-driven. When no target is requested,
// adopt the visible tab and avoid raising it before screenshots. An explicit
// target may be backgrounded, so retain activation for target-correct pixels.
const userDriven = browser.kind.kind === "connected" || browser.kind.kind === "relay";
const activateForScreenshot = !userDriven || !shouldPreserveConnectedBrowserFocus(opts.target);
const page = await pickElectronTarget(browser.browser, {
matcher: opts.target,
preferVisible: !activateForScreenshot,
@@ -0,0 +1,103 @@
import { describe, expect, it } from "bun:test";
import { RelayBridge, type RelaySocket } from "@oh-my-pi/pi-coding-agent/tools/browser/relay/bridge";
import type { RelayToExtMessage, TabSnapshot } from "@oh-my-pi/pi-coding-agent/tools/browser/relay/protocol";
class FakeExtSocket implements RelaySocket {
readonly messages: RelayToExtMessage[] = [];
send(text: string): void {
this.messages.push(JSON.parse(text) as RelayToExtMessage);
}
close(): void {}
rpcs(op: string): Array<Extract<RelayToExtMessage, { t: "rpc" }>> {
return this.messages.filter(
(msg): msg is Extract<RelayToExtMessage, { t: "rpc" }> => msg.t === "rpc" && msg.op === op,
);
}
}
function tab(overrides: Partial<TabSnapshot> & { tabId: number }): TabSnapshot {
return {
url: "https://example.com/",
title: "Example",
active: false,
windowId: 1,
pinned: false,
groupId: -1,
...overrides,
};
}
function connect(bridge: RelayBridge, socket: FakeExtSocket, tabs: TabSnapshot[]): void {
bridge.extConnected(socket);
bridge.extMessage(
socket,
JSON.stringify({
t: "hello",
userAgent: "test",
browserVersion: "Chrome/151.0.0.0",
tabs,
attachedTabIds: [],
}),
);
}
describe("RelayBridge tab grouping", () => {
it("groups only controllable, unpinned, ungrouped tabs on hello", () => {
const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } });
const socket = new FakeExtSocket();
connect(bridge, socket, [
tab({ tabId: 1 }),
tab({ tabId: 2, url: "chrome://settings/" }),
tab({ tabId: 3, pinned: true }),
tab({ tabId: 4, groupId: 77 }), // already in a user group
tab({ tabId: 5, url: "about:blank" }),
]);
const groups = socket.rpcs("group");
expect(groups).toHaveLength(1);
const group = groups[0]! as { tabIds: number[]; title: string; color: string };
expect(group.tabIds.toSorted()).toEqual([1, 5]);
expect(group.title).toBe("omp");
expect(group.color).toBe("cyan");
});
it("does not issue group RPCs when grouping is disabled", () => {
const bridge = new RelayBridge({});
const socket = new FakeExtSocket();
connect(bridge, socket, [tab({ tabId: 1 })]);
expect(socket.rpcs("group")).toHaveLength(0);
});
it("never re-groups a tab the user pulled out of the omp group", async () => {
const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } });
const socket = new FakeExtSocket();
connect(bridge, socket, [tab({ tabId: 1 })]);
const first = socket.rpcs("group")[0]!;
bridge.extMessage(
socket,
JSON.stringify({ t: "rpcResult", id: first.id, ok: true, result: { grouped: { "1": 42 } } }),
);
// Flush the rpc .then() microtask chain (no timers involved).
await Promise.resolve();
await Promise.resolve();
// Chrome reports the grouping we just made — no opt-out.
bridge.extMessage(socket, JSON.stringify({ t: "tabUpdated", tab: tab({ tabId: 1, groupId: 42 }) }));
// The user drags the tab out of the group.
bridge.extMessage(socket, JSON.stringify({ t: "tabUpdated", tab: tab({ tabId: 1, groupId: -1 }) }));
// A later navigation on the now-ungrouped tab must not re-group it.
bridge.extMessage(
socket,
JSON.stringify({ t: "tabUpdated", tab: tab({ tabId: 1, groupId: -1, url: "https://example.com/other" }) }),
);
expect(socket.rpcs("group")).toHaveLength(1);
});
it("groups a newly created controllable tab", () => {
const bridge = new RelayBridge({ group: { title: "omp", color: "cyan" } });
const socket = new FakeExtSocket();
connect(bridge, socket, []);
bridge.extMessage(socket, JSON.stringify({ t: "tabCreated", tab: tab({ tabId: 9 }) }));
const groups = socket.rpcs("group");
expect(groups).toHaveLength(1);
expect((groups[0] as unknown as { tabIds: number[] }).tabIds).toEqual([9]);
});
});
@@ -0,0 +1,109 @@
import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { createDaemonBrokerClient } from "../../src/launch/client";
import { findFreeCdpPort } from "../../src/tools/browser/attach";
import { probeRelayServer } from "../../src/tools/browser/relay/daemon";
async function waitUntil(condition: () => boolean | Promise<boolean>, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await condition()) return true;
await Bun.sleep(50);
}
return condition();
}
describe("browser relay daemon", () => {
it("stays alive while a consumer in another project holds the global broker lease", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "omp-relay-global-"));
const firstProject = path.join(home, "project-a");
const secondProject = path.join(home, "project-b");
const firstMarker = path.join(home, "first-ready");
const secondMarker = path.join(home, "second-ready");
const globalRuntimeDir = path.join(home, ".omp", "run", "daemons", "global", "browser-relay");
const cdpUrl = `http://127.0.0.1:${await findFreeCdpPort()}`;
const scriptPath = path.join(home, "consumer.ts");
await Promise.all([fs.mkdir(firstProject), fs.mkdir(secondProject)]);
await Bun.write(
scriptPath,
`
import { closeDaemonClients } from ${JSON.stringify(path.resolve(import.meta.dir, "../../src/launch/client.ts"))};
import { ensureRelayDaemon } from ${JSON.stringify(path.resolve(import.meta.dir, "../../src/tools/browser/relay/daemon.ts"))};
const cdpUrl = process.env.OMP_TEST_RELAY_URL;
const marker = process.env.OMP_TEST_READY_MARKER;
if (!cdpUrl || !marker) throw new Error("relay consumer environment is incomplete");
try {
if (!(await ensureRelayDaemon({ cdpUrl }))) throw new Error("relay did not start");
await Bun.write(marker, "ready");
const stopped = Promise.withResolvers<void>();
process.stdin.once("end", () => stopped.resolve());
process.stdin.resume();
await stopped.promise;
} finally {
await closeDaemonClients();
}
`,
);
const spawnConsumer = (cwd: string, profile: string, marker: string) =>
Bun.spawn([process.execPath, scriptPath], {
cwd,
env: {
...process.env,
HOME: home,
USERPROFILE: home,
PI_CONFIG_DIR: ".omp",
OMP_PROFILE: profile,
OMP_DAEMON_IDLE_GRACE_MS: "200",
OMP_TEST_RELAY_URL: cdpUrl,
OMP_TEST_READY_MARKER: marker,
},
stdin: "pipe",
stdout: "ignore",
stderr: "pipe",
});
const first = spawnConsumer(firstProject, "profile-a", firstMarker);
try {
expect(await waitUntil(() => Bun.file(firstMarker).exists(), 15_000)).toBeTrue();
expect(await probeRelayServer(cdpUrl)).toBeTrue();
const second = spawnConsumer(secondProject, "profile-b", secondMarker);
try {
expect(await waitUntil(() => Bun.file(secondMarker).exists(), 15_000)).toBeTrue();
first.stdin.end();
const firstExit = await first.exited;
if (firstExit !== 0) throw new Error(await new Response(first.stderr).text());
// The global broker's real idle clock must pass while the second client remains connected.
await Bun.sleep(500);
expect(await probeRelayServer(cdpUrl)).toBeTrue();
second.stdin.end();
const secondExit = await second.exited;
if (secondExit !== 0) throw new Error(await new Response(second.stderr).text());
expect(await waitUntil(async () => !(await probeRelayServer(cdpUrl)), 5_000)).toBeTrue();
} finally {
if (second.exitCode === null) second.kill();
await second.exited;
}
} finally {
if (first.exitCode === null) first.kill();
await first.exited;
const rescue = await createDaemonBrokerClient(globalRuntimeDir, {
runtimeDir: globalRuntimeDir,
idleGraceMs: 200,
});
try {
await rescue.request({ op: "shutdown" });
} catch {
// The last-client grace may already have stopped the broker.
}
rescue.close();
await fs.rm(home, { recursive: true, force: true });
}
}, 30_000);
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "bun:test";
import { DEFAULT_RELAY_URL, resolveRelayKind } from "@oh-my-pi/pi-coding-agent/tools/browser";
describe("resolveRelayKind", () => {
it("is disabled by default", () => {
expect(resolveRelayKind(null, {})).toBeNull();
expect(resolveRelayKind({}, {})).toBeNull();
});
it("resolves the default endpoint when the setting enables it", () => {
expect(resolveRelayKind({ settingEnabled: true }, {})).toEqual({ kind: "relay", cdpUrl: DEFAULT_RELAY_URL });
});
it("uses the configured URL and trims trailing slashes", () => {
expect(resolveRelayKind({ settingEnabled: true, url: "http://127.0.0.1:9333///" }, {})).toEqual({
kind: "relay",
cdpUrl: "http://127.0.0.1:9333",
});
});
it("falls back to the default endpoint for a blank URL", () => {
expect(resolveRelayKind({ settingEnabled: true, url: " " }, {})).toEqual({
kind: "relay",
cdpUrl: DEFAULT_RELAY_URL,
});
});
it("PI_BROWSER_RELAY=0 disables the relay even when the setting enables it", () => {
expect(resolveRelayKind({ settingEnabled: true }, { PI_BROWSER_RELAY: "0" })).toBeNull();
});
it("PI_BROWSER_RELAY=1 enables the relay when the setting is off", () => {
expect(resolveRelayKind({ settingEnabled: false }, { PI_BROWSER_RELAY: "1" })).toEqual({
kind: "relay",
cdpUrl: DEFAULT_RELAY_URL,
});
});
});
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Added
- Added `parseFlag()` (boolean-ish env value parsing backing `$flag()`), `getBrowserRelayDir()` (~/.omp/browser-relay, XDG data category), and `getGlobalDaemonRuntimeDir()` (profile-independent broker state) for the browser relay mode.
## [17.2.4] - 2026-08-01
### Added
+13
View File
@@ -627,6 +627,11 @@ export function getPuppeteerDir(): string {
return dirs.rootSubdir("puppeteer", "cache");
}
/** Get the browser relay extension install directory (~/.omp/browser-relay). */
export function getBrowserRelayDir(): string {
return dirs.rootSubdir("browser-relay", "data");
}
/** Get DOCS_RS cache directory () */
export function getDocsRsCacheDir(): string {
return dirs.rootSubdir("webcache", "cache");
@@ -852,6 +857,14 @@ export function getDaemonRuntimeDir(projectDir: string): string {
return dirs.rootSubdir(path.join("run", "daemons", key), "state");
}
/** Get a profile-independent runtime directory for a machine-global daemon service. */
export function getGlobalDaemonRuntimeDir(service: string): string {
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(service)) {
throw new Error(`Invalid global daemon service name: ${JSON.stringify(service)}`);
}
return path.join(getBaseConfigRoot(), "run", "daemons", "global", service);
}
/** Get the provider in-flight root directory (~/.omp/run/provider-inflight; XDG default: $XDG_STATE_HOME/omp/run/provider-inflight). */
export function getProviderInFlightRoot(): string {
return dirs.rootSubdir(path.join("run", "provider-inflight"), "state");
+6 -2
View File
@@ -360,8 +360,12 @@ const TRUTHY: Dict<boolean> = {
ON: true,
on: true,
};
export function $flag(name: string, def: boolean = false): boolean {
const value = $env[name];
/** Parse a boolean-ish env value ("1", "yes", "on", …); `def` when unset/empty. */
export function parseFlag(value: string | undefined, def = false): boolean {
if (!value) return def;
return TRUTHY[value] === true;
}
export function $flag(name: string, def: boolean = false): boolean {
return parseFlag($env[name], def);
}