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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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)`);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../tsconfig.workspace.json",
|
||||
"include": ["extension", "scripts"]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user