browser, extracted from the platform — extension, relay and all

Your own Chrome, driven from Officer. Not a headless one: it attaches to the browser you are already
signed into, which is the whole difference from /scrape, and /scrape stays behind in the platform.

The relay is a sidecar now, and that is the point. It was a second Bun.serve inside the platform
process on its own port, which is exactly why it was switched off on 2026-08-13 — "the platform serves
one port". A sidecar is a process allowed its own listeners, so the objection stops applying instead of
being argued with.

The boundary across it is deliberately small: two questions, who is connected and what tabs they have,
because only those live in memory on the relay side. Screenshots, evaluation and navigation still go
straight down cdp.ts's own WebSocket to the relay's /cdp endpoint, exactly as before — routing page
bytes through the platform would be work for nothing.

Three normally-breaking changes taken now because now they are free, each already recommended in a
comment by whoever switched the relay off. Its own signing key instead of borrowing getKey('jwt'). Its
own table instead of the platform's user_integrations. And the port kept at 18792 on purpose, because
the port is an input to the token HMAC as well as an address — moving it fails every pairing silently,
and the extension blames the address. user_integrations held zero browser-relay rows, checked in
Postgres rather than assumed, so there was nothing to unpair.

The screen became panels. BrowserScreen.tsx rendered TabList and TabPreview through appType: null
placeholders; both components were already self-contained and took no props, so the screen had nothing
to pass and simply went.

Not tested end to end — no browser has been paired since the move, and that needs a Chrome and a
person. BLOCKERS.md says so rather than the commit implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 01:04:09 +00:00
co-authored by Claude Opus 5
commit 52934f52dc
30 changed files with 3275 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
node_modules/
+38
View File
@@ -0,0 +1,38 @@
# Blockers — browser extraction, 2026-08-21
## 1. Three breaking changes taken now, because now they are free
The relay has been switched off since 2026-08-13 and `user_integrations` held **zero** `browser-relay`
rows — checked in Postgres, not assumed. Nothing is paired, so nothing can be unpaired. Each of these
would cost somebody their pairing on any other day, and each was already recommended in a comment by
whoever switched the relay off:
- **the signing key** — tokens derived from `getKey('jwt')`, the platform's session key. Now
`getKey('browser-relay')`. The old file said to do this on extraction, in those words.
- **the storage** — pairing salts lived in the platform's `user_integrations`. Now `browser_pairings`,
this plugin's own table.
- **the port** — kept at 18792 deliberately, so an extension configured before the extraction still works.
It is an input to the token HMAC as well as an address; changing it is silent and the extension reports
it as "Relay not reachable", which SETUP.md blames on a wrong address.
If any of this is revisited later, it is no longer free.
## 2. The extension is unbranded beyond its name
`extension/manifest.json` is "Officer Browser Relay" with the icons it shipped with. The OfficerIcons
repository has newer artwork and a naming convention (`OffBrowserAppIcon.png`) that this predates. Left
alone rather than half-applied — the icons are a set, and the owner has more of that repository coming.
## 3. Untested end to end
The relay ran as a second listener inside the platform and now runs as a sidecar. The rewiring is small —
two questions cross the boundary and CDP still goes direct over its own socket — but **no browser has been
paired against it since the move**. What is verified: it typechecks, the plugin passes discovery, the
schema pushed, and the platform's test suite is green. What is not: an extension actually connecting,
which needs a Chrome and a person.
## 4. `/scrape` stayed, and shares a word rather than a mechanism
The platform keeps `browser` as an `execution` permission covering `/scrape` — headless chromium via
playwright, running as the owner, no sessions. Renamed to "Scraping" in the registry so the two stop
looking like one feature split in half. They were never the same thing.
+54
View File
@@ -0,0 +1,54 @@
# Browser
Your own browser, driven from Officer. Not a headless one — the browser you are already signed into.
## The shape
```
extension/ the Chrome extension: attaches the debugger to your tabs, talks to the relay
sidecar/ the relay it connects to, on port 18792, plus the CDP bridge
api/ pairing, status, and the target verbs
db/ browser_pairings — one salt per account
web/ the tab list and the preview panel
```
## Why the relay is a sidecar now
It was a second `Bun.serve` inside the platform process, on its own port. That is precisely why it was
switched off on 2026-08-13, with the note "the platform serves one port".
A sidecar is a process allowed its own listeners, so the objection stops applying rather than being
argued with. Two listeners live there: the extension's WebSocket, and the registration socket back to the
platform that `api/router.ts` asks questions over.
**The boundary is deliberately small.** Only two things cross it — who is connected, and what tabs they
have — because only those live in memory on the relay side. Screenshots, evaluation and navigation still
go straight down `cdp.ts`'s own WebSocket to the relay's `/cdp` endpoint, exactly as before. Routing page
bytes through the platform would be work for nothing.
## The port is a credential, not just an address
`deriveRelayToken` is `HMAC(key, 'officer-browser-relay-v1:${port}:${userId}:${salt}')`. The port is an
**input**. Move it and every paired browser fails authentication, reporting "Relay not reachable" — which
SETUP.md attributes to a wrong address or token, so the real cause is invisible.
It therefore cannot be kernel-assigned the way other sidecars' listeners are: the extension is configured
by hand and stores the number. 18792 is what it was before the extraction and what it stays.
## Per-account, unlike desktop
The desktop plugin mirrors one physical screen and is owner-only because there is only one and it is the
owner's. This is the opposite: each account pairs **its own** browser, the relay resolves an extension's
token to the account that minted it, and two people on one server drive two different browsers. So the
permission is ordinarily grantable.
## What changed on the way out
Three things that are normally breaking and were free exactly once — see `BLOCKERS.md` §1. Short version:
its own signing key, its own table, and the same port on purpose.
## Iris
`plugins/iris` captures pages using a browser profile at `~/.config/officer/browser-profile`. That is the
**desktop** plugin's browser, not this one — iris drives a browser it launches itself, while this attaches
to one a person is using. They are neighbours, not layers.
+19
View File
@@ -0,0 +1,19 @@
# Browser
Drive your own browser from Officer — your tabs, your sessions, your logins.
This is not a scraper. It attaches to the Chrome you are already using, so a page behind a login is a page
Officer can see, because *you* are signed in to it. Ask an agent about what is on your screen, have it read
a tab, navigate, or take a screenshot of something no headless browser could reach.
**You install an extension.** The plugin ships it; Chrome loads it from disk, and you paste one address and
one token from Settings to pair. `extension/SETUP.md` has the steps.
**Each account pairs its own browser.** Two people on one server drive two different browsers, and neither
can see the other's tabs.
**Not yet tested end to end.** The relay moved from inside the platform to its own process as part of this
extraction, and no browser has been paired against it since. It typechecks and installs; whether an
extension connects is the next thing to find out. `BLOCKERS.md` is honest about the rest.
Needs no host packages — the relay is Bun, and Chrome is on whatever machine you are sitting at.
+198
View File
@@ -0,0 +1,198 @@
import { randomUUID } from 'node:crypto';
import { createRouter } from '@@/create-router';
import * as errors from '@@/custom-errors';
import { sendSidecarCommand } from '@@/sidecar-registry';
import { getPairingSalt, setPairingSalt, clearPairing } from '../db/queries';
import { deriveRelayToken } from '../sidecar/relay-auth';
import { captureScreenshot, evaluateJS, navigateTo } from '../sidecar/cdp';
import { BROWSER_SIDECAR_KIND, RELAY_PORT, type BrowserEvent } from '../sidecar/protocol';
export const router = createRouter();
/**
* Ask the relay process something, and unwrap its reply.
*
* Only the two questions whose answer is in-memory on the relay side go this way — who is connected, and
* what tabs they have. Everything else the CDP socket already does directly.
*/
async function ask<T extends BrowserEvent['type']>(
type: 'relay:status' | 'relay:targets' | 'relay:token-add' | 'relay:token-remove',
payload: Record<string, unknown>,
): Promise<Extract<BrowserEvent, { type: T }>> {
const reply = await sendSidecarCommand<BrowserEvent>(BROWSER_SIDECAR_KIND, { type, id: randomUUID(), ...payload });
if (reply.type === 'error') throw errors.INTERNAL_SERVER_ERROR(reply.error);
return reply as Extract<BrowserEvent, { type: T }>;
}
/**
* The port and token for a direct CDP connection.
*
* Derived HERE as well as in the relay, from the same key and the same inputs — not fetched. A token that
* crossed the sidecar channel would be a live credential in whatever traced those messages, and deriving
* is cheaper than asking anyway.
*
* Registers the token with the relay on the way past, because a token nothing has registered is one the
* relay will refuse — that is what `relay:token-add` is for.
*/
async function getOpts(userId: number) {
let salt = await getPairingSalt(userId);
if (!salt) {
salt = randomUUID();
await setPairingSalt(userId, salt);
}
await ask('relay:token-add', { userId, salt });
return { relayPort: RELAY_PORT, userToken: deriveRelayToken(userId, RELAY_PORT, salt) };
}
// GET /extension-download used to zip the extension directory on the fly, shelling out to `zip`. It had
// no caller: BrowserRelay.tsx links straight at /browser-relay-extension.zip, a real file in public/.
router.get('/status', async (ctx) => {
const user = ctx.get('user');
const { extensionConnected, targetCount } = await ask<'relay:status'>('relay:status', { userId: user.id });
return ctx.json({ extensionConnected, targetCount });
});
// The pair of values the extension is configured with. Shown as often as asked — unlike an API key, this
// is derived from a stored salt rather than generated, so there is nothing lost by showing it twice.
router.get('/relay-token', async (ctx) => {
const user = ctx.get('user');
let salt = await getPairingSalt(user.id);
if (!salt) {
salt = randomUUID();
await setPairingSalt(user.id, salt);
}
const { token, port } = await ask<'relay:token'>('relay:token-add', { userId: user.id, salt });
return ctx.json({ token, port });
});
// Revoke and re-issue. The OLD token is unregistered first: the salt row is replaced by the write below,
// so if this ran the other way round the relay would keep accepting the old one until it restarted.
router.post('/relay-token/regenerate', async (ctx) => {
const user = ctx.get('user');
const oldSalt = await getPairingSalt(user.id);
if (oldSalt) await ask('relay:token-remove', { userId: user.id, salt: oldSalt });
const newSalt = randomUUID();
await setPairingSalt(user.id, newSalt);
const { token, port } = await ask<'relay:token'>('relay:token-add', { userId: user.id, salt: newSalt });
return ctx.json({ token, port });
});
router.delete('/relay-token', async (ctx) => {
const user = ctx.get('user');
const salt = await getPairingSalt(user.id);
if (salt) {
await ask('relay:token-remove', { userId: user.id, salt });
await clearPairing(user.id);
}
return ctx.json({ ok: true });
});
router.get('/targets', async (ctx) => {
const user = ctx.get('user');
const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id });
return ctx.json(
targets.map((t) => ({
id: t.targetId,
sessionId: t.sessionId,
type: t.targetInfo.type ?? 'page',
title: t.targetInfo.title ?? '',
url: t.targetInfo.url ?? '',
})),
);
});
router.get('/targets/:id/screenshot', async (ctx) => {
const user = ctx.get('user');
const targetId = ctx.req.param('id');
const format = (ctx.req.query('format') as 'png' | 'jpeg') || 'png';
const opts = await getOpts(user.id);
// The session id, not the target id — CDP addresses a session. Asked of the relay, which is where the
// live map is; this used to be an in-process lookup.
const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id });
const target = targets.find((t) => t.targetId === targetId);
if (!target) throw errors.NOT_FOUND('Target not found');
try {
const data = await captureScreenshot({ ...opts, targetId: target.sessionId }, format);
return ctx.json({ data, format });
} catch (err) {
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Screenshot failed');
}
});
router.post('/targets/:id/evaluate', async (ctx) => {
const user = ctx.get('user');
const targetId = ctx.req.param('id');
const body = ctx.get('body') as { expression?: string };
if (!body.expression) throw errors.BAD_REQUEST('expression is required');
const opts = await getOpts(user.id);
const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id });
const target = targets.find((t) => t.targetId === targetId);
if (!target) throw errors.NOT_FOUND('Target not found');
try {
const result = await evaluateJS({ ...opts, targetId: target.sessionId }, body.expression);
return ctx.json({ result });
} catch (err) {
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Evaluation failed');
}
});
router.post('/targets/:id/navigate', async (ctx) => {
const user = ctx.get('user');
const targetId = ctx.req.param('id');
const body = ctx.get('body') as { url?: string };
if (!body.url) throw errors.BAD_REQUEST('url is required');
const opts = await getOpts(user.id);
const { targets } = await ask<'relay:targets'>('relay:targets', { userId: user.id });
const target = targets.find((t) => t.targetId === targetId);
if (!target) throw errors.NOT_FOUND('Target not found');
try {
await navigateTo({ ...opts, targetId: target.sessionId }, body.url);
return ctx.json({ ok: true });
} catch (err) {
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Navigation failed');
}
});
router.post('/targets/:id/activate', async (ctx) => {
const user = ctx.get('user');
const targetId = ctx.req.param('id');
const { relayPort, userToken } = await getOpts(user.id);
try {
const res = await fetch(`http://127.0.0.1:${relayPort}/json/activate/${encodeURIComponent(targetId)}`, {
headers: { 'x-officer-relay-token': userToken },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return ctx.json({ ok: true });
} catch (err) {
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Activate failed');
}
});
router.post('/targets/:id/close', async (ctx) => {
const user = ctx.get('user');
const targetId = ctx.req.param('id');
const { relayPort, userToken } = await getOpts(user.id);
try {
const res = await fetch(`http://127.0.0.1:${relayPort}/json/close/${encodeURIComponent(targetId)}`, {
headers: { 'x-officer-relay-token': userToken },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return ctx.json({ ok: true });
} catch (err) {
throw errors.INTERNAL_SERVER_ERROR(err instanceof Error ? err.message : 'Close failed');
}
});
+32
View File
@@ -0,0 +1,32 @@
import { eq } from 'drizzle-orm';
import { db } from 'officerdb/db';
import { browserPairings } from './schema';
/** The caller's pairing salt, or null when they have never paired. */
export async function getPairingSalt(userId: number): Promise<string | null> {
const [row] = await db.select({ salt: browserPairings.salt }).from(browserPairings).where(eq(browserPairings.userId, userId));
return row?.salt ?? null;
}
/**
* Store a salt for this account, replacing any previous one.
*
* Upsert rather than insert-or-check: the unique index is what guarantees one pairing per account, and
* doing it in a single statement means a regenerate cannot race itself into two rows.
*/
export async function setPairingSalt(userId: number, salt: string): Promise<void> {
await db
.insert(browserPairings)
.values({ userId, salt })
.onConflictDoUpdate({ target: browserPairings.userId, set: { salt } });
}
/** Forget the pairing. The old token stops resolving as soon as the sidecar is told. */
export async function clearPairing(userId: number): Promise<void> {
await db.delete(browserPairings).where(eq(browserPairings.userId, userId));
}
/** Every pairing, for the sidecar to rebuild its token map on start. */
export async function allPairings(): Promise<{ userId: number; salt: string }[]> {
return db.select({ userId: browserPairings.userId, salt: browserPairings.salt }).from(browserPairings);
}
+30
View File
@@ -0,0 +1,30 @@
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from 'officerdb/schema';
// One row per paired browser: the salt its token is derived from, and nothing else.
//
// ── Why this is not `user_integrations` ──
//
// It was, under provider `browser-relay`. That put a plugin's data in a platform table, which is the same
// coupling the extraction removes everywhere else — and it meant the platform's schema carried a row
// shape whose meaning lived in code it does not own.
//
// Moving it is normally a breaking change, because the salt IS the pairing: lose it and every browser
// re-pairs. It is free right now and only right now — the relay has been switched off since 2026-08-13
// and `user_integrations` holds zero `browser-relay` rows, checked before this table was written. Doing
// it later would cost somebody their paired browsers.
export const browserPairings = pgTable(
'browser_pairings',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
/** Random per pairing. The token is HMAC(key, `…:${port}:${userId}:${salt}`) — see sidecar/relay-auth.ts. */
salt: text('salt').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
// One pairing per account. Regenerating replaces the salt rather than adding a second row, which is
// what makes "regenerate" actually revoke the old token instead of leaving two valid.
(table) => [uniqueIndex('uq_browser_pairings_user').on(table.userId)],
);
+45
View File
@@ -0,0 +1,45 @@
# Officer Browser Relay — Setup Guide
## Prerequisites
- Officer server running (`bun dev`)
- Google Chrome or Chromium-based browser on your machine
- Both machines on the same Tailscale network (or localhost for local dev)
## Steps
### 1. Verify relay is running
Check the server logs for `[browser-relay] listening on port 18792`.
### 2. Get your relay credentials
Open Officer dashboard → **Browser** screen (or `/browser`).
The setup card shows your **server address**, **port**, and **relay token**.
### 3. Load the extension in Chrome
1. Open `chrome://extensions`
2. Enable **Developer mode** (top-right toggle)
3. Click **Load unpacked**
4. Select `src/extensions/browser-relay/`
### 4. Configure the extension
1. The options page opens automatically after install (or right-click extension → Options)
2. Enter the **Server address** (Officer's IP — e.g. your Tailscale IP, or `127.0.0.1` for local)
3. Enter the **Port** (default `18792`)
4. Paste your **Relay token** from step 2
5. Click **Save** — status should show green "Relay reachable and authenticated"
### 5. Attach a tab
1. Navigate to any webpage in Chrome
2. Click the Officer Browser Relay extension icon in the toolbar
3. Badge shows **ON** (cyan) = attached
### 6. Use from the dashboard
1. Open `/browser` in Officer
2. Your attached tab appears in the left panel
3. Click it to see a live screenshot in the right panel
4. Use the URL bar to navigate, or the JS console to evaluate expressions
## Troubleshooting
- **Red `!` badge**: Relay not reachable — check server address/port/token
- **`…` badge**: Reconnecting — relay was temporarily unavailable
- **Tab not appearing**: Make sure the extension badge shows ON, and refresh the dashboard
- **Screenshot blank**: The tab may be on a `chrome://` page (not supported by CDP)
+51
View File
@@ -0,0 +1,51 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
export function reconnectDelayMs(
attempt,
opts = { baseMs: 1000, maxMs: 30000, jitterMs: 1000, random: Math.random },
) {
const baseMs = Number.isFinite(opts.baseMs) ? opts.baseMs : 1000;
const maxMs = Number.isFinite(opts.maxMs) ? opts.maxMs : 30000;
const jitterMs = Number.isFinite(opts.jitterMs) ? opts.jitterMs : 1000;
const random = typeof opts.random === "function" ? opts.random : Math.random;
const safeAttempt = Math.max(0, Number.isFinite(attempt) ? attempt : 0);
const backoff = Math.min(baseMs * 2 ** safeAttempt, maxMs);
return backoff + Math.max(0, jitterMs) * random();
}
export async function deriveRelayToken(gatewayToken, port) {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(gatewayToken),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sig = await crypto.subtle.sign(
"HMAC",
key,
enc.encode(`officer-browser-relay-v1:${port}`),
);
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function buildRelayWsUrl(host, port, gatewayToken) {
const token = String(gatewayToken || "").trim();
if (!token) {
throw new Error(
"Missing relay token in extension settings (chrome.storage.local.relayToken)",
);
}
// Token is already derived server-side — use it directly
return `ws://${host}:${port}/extension?token=${encodeURIComponent(token)}`;
}
export function isRetryableReconnectError(err) {
const message = err instanceof Error ? err.message : String(err || "");
if (message.includes("Missing relay token")) {
return false;
}
return true;
}
+978
View File
@@ -0,0 +1,978 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
const DEFAULT_PORT = 18792
const DEFAULT_HOST = '127.0.0.1'
const BADGE = {
on: { text: 'ON', color: '#06b6d4' },
off: { text: '', color: '#000000' },
connecting: { text: '…', color: '#F59E0B' },
error: { text: '!', color: '#B91C1C' },
}
/** @type {WebSocket|null} */
let relayWs = null
/** @type {Promise<void>|null} */
let relayConnectPromise = null
let relayGatewayToken = ''
/** @type {string|null} */
let relayConnectRequestId = null
let nextSession = 1
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
const tabs = new Map()
/** @type {Map<string, number>} */
const tabBySession = new Map()
/** @type {Map<string, number>} */
const childSessionToTab = new Map()
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
const pending = new Map()
/** @type {Set<number>} */
const tabOperationLocks = new Set()
/** @type {Set<number>} */
const reattachPending = new Set()
/** @type {Set<number>} */
const reattachingTabs = new Set()
let reconnectAttempt = 0
let reconnectTimer = null
async function getRelayPort() {
const stored = await chrome.storage.local.get(['relayPort'])
const raw = stored.relayPort
const n = Number.parseInt(String(raw || ''), 10)
if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
async function getRelayHost() {
const stored = await chrome.storage.local.get(['relayHost'])
const host = String(stored.relayHost || '').trim()
return host || DEFAULT_HOST
}
async function getRelayToken() {
const stored = await chrome.storage.local.get(['relayToken'])
const token = String(stored.relayToken || '').trim()
return token || ''
}
function setBadge(tabId, kind) {
const cfg = BADGE[kind]
void chrome.action.setBadgeText({ tabId, text: cfg.text })
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
}
async function persistState() {
try {
const tabEntries = []
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected' && tab.sessionId && tab.targetId) {
tabEntries.push({ tabId, sessionId: tab.sessionId, targetId: tab.targetId, attachOrder: tab.attachOrder })
}
}
await chrome.storage.session.set({
persistedTabs: tabEntries,
nextSession,
})
} catch {
// chrome.storage.session may not be available in all contexts.
}
}
async function rehydrateState() {
try {
const stored = await chrome.storage.session.get(['persistedTabs', 'nextSession'])
if (stored.nextSession) {
nextSession = Math.max(nextSession, stored.nextSession)
}
const entries = stored.persistedTabs || []
for (const entry of entries) {
tabs.set(entry.tabId, {
state: 'connected',
sessionId: entry.sessionId,
targetId: entry.targetId,
attachOrder: entry.attachOrder,
})
tabBySession.set(entry.sessionId, entry.tabId)
setBadge(entry.tabId, 'on')
}
for (const entry of entries) {
try {
await chrome.tabs.get(entry.tabId)
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
tabs.delete(entry.tabId)
tabBySession.delete(entry.sessionId)
setBadge(entry.tabId, 'off')
}
}
} catch {
// Ignore rehydration errors.
}
}
async function ensureRelayConnection() {
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
if (relayConnectPromise) return await relayConnectPromise
relayConnectPromise = (async () => {
const port = await getRelayPort()
const host = await getRelayHost()
const token = await getRelayToken()
const httpBase = `http://${host}:${port}`
const wsUrl = await buildRelayWsUrl(host, port, token)
try {
await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
} catch (err) {
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
}
const ws = new WebSocket(wsUrl)
relayWs = ws
relayGatewayToken = token
ws.onmessage = (event) => {
if (ws !== relayWs) return
void whenReady(() => onRelayMessage(String(event.data || '')))
}
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
ws.onopen = () => {
clearTimeout(t)
resolve()
}
ws.onerror = () => {
clearTimeout(t)
reject(new Error('WebSocket connect failed'))
}
ws.onclose = (ev) => {
clearTimeout(t)
reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
}
})
ws.onclose = (ev) => {
if (ws !== relayWs) return
if (ev.code === 4000) {
onRelayReplaced()
return
}
onRelayClosed('closed')
}
ws.onerror = () => {
if (ws !== relayWs) return
onRelayClosed('error')
}
})()
try {
await relayConnectPromise
reconnectAttempt = 0
} finally {
relayConnectPromise = null
}
}
function onRelayClosed(reason) {
relayWs = null
relayGatewayToken = ''
relayConnectRequestId = null
for (const [id, p] of pending.entries()) {
pending.delete(id)
p.reject(new Error(`Relay disconnected (${reason})`))
}
reattachPending.clear()
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected') {
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: relay reconnecting…',
})
}
}
scheduleReconnect()
}
function onRelayReplaced() {
relayWs = null
relayGatewayToken = ''
relayConnectRequestId = null
for (const [id, p] of pending.entries()) {
pending.delete(id)
p.reject(new Error('Replaced by another browser session'))
}
reattachPending.clear()
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected') {
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: replaced by another browser — click to reconnect',
})
}
}
// Do NOT schedule reconnect — user must click to take over again
}
function scheduleReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
const delay = reconnectDelayMs(reconnectAttempt)
reconnectAttempt++
console.log(`Scheduling reconnect attempt ${reconnectAttempt} in ${Math.round(delay)}ms`)
reconnectTimer = setTimeout(async () => {
reconnectTimer = null
try {
await ensureRelayConnection()
reconnectAttempt = 0
console.log('Reconnected successfully')
await reannounceAttachedTabs()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.warn(`Reconnect attempt ${reconnectAttempt} failed: ${message}`)
if (!isRetryableReconnectError(err)) {
return
}
scheduleReconnect()
}
}, delay)
}
function cancelReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
reconnectAttempt = 0
}
async function reannounceAttachedTabs() {
for (const [tabId, tab] of tabs.entries()) {
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
tabs.delete(tabId)
if (tab.sessionId) tabBySession.delete(tab.sessionId)
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay (click to attach/detach)',
})
continue
}
try {
const info = /** @type {any} */ (
await chrome.debugger.sendCommand({ tabId }, 'Target.getTargetInfo')
)
const targetInfo = info?.targetInfo
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId: tab.sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
setBadge(tabId, 'on')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: attached (click to detach)',
})
} catch {
setBadge(tabId, 'on')
}
}
await persistState()
}
function sendToRelay(payload) {
const ws = relayWs
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('Relay not connected')
}
ws.send(JSON.stringify(payload))
}
function ensureGatewayHandshakeStarted(payload) {
if (relayConnectRequestId) return
const nonce = typeof payload?.nonce === 'string' ? payload.nonce.trim() : ''
relayConnectRequestId = `ext-connect-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
sendToRelay({
type: 'req',
id: relayConnectRequestId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'officer-browser-relay-extension',
version: '1.0.0',
platform: 'chrome-extension',
mode: 'webchat',
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
caps: [],
commands: [],
nonce: nonce || undefined,
auth: relayGatewayToken ? { token: relayGatewayToken } : undefined,
},
})
}
async function maybeOpenHelpOnce() {
try {
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
if (stored.helpOnErrorShown === true) return
await chrome.storage.local.set({ helpOnErrorShown: true })
await chrome.runtime.openOptionsPage()
} catch {
// ignore
}
}
function requestFromRelay(command) {
const id = command.id
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id)
reject(new Error('Relay request timeout (30s)'))
}, 30000)
pending.set(id, {
resolve: (v) => { clearTimeout(timer); resolve(v) },
reject: (e) => { clearTimeout(timer); reject(e) },
})
try {
sendToRelay(command)
} catch (err) {
clearTimeout(timer)
pending.delete(id)
reject(err instanceof Error ? err : new Error(String(err)))
}
})
}
async function onRelayMessage(text) {
/** @type {any} */
let msg
try {
msg = JSON.parse(text)
} catch {
return
}
if (msg && msg.type === 'event' && msg.event === 'connect.challenge') {
try {
ensureGatewayHandshakeStarted(msg.payload)
} catch (err) {
console.warn('gateway connect handshake start failed', err instanceof Error ? err.message : String(err))
relayConnectRequestId = null
const ws = relayWs
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1008, 'gateway connect failed')
}
}
return
}
if (msg && msg.type === 'res' && relayConnectRequestId && msg.id === relayConnectRequestId) {
relayConnectRequestId = null
if (!msg.ok) {
const detail = msg?.error?.message || msg?.error || 'gateway connect failed'
console.warn('gateway connect handshake rejected', String(detail))
const ws = relayWs
if (ws && ws.readyState === WebSocket.OPEN) {
ws.close(1008, 'gateway connect failed')
}
}
return
}
if (msg && msg.method === 'ping') {
try {
sendToRelay({ method: 'pong' })
} catch {
// ignore
}
return
}
if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) {
const p = pending.get(msg.id)
if (!p) return
pending.delete(msg.id)
if (msg.error) p.reject(new Error(String(msg.error)))
else p.resolve(msg.result)
return
}
if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
try {
const result = await handleForwardCdpCommand(msg)
sendToRelay({ id: msg.id, result })
} catch (err) {
sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
}
}
}
function getTabBySessionId(sessionId) {
const direct = tabBySession.get(sessionId)
if (direct) return { tabId: direct, kind: 'main' }
const child = childSessionToTab.get(sessionId)
if (child) return { tabId: child, kind: 'child' }
return null
}
function getTabByTargetId(targetId) {
for (const [tabId, tab] of tabs.entries()) {
if (tab.targetId === targetId) return tabId
}
return null
}
async function attachTab(tabId, opts = {}) {
const debuggee = { tabId }
await chrome.debugger.attach(debuggee, '1.3')
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'))
const targetInfo = info?.targetInfo
const targetId = String(targetInfo?.targetId || '').trim()
if (!targetId) {
throw new Error('Target.getTargetInfo returned no targetId')
}
const sid = nextSession++
const sessionId = `cb-tab-${sid}`
const attachOrder = sid
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
tabBySession.set(sessionId, tabId)
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: attached (click to detach)',
})
if (!opts.skipAttachedEvent) {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
}
setBadge(tabId, 'on')
await persistState()
return { sessionId, targetId }
}
async function detachTab(tabId, reason) {
const tab = tabs.get(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: childSessionId, reason: 'parent_detached' },
},
})
} catch {
// Relay may be down.
}
childSessionToTab.delete(childSessionId)
}
}
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason },
},
})
} catch {
// Relay may be down.
}
}
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
try {
await chrome.debugger.detach({ tabId })
} catch {
// May already be detached.
}
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay (click to attach/detach)',
})
await persistState()
}
function describeError(err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('not reachable')) return 'relay not reachable — check server address in options'
if (msg.includes('Missing relay token')) return 'no token configured — open options to set up'
if (msg.includes('401') || msg.includes('Unauthorized')) return 'token rejected — check token in options'
if (msg.toLowerCase().includes('timeout')) return 'connection timed out'
return msg.length > 80 ? msg.slice(0, 80) + '…' : msg
}
async function connectOrToggleForActiveTab() {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
const tabId = active?.id
if (!tabId) return
if (tabOperationLocks.has(tabId)) return
tabOperationLocks.add(tabId)
try {
if (reattachPending.has(tabId)) {
reattachPending.delete(tabId)
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay (click to attach/detach)',
})
return
}
const existing = tabs.get(tabId)
if (existing?.state === 'connected') {
await detachTab(tabId, 'toggle')
return
}
cancelReconnect()
tabs.set(tabId, { state: 'connecting' })
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: connecting to relay…',
})
try {
await ensureRelayConnection()
await attachTab(tabId)
} catch (err) {
tabs.delete(tabId)
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: `Officer Browser Relay: ${describeError(err)}`,
})
void maybeOpenHelpOnce()
const message = err instanceof Error ? err.message : String(err)
console.warn('attach failed', message)
}
} finally {
tabOperationLocks.delete(tabId)
}
}
async function handleForwardCdpCommand(msg) {
const method = String(msg?.params?.method || '').trim()
const params = msg?.params?.params || undefined
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
const bySession = sessionId ? getTabBySessionId(sessionId) : null
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
const tabId =
bySession?.tabId ||
(targetId ? getTabByTargetId(targetId) : null) ||
(() => {
for (const [id, tab] of tabs.entries()) {
if (tab.state === 'connected') return id
}
return null
})()
if (!tabId) throw new Error(`No attached tab for method ${method}`)
/** @type {chrome.debugger.DebuggerSession} */
const debuggee = { tabId }
if (method === 'Runtime.enable') {
try {
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
await new Promise((r) => setTimeout(r, 50))
} catch {
// ignore
}
return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params)
}
if (method === 'Target.createTarget') {
const url = typeof params?.url === 'string' ? params.url : 'about:blank'
const tab = await chrome.tabs.create({ url, active: false })
if (!tab.id) throw new Error('Failed to create tab')
await new Promise((r) => setTimeout(r, 100))
const attached = await attachTab(tab.id)
return { targetId: attached.targetId }
}
if (method === 'Target.closeTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toClose = target ? getTabByTargetId(target) : tabId
if (!toClose) return { success: false }
try {
await chrome.tabs.remove(toClose)
} catch {
return { success: false }
}
return { success: true }
}
if (method === 'Target.activateTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toActivate = target ? getTabByTargetId(target) : tabId
if (!toActivate) return {}
const tab = await chrome.tabs.get(toActivate).catch(() => null)
if (!tab) return {}
if (tab.windowId) {
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
}
await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
return {}
}
const tabState = tabs.get(tabId)
const mainSessionId = tabState?.sessionId
const debuggerSession =
sessionId && mainSessionId && sessionId !== mainSessionId
? { ...debuggee, sessionId }
: debuggee
try {
return await chrome.debugger.sendCommand(debuggerSession, method, params)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!message.includes('Session with given id not found')) throw err
// Chrome's internal debugger session became stale — re-attach and retry once
console.warn(`Stale session for tab ${tabId}, re-attaching debugger`)
reattachingTabs.add(tabId)
try {
await chrome.debugger.detach(debuggee).catch(() => {})
await chrome.debugger.attach(debuggee, '1.3')
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
return await chrome.debugger.sendCommand(debuggee, method, params)
} finally {
reattachingTabs.delete(tabId)
}
}
}
function onDebuggerEvent(source, method, params) {
const tabId = source.tabId
if (!tabId) return
const tab = tabs.get(tabId)
if (!tab?.sessionId) return
if (method === 'Target.attachedToTarget' && params?.sessionId) {
childSessionToTab.set(String(params.sessionId), tabId)
}
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
childSessionToTab.delete(String(params.sessionId))
}
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
sessionId: source.sessionId || tab.sessionId,
method,
params,
},
})
} catch {
// Relay may be down.
}
}
async function onDebuggerDetach(source, reason) {
const tabId = source.tabId
if (!tabId) return
if (!tabs.has(tabId)) return
if (reattachingTabs.has(tabId)) return
if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') {
void detachTab(tabId, reason)
return
}
let tabInfo
try {
tabInfo = await chrome.tabs.get(tabId)
} catch {
void detachTab(tabId, reason)
return
}
if (tabInfo.url?.startsWith('chrome://') || tabInfo.url?.startsWith('chrome-extension://')) {
void detachTab(tabId, reason)
return
}
if (reattachPending.has(tabId)) return
const oldTab = tabs.get(tabId)
const oldSessionId = oldTab?.sessionId
const oldTargetId = oldTab?.targetId
if (oldSessionId) tabBySession.delete(oldSessionId)
tabs.delete(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
}
if (oldSessionId && oldTargetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: oldSessionId, targetId: oldTargetId, reason: 'navigation-reattach' },
},
})
} catch {
// Relay may be down.
}
}
reattachPending.add(tabId)
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: re-attaching after navigation…',
})
const delays = [300, 700, 1500]
for (let attempt = 0; attempt < delays.length; attempt++) {
await new Promise((r) => setTimeout(r, delays[attempt]))
if (!reattachPending.has(tabId)) return
try {
await chrome.tabs.get(tabId)
} catch {
reattachPending.delete(tabId)
setBadge(tabId, 'off')
return
}
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
reattachPending.delete(tabId)
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: relay disconnected during re-attach',
})
return
}
try {
await attachTab(tabId)
reattachPending.delete(tabId)
return
} catch {
// continue retries
}
}
reattachPending.delete(tabId)
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Officer Browser Relay: re-attach failed (click to retry)',
})
}
chrome.tabs.onRemoved.addListener((tabId) => void whenReady(() => {
reattachPending.delete(tabId)
if (!tabs.has(tabId)) return
const tab = tabs.get(tabId)
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
}
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason: 'tab_closed' },
},
})
} catch {
// Relay may be down.
}
}
void persistState()
}))
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => void whenReady(() => {
const tab = tabs.get(removedTabId)
if (!tab) return
tabs.delete(removedTabId)
tabs.set(addedTabId, tab)
if (tab.sessionId) {
tabBySession.set(tab.sessionId, addedTabId)
}
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === removedTabId) {
childSessionToTab.set(childSessionId, addedTabId)
}
}
setBadge(addedTabId, 'on')
void persistState()
}))
chrome.debugger.onEvent.addListener((...args) => void whenReady(() => onDebuggerEvent(...args)))
chrome.debugger.onDetach.addListener((...args) => void whenReady(() => onDebuggerDetach(...args)))
chrome.action.onClicked.addListener(() => void whenReady(() => connectOrToggleForActiveTab()))
chrome.webNavigation.onCompleted.addListener(({ tabId, frameId }) => void whenReady(() => {
if (frameId !== 0) return
const tab = tabs.get(tabId)
if (tab?.state === 'connected') {
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
}
}))
chrome.tabs.onActivated.addListener(({ tabId }) => void whenReady(() => {
const tab = tabs.get(tabId)
if (tab?.state === 'connected') {
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
}
}))
chrome.runtime.onInstalled.addListener(() => {
void chrome.runtime.openOptionsPage()
})
chrome.alarms.create('relay-keepalive', { periodInMinutes: 0.5 })
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== 'relay-keepalive') return
await initPromise
if (tabs.size === 0) return
for (const [tabId, tab] of tabs.entries()) {
if (tab.state === 'connected') {
setBadge(tabId, relayWs && relayWs.readyState === WebSocket.OPEN ? 'on' : 'connecting')
}
}
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
if (!relayConnectPromise && !reconnectTimer) {
console.log('Keepalive: WebSocket unhealthy, triggering reconnect')
await ensureRelayConnection().catch(() => {
if (!reconnectTimer) {
scheduleReconnect()
}
})
}
}
})
const initPromise = rehydrateState()
initPromise.then(() => {
if (tabs.size > 0) {
ensureRelayConnection().then(() => {
reconnectAttempt = 0
return reannounceAttachedTabs()
}).catch(() => {
scheduleReconnect()
})
}
})
async function whenReady(fn) {
await initPromise
return fn()
}
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type !== 'relayCheck') return false
const { url, token } = msg
const headers = token ? { 'x-officer-relay-token': token } : {}
fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(2000) })
.then(async (res) => {
const contentType = String(res.headers.get('content-type') || '')
let json = null
if (contentType.includes('application/json')) {
try {
json = await res.json()
} catch {
json = null
}
}
sendResponse({ status: res.status, ok: res.ok, contentType, json })
})
.catch((err) => sendResponse({ status: 0, ok: false, error: String(err) }))
return true
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

+25
View File
@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "Officer Browser Relay",
"version": "0.1.0",
"description": "Connect your Chrome browser to Officer",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["debugger", "tabs", "activeTab", "storage", "alarms", "webNavigation"],
"host_permissions": ["<all_urls>"],
"background": { "service_worker": "background.js", "type": "module" },
"action": {
"default_title": "Officer Browser Relay (click to attach/detach)",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_ui": { "page": "options.html", "open_in_tab": true }
}
+66
View File
@@ -0,0 +1,66 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
function hasCdpVersionShape(data) {
return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data
}
export function classifyRelayCheckResponse(res, host, port) {
if (!res) {
return { action: 'throw', error: 'No response from service worker' }
}
if (res.status === 401) {
return { action: 'status', kind: 'error', message: 'Relay token rejected. Check token and save again.' }
}
if (res.error) {
return { action: 'throw', error: res.error }
}
if (!res.ok) {
return { action: 'throw', error: `HTTP ${res.status}` }
}
const contentType = String(res.contentType || '')
if (!contentType.includes('application/json')) {
return {
action: 'status',
kind: 'error',
message: `Wrong endpoint: expected Officer browser relay at this address.`,
}
}
if (!hasCdpVersionShape(res.json)) {
const browser = res.json?.Browser ?? ''
if (browser && !browser.startsWith('Officer/')) {
return {
action: 'status',
kind: 'error',
message: `Wrong relay: found "${browser}" instead of Officer/browser-relay.`,
}
}
return {
action: 'status',
kind: 'error',
message: `Wrong endpoint: expected relay /json/version response.`,
}
}
return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://${host}:${port}/` }
}
export function classifyRelayCheckException(err, host, port) {
const message = String(err || '').toLowerCase()
if (message.includes('json') || message.includes('syntax')) {
return {
kind: 'error',
message: `Wrong endpoint: this is not a relay endpoint.`,
}
}
return {
kind: 'error',
message: `Relay not reachable at http://${host}:${port}/. Verify the server address and relay token.`,
}
}
+209
View File
@@ -0,0 +1,209 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Officer Browser Relay</title>
<style>
:root {
color-scheme: light dark;
--accent: #06b6d4;
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
--border: color-mix(in oklab, canvasText 18%, transparent);
--muted: color-mix(in oklab, canvasText 70%, transparent);
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
"SF Pro Display", "Segoe UI", sans-serif;
line-height: 1.4;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
canvas;
color: canvasText;
}
.wrap {
max-width: 820px;
margin: 36px auto;
padding: 0 24px 48px 24px;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.logo {
width: 44px;
height: 44px;
border-radius: 14px;
background: color-mix(in oklab, var(--accent) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
box-shadow: var(--shadow);
display: grid;
place-items: center;
font-size: 24px;
}
h1 {
font-size: 20px;
margin: 0;
letter-spacing: -0.01em;
}
.subtitle {
margin: 2px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 10px 0;
font-size: 14px;
letter-spacing: 0.01em;
}
.card p {
margin: 8px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in oklab, canvas 92%, canvasText 8%);
color: canvasText;
outline: none;
}
input:focus {
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
}
input.host-input {
width: 240px;
}
input.port-input {
width: 100px;
}
input.token-input {
width: min(520px, 100%);
}
button {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
background: linear-gradient(
180deg,
color-mix(in oklab, var(--accent) 80%, white 20%),
var(--accent)
);
color: white;
font-weight: 650;
letter-spacing: 0.01em;
cursor: pointer;
}
button:active {
transform: translateY(1px);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
code {
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
font-size: 12px;
}
a {
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
}
.status {
margin-top: 10px;
font-size: 12px;
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
min-height: 16px;
}
.status[data-kind='ok'] {
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
}
.status[data-kind='error'] {
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo" aria-hidden="true">&#x1f310;</div>
<div>
<h1>Officer Browser Relay</h1>
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
</div>
</header>
<div class="grid">
<div class="card">
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Make sure Officer is running and enter the correct server address, port, and relay token below.
</p>
<p>
You can find your relay token in the Officer dashboard under <strong>Browser</strong>.
</p>
</div>
<div class="card">
<h2>Relay connection</h2>
<label for="host">Server address</label>
<div class="row">
<input id="host" class="host-input" placeholder="e.g. 100.64.0.1" />
</div>
<label for="port" style="margin-top: 10px">Port</label>
<div class="row">
<input id="port" class="port-input" inputmode="numeric" pattern="[0-9]*" />
</div>
<label for="token" style="margin-top: 10px">Relay token</label>
<div class="row">
<input id="token" class="token-input" type="password" autocomplete="off" />
<button id="save" type="button">Save</button>
</div>
<div class="hint">
Default port: <code>18792</code>. Extension connects to: <code id="relay-url">http://&lt;host&gt;:&lt;port&gt;/</code>.
</div>
<div class="status" id="status"></div>
</div>
</div>
<script type="module" src="options.js"></script>
</div>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js'
const DEFAULT_PORT = 18792
const DEFAULT_HOST = '127.0.0.1'
function clampPort(value) {
const n = Number.parseInt(String(value || ''), 10)
if (!Number.isFinite(n)) return DEFAULT_PORT
if (n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function updateRelayUrl(host, port) {
const el = document.getElementById('relay-url')
if (!el) return
el.textContent = `http://${host}:${port}/`
}
function setStatus(kind, message) {
const status = document.getElementById('status')
if (!status) return
status.dataset.kind = kind || ''
status.textContent = message || ''
}
async function checkRelayReachable(host, port, token) {
const url = `http://${host}:${port}/json/version`
const trimmedToken = String(token || '').trim()
if (!trimmedToken) {
setStatus('error', 'Relay token required. Save your token to connect.')
return
}
try {
// Token is already derived server-side — use it directly
const res = await chrome.runtime.sendMessage({
type: 'relayCheck',
url,
token: trimmedToken,
})
const result = classifyRelayCheckResponse(res, host, port)
if (result.action === 'throw') throw new Error(result.error)
setStatus(result.kind, result.message)
} catch (err) {
const result = classifyRelayCheckException(err, host, port)
setStatus(result.kind, result.message)
}
}
async function load() {
const stored = await chrome.storage.local.get(['relayPort', 'relayHost', 'relayToken'])
const port = clampPort(stored.relayPort)
const host = String(stored.relayHost || '').trim() || DEFAULT_HOST
const token = String(stored.relayToken || '').trim()
document.getElementById('host').value = host
document.getElementById('port').value = String(port)
document.getElementById('token').value = token
updateRelayUrl(host, port)
await checkRelayReachable(host, port, token)
}
async function save() {
const hostInput = document.getElementById('host')
const portInput = document.getElementById('port')
const tokenInput = document.getElementById('token')
const host = String(hostInput.value || '').trim() || DEFAULT_HOST
const port = clampPort(portInput.value)
const token = String(tokenInput.value || '').trim()
await chrome.storage.local.set({ relayHost: host, relayPort: port, relayToken: token })
hostInput.value = host
portInput.value = String(port)
tokenInput.value = token
updateRelayUrl(host, port)
await checkRelayReachable(host, port, token)
}
document.getElementById('save').addEventListener('click', () => void save())
void load()
+48
View File
@@ -0,0 +1,48 @@
import type { PluginManifest } from '@@/plugins/manifest';
// Browser — your own Chrome, driven from Officer, through an extension you install yourself.
//
// Extracted 2026-08-21, having been switched off since 2026-08-13. It was never a scraper: it attaches to
// the browser the person is already using, with their sessions, their logins and their tabs. That is the
// whole difference from `/scrape`, which stays in the platform and launches a headless chromium that
// knows nobody.
//
// extension/ the Chrome extension — the half that runs in the browser
// sidecar/ the relay it connects to, on a FIXED port, plus the CDP bridge
// api/ pairing, status, and the target verbs
// db/ one table: the salt each pairing's token is derived from
// web/ the tab list and preview panels
//
// ── Why the relay is a sidecar ──
//
// It listens on its own port, which is why it was switched off: the platform serves one. A sidecar is a
// process with its own listeners, so the objection disappears rather than being argued with.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Browser',
summary: 'Drive your own browser from Officer — your tabs, your sessions, your logins',
icon: 'Globe',
color: '#38bdf8',
permissions: [
{
key: 'browser-relay',
label: 'Browser',
description: 'Pair a browser and drive its tabs — screenshots, navigation, evaluated scripts',
// NOT owner-only, unlike desktop, and the difference is real: every route here is scoped by the
// caller's own pairing. A member pairs THEIR browser, and the relay resolves an extension's token
// to the account that minted it — so two accounts on one server drive two different browsers.
//
// That is also why this is not the platform's `browser` permission, which is `kind: 'execution'`
// and covers `/scrape`. Those are different things wearing the same word: one runs a headless
// chromium as the owner, this one attaches to whoever is holding the keyboard.
},
],
// Nothing to declare. The extension is installed by the person, into their own browser, and the relay
// is Bun — there is no host binary in the path at all. Chrome itself is deliberately NOT declared: it
// does not have to be on this machine, and on a remote pairing it is not.
};
+99
View File
@@ -0,0 +1,99 @@
type CdpCommandOptions = {
relayPort: number;
userToken: string;
targetId?: string;
};
async function sendCdpCommand(opts: CdpCommandOptions, method: string, params?: unknown): Promise<unknown> {
const { relayPort, userToken, targetId } = opts;
const url = `ws://127.0.0.1:${relayPort}/cdp?token=${encodeURIComponent(userToken)}`;
return await new Promise<unknown>((resolve, reject) => {
const ws = new WebSocket(url);
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
ws.close();
reject(new Error(`CDP command timeout: ${method}`));
}, 30_000);
ws.addEventListener('open', () => {
const cmd: Record<string, unknown> = { id: 1, method };
if (params) cmd.params = params;
if (targetId) cmd.sessionId = targetId;
ws.send(JSON.stringify(cmd));
});
ws.addEventListener('message', (event) => {
if (settled) return;
try {
const msg = JSON.parse(String(event.data)) as { id?: number; result?: unknown; error?: { message: string } };
if (msg.id === 1) {
settled = true;
clearTimeout(timeout);
ws.close();
if (msg.error) reject(new Error(msg.error.message));
else resolve(msg.result);
}
} catch {
// ignore parse errors, wait for correct message
}
});
ws.addEventListener('error', () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(new Error('CDP WebSocket connection failed'));
});
ws.addEventListener('close', () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(new Error('CDP WebSocket closed before response'));
});
});
}
export async function captureScreenshot(
opts: CdpCommandOptions,
format: 'png' | 'jpeg' = 'png',
quality?: number,
): Promise<string> {
const params: Record<string, unknown> = { format };
if (quality !== undefined) params.quality = quality;
const result = (await sendCdpCommand(opts, 'Page.captureScreenshot', params)) as { data: string };
return result.data;
}
export async function evaluateJS(opts: CdpCommandOptions, expression: string): Promise<unknown> {
const result = (await sendCdpCommand(opts, 'Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
})) as { result?: { value?: unknown; description?: string }; exceptionDetails?: { text?: string } };
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text ?? 'Evaluation failed');
}
return result.result?.value;
}
export async function navigateTo(opts: CdpCommandOptions, url: string): Promise<void> {
await sendCdpCommand(opts, 'Page.navigate', { url });
}
export async function getPageInfo(opts: CdpCommandOptions): Promise<{ title: string; url: string }> {
const result = (await sendCdpCommand(opts, 'Runtime.evaluate', {
expression: 'JSON.stringify({ title: document.title, url: location.href })',
returnByValue: true,
})) as { result?: { value?: string } };
try {
return JSON.parse(result.result?.value ?? '{}');
} catch {
return { title: '', url: '' };
}
}
+72
View File
@@ -0,0 +1,72 @@
import { API_URL } from '@@/officer-url.mjs';
import { createSidecarConnector } from '@@/sidecar/connect';
import { startBrowserRelay, stopBrowserRelay, getUserRelayStatus, getUserTargets, getRelayPort } from './relay';
import { registerUserToken, unregisterUserToken, deriveRelayToken } from './relay-auth';
import { BROWSER_SIDECAR_KIND, RELAY_PORT, type BrowserCommand, type BrowserEvent } from './protocol';
// The browser relay, as its own process.
//
// It was a second `Bun.serve` inside the platform, on its own port, and that is exactly why it was
// switched off on 2026-08-13 — "the platform serves one port". A sidecar is the shape that objection
// asks for, and it is what this is now.
//
// Two listeners live here: the extension's WebSocket (RELAY_PORT, fixed — see protocol.ts) and the
// registration socket back to the platform, which is how `api/router.ts` asks it anything.
type ReplyFn = (msg: BrowserEvent) => void;
async function handleCommand(cmd: BrowserCommand, reply: ReplyFn): Promise<void> {
try {
switch (cmd.type) {
case 'ping':
return reply({ type: 'pong', id: cmd.id });
case 'relay:status': {
const status = getUserRelayStatus(cmd.userId);
return reply({ type: 'relay:status', id: cmd.id, ...status });
}
case 'relay:targets':
return reply({ type: 'relay:targets', id: cmd.id, targets: getUserTargets(cmd.userId) });
case 'relay:token-add': {
// Derived here rather than sent over the wire: the key lives in this process, and a token that
// crossed the boundary would be a credential in a log the moment anything traced these messages.
const token = registerUserToken(cmd.userId, getRelayPort(), cmd.salt);
return reply({ type: 'relay:token', id: cmd.id, token, port: getRelayPort() });
}
case 'relay:token-remove': {
unregisterUserToken(deriveRelayToken(cmd.userId, getRelayPort(), cmd.salt));
return reply({ type: 'relay:ok', id: cmd.id });
}
default:
return reply({ type: 'error', id: (cmd as { id: string }).id, error: `unknown command` });
}
} catch (err) {
reply({ type: 'error', id: cmd.id, error: err instanceof Error ? err.message : String(err) });
}
}
await startBrowserRelay(RELAY_PORT);
console.log(`[browser-relay] listening for extensions on ${RELAY_PORT}`);
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'browser-relay',
handles: [BROWSER_SIDECAR_KIND],
onCommand(cmd, reply) {
void handleCommand(cmd as BrowserCommand, reply as ReplyFn);
},
});
function shutdown(signal: string) {
console.log(`[browser-relay] ${signal} received, closing`);
stopBrowserRelay();
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+44
View File
@@ -0,0 +1,44 @@
import type { ConnectedTarget } from './relay-types';
// What the api half says to the relay process, and what comes back.
//
// The relay used to run INSIDE the platform, so `api/router.ts` simply called `getUserRelayStatus()`.
// It cannot now: the relay is its own process because it owns a second listener, and "the platform
// serves one port" is why the whole thing was switched off in the first place.
//
// So this is the boundary. Small on purpose — four questions and one passthrough — because everything
// stateful stays on the relay side where the sockets are.
export type BrowserCommand =
| { type: 'ping'; id: string }
| { type: 'relay:status'; id: string; userId: number }
| { type: 'relay:targets'; id: string; userId: number }
/** Register a derived token so the relay will accept an extension presenting it. */
| { type: 'relay:token-add'; id: string; userId: number; salt: string }
| { type: 'relay:token-remove'; id: string; userId: number; salt: string };
// NOT here: CDP. `cdp.ts` opens its own WebSocket to the relay's /cdp endpoint with a derived token, which
// already crosses the process boundary — routing screenshots through this channel would put page bytes
// through the platform for no reason.
export type BrowserEvent =
| { type: 'pong'; id: string }
| { type: 'error'; id: string; error: string }
| { type: 'relay:status'; id: string; extensionConnected: boolean; targetCount: number }
| { type: 'relay:targets'; id: string; targets: ConnectedTarget[] }
| { type: 'relay:token'; id: string; token: string; port: number }
| { type: 'relay:ok'; id: string };
export const BROWSER_SIDECAR_KIND = 'browser-relay';
/**
* The port the extension connects to.
*
* NOT kernel-assigned, unlike every other sidecar's listener. The extension is configured by hand and
* stores the number, so a port that changed each restart would break the pairing each restart — and the
* port is an input to the token HMAC, so changing it invalidates every paired browser silently. The
* extension then reports "Relay not reachable", which SETUP.md attributes to a wrong address or token.
*
* 18792 is what it was as `BROWSER_RELAY_PORT` before the extraction, kept so any extension configured
* back then still points at the right place.
*/
export const RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? 18792);
+48
View File
@@ -0,0 +1,48 @@
import { createHmac } from 'node:crypto';
import { getKey } from 'officerdb/secret-store';
const RELAY_TOKEN_CONTEXT = 'officer-browser-relay-v1';
// ── Its own key, not the platform's ──
//
// This derived tokens from the platform's session signing key, and the file said, in this very
// comment, that an extraction should stop doing that: "a plugin deriving tokens from the platform's
// signing key is the coupling the per-purpose split exists to remove". Done now.
//
// Changing the key changes every derived token, so this is normally a breaking change of the same kind as
// changing the port. It is free today and only today: the relay has been off since 2026-08-13 and there
// are no pairings to invalidate — checked in Postgres before the change, not assumed.
//
// `getKey` mints a random value for a purpose that has none yet and stores it, which is right here: this
// key protects nothing but its own tokens, and a fresh install having a fresh one is correct.
export function deriveRelayToken(userId: number, port: number, salt: string): string {
return createHmac('sha256', getKey('browser-relay')).update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`).digest('hex');
}
const tokenToUser = new Map<string, number>();
export function registerUserToken(userId: number, port: number, salt: string): string {
const token = deriveRelayToken(userId, port, salt);
tokenToUser.set(token, userId);
return token;
}
export function unregisterUserToken(token: string): void {
tokenToUser.delete(token);
}
export function restoreUserTokens(entries: Array<{ userId: number; salt: string }>, port: number): void {
for (const { userId, salt } of entries) {
const token = deriveRelayToken(userId, port, salt);
tokenToUser.set(token, userId);
}
}
export function resolveUserFromToken(token: string): number | null {
return tokenToUser.get(token) ?? null;
}
export function getRelayAuthHeader(): string {
return 'x-officer-relay-token';
}
+89
View File
@@ -0,0 +1,89 @@
export type TargetInfo = {
targetId: string;
type?: string;
title?: string;
url?: string;
attached?: boolean;
};
export type ConnectedTarget = {
sessionId: string;
targetId: string;
targetInfo: TargetInfo;
};
export type RelayStatus = {
extensionConnected: boolean;
targetCount: number;
};
export type CdpCommand = {
id: number;
method: string;
params?: unknown;
sessionId?: string;
};
export type CdpResponse = {
id: number;
result?: unknown;
error?: { message: string };
sessionId?: string;
};
export type CdpEvent = {
method: string;
params?: unknown;
sessionId?: string;
};
export type ExtensionForwardCommandMessage = {
id: number;
method: 'forwardCDPCommand';
params: { method: string; params?: unknown; sessionId?: string };
};
export type ExtensionResponseMessage = {
id: number;
result?: unknown;
error?: string;
};
export type ExtensionForwardEventMessage = {
method: 'forwardCDPEvent';
params: { method: string; params?: unknown; sessionId?: string };
};
export type ExtensionPingMessage = { method: 'ping' };
export type ExtensionPongMessage = { method: 'pong' };
export type ExtensionMessage = ExtensionResponseMessage | ExtensionForwardEventMessage | ExtensionPongMessage;
export type AttachedToTargetEvent = {
sessionId: string;
targetInfo: TargetInfo;
waitingForDebugger?: boolean;
};
export type DetachedFromTargetEvent = {
sessionId: string;
targetId?: string;
};
export type UserRelay = {
extensionWs: unknown | null;
cdpClients: Set<unknown>;
connectedTargets: Map<string, ConnectedTarget>;
pingInterval: Timer | null;
gracePeriodTimer: Timer | null;
pendingExtension: Map<
number,
{
resolve: (v: unknown) => void;
reject: (e: Error) => void;
timer: Timer;
}
>;
nextExtensionId: number;
extensionReconnectWaiters: Set<(connected: boolean) => void>;
};
+675
View File
@@ -0,0 +1,675 @@
// Based on OpenClaw Browser Extension Relay (MIT License)
// https://github.com/nicepkg/openclaw
//
// Rewritten for Bun.serve() with multi-user support.
import type { ServerWebSocket } from 'bun';
import type {
AttachedToTargetEvent,
CdpCommand,
CdpEvent,
CdpResponse,
ConnectedTarget,
DetachedFromTargetEvent,
ExtensionForwardCommandMessage,
ExtensionForwardEventMessage,
ExtensionMessage,
ExtensionPongMessage,
} from './relay-types';
import { allPairings } from '../db/queries';
import { getRelayAuthHeader, resolveUserFromToken, restoreUserTokens } from './relay-auth';
const RELAY_AUTH_HEADER = getRelayAuthHeader();
const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 5_000;
const DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS = 3_000;
type WSData = {
kind: 'extension' | 'cdp';
userId: number;
token: string;
};
type UserRelay = {
extensionWs: ServerWebSocket<WSData> | null;
cdpClients: Set<ServerWebSocket<WSData>>;
connectedTargets: Map<string, ConnectedTarget>;
pingInterval: Timer | null;
gracePeriodTimer: Timer | null;
pendingExtension: Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void; timer: Timer }>;
nextExtensionId: number;
extensionReconnectWaiters: Set<(connected: boolean) => void>;
};
const userRelays = new Map<number, UserRelay>();
function getUserRelay(userId: number): UserRelay {
let relay = userRelays.get(userId);
if (!relay) {
relay = {
extensionWs: null,
cdpClients: new Set(),
connectedTargets: new Map(),
pingInterval: null,
gracePeriodTimer: null,
pendingExtension: new Map(),
nextExtensionId: 1,
extensionReconnectWaiters: new Set(),
};
userRelays.set(userId, relay);
}
return relay;
}
function extensionConnected(relay: UserRelay): boolean {
return relay.extensionWs !== null && relay.extensionWs.readyState === WebSocket.OPEN;
}
function flushExtensionReconnectWaiters(relay: UserRelay, connected: boolean) {
if (relay.extensionReconnectWaiters.size === 0) return;
const waiters = Array.from(relay.extensionReconnectWaiters);
relay.extensionReconnectWaiters.clear();
for (const waiter of waiters) waiter(connected);
}
function clearGracePeriodTimer(relay: UserRelay) {
if (!relay.gracePeriodTimer) return;
clearTimeout(relay.gracePeriodTimer);
relay.gracePeriodTimer = null;
}
function closeCdpClientsAfterExtensionDisconnect(relay: UserRelay) {
relay.connectedTargets.clear();
for (const client of relay.cdpClients) {
try {
client.close(1011, 'extension disconnected');
} catch {
// ignore
}
}
relay.cdpClients.clear();
flushExtensionReconnectWaiters(relay, false);
}
function scheduleExtensionDisconnectCleanup(relay: UserRelay) {
clearGracePeriodTimer(relay);
relay.gracePeriodTimer = setTimeout(() => {
relay.gracePeriodTimer = null;
if (extensionConnected(relay)) return;
closeCdpClientsAfterExtensionDisconnect(relay);
}, DEFAULT_EXTENSION_RECONNECT_GRACE_MS);
}
async function waitForExtensionReconnect(relay: UserRelay, timeoutMs: number): Promise<boolean> {
if (extensionConnected(relay)) return true;
return await new Promise<boolean>((resolve) => {
let settled = false;
const waiter = (connected: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
relay.extensionReconnectWaiters.delete(waiter);
resolve(connected);
};
const timer = setTimeout(() => waiter(false), timeoutMs);
relay.extensionReconnectWaiters.add(waiter);
});
}
function sendToExtension(relay: UserRelay, payload: ExtensionForwardCommandMessage): Promise<unknown> {
const ws = relay.extensionWs;
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('Chrome extension not connected');
}
ws.send(JSON.stringify(payload));
return new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
relay.pendingExtension.delete(payload.id);
reject(new Error(`extension request timeout: ${payload.params.method}`));
}, 30_000);
relay.pendingExtension.set(payload.id, { resolve, reject, timer });
});
}
function broadcastToCdpClients(relay: UserRelay, evt: CdpEvent) {
const msg = JSON.stringify(evt);
for (const ws of relay.cdpClients) {
if (ws.readyState !== WebSocket.OPEN) continue;
ws.send(msg);
}
}
function sendResponseToCdp(ws: ServerWebSocket<WSData>, res: CdpResponse) {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(res));
}
function ensureTargetEventsForClient(relay: UserRelay, ws: ServerWebSocket<WSData>, mode: 'autoAttach' | 'discover') {
for (const target of relay.connectedTargets.values()) {
if (mode === 'autoAttach') {
ws.send(
JSON.stringify({
method: 'Target.attachedToTarget',
params: {
sessionId: target.sessionId,
targetInfo: { ...target.targetInfo, attached: true },
waitingForDebugger: false,
},
} satisfies CdpEvent),
);
} else {
ws.send(
JSON.stringify({
method: 'Target.targetCreated',
params: { targetInfo: { ...target.targetInfo, attached: true } },
} satisfies CdpEvent),
);
}
}
}
async function routeCdpCommand(relay: UserRelay, cmd: CdpCommand): Promise<unknown> {
switch (cmd.method) {
case 'Browser.getVersion':
return {
protocolVersion: '1.3',
product: 'Chrome/Officer-Browser-Relay',
revision: '0',
userAgent: 'Officer-Browser-Relay',
jsVersion: 'V8',
};
case 'Browser.setDownloadBehavior':
return {};
case 'Target.setAutoAttach':
case 'Target.setDiscoverTargets':
return {};
case 'Target.getTargets':
return {
targetInfos: Array.from(relay.connectedTargets.values()).map((t) => ({
...t.targetInfo,
attached: true,
})),
};
case 'Target.getTargetInfo': {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId = typeof params.targetId === 'string' ? params.targetId : undefined;
if (targetId) {
for (const t of relay.connectedTargets.values()) {
if (t.targetId === targetId) return { targetInfo: t.targetInfo };
}
}
if (cmd.sessionId && relay.connectedTargets.has(cmd.sessionId)) {
const t = relay.connectedTargets.get(cmd.sessionId);
if (t) return { targetInfo: t.targetInfo };
}
const first = Array.from(relay.connectedTargets.values())[0];
return { targetInfo: first?.targetInfo };
}
case 'Target.attachToTarget': {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId = typeof params.targetId === 'string' ? params.targetId : undefined;
if (!targetId) throw new Error('targetId required');
for (const t of relay.connectedTargets.values()) {
if (t.targetId === targetId) return { sessionId: t.sessionId };
}
throw new Error('target not found');
}
default: {
const id = relay.nextExtensionId++;
return await sendToExtension(relay, {
id,
method: 'forwardCDPCommand',
params: {
method: cmd.method,
sessionId: cmd.sessionId,
params: cmd.params,
},
});
}
}
}
function handleExtensionMessage(relay: UserRelay, data: string) {
let parsed: ExtensionMessage | null = null;
try {
parsed = JSON.parse(data) as ExtensionMessage;
} catch {
return;
}
if (parsed && typeof parsed === 'object' && 'id' in parsed && typeof parsed.id === 'number') {
const pending = relay.pendingExtension.get(parsed.id);
if (!pending) return;
relay.pendingExtension.delete(parsed.id);
clearTimeout(pending.timer);
if ('error' in parsed && typeof parsed.error === 'string' && parsed.error.trim()) {
pending.reject(new Error(parsed.error));
} else {
pending.resolve(parsed.result);
}
return;
}
if (parsed && typeof parsed === 'object' && 'method' in parsed) {
if ((parsed as ExtensionPongMessage).method === 'pong') return;
if ((parsed as ExtensionForwardEventMessage).method !== 'forwardCDPEvent') return;
const evt = parsed as ExtensionForwardEventMessage;
const method = evt.params?.method;
const params = evt.params?.params;
const sessionId = evt.params?.sessionId;
if (!method || typeof method !== 'string') return;
if (method === 'Target.attachedToTarget') {
const attached = (params ?? {}) as AttachedToTargetEvent;
const targetType = attached?.targetInfo?.type ?? 'page';
if (targetType !== 'page') return;
if (attached?.sessionId && attached?.targetInfo?.targetId) {
const prev = relay.connectedTargets.get(attached.sessionId);
const nextTargetId = attached.targetInfo.targetId;
const prevTargetId = prev?.targetId;
const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId);
relay.connectedTargets.set(attached.sessionId, {
sessionId: attached.sessionId,
targetId: nextTargetId,
targetInfo: attached.targetInfo,
});
if (changedTarget && prevTargetId) {
broadcastToCdpClients(relay, {
method: 'Target.detachedFromTarget',
params: { sessionId: attached.sessionId, targetId: prevTargetId },
sessionId: attached.sessionId,
});
}
if (!prev || changedTarget) {
broadcastToCdpClients(relay, { method, params, sessionId });
}
return;
}
}
if (method === 'Target.detachedFromTarget') {
const detached = (params ?? {}) as DetachedFromTargetEvent;
if (detached?.sessionId) {
relay.connectedTargets.delete(detached.sessionId);
}
broadcastToCdpClients(relay, { method, params, sessionId });
return;
}
if (method === 'Target.targetInfoChanged') {
const changed = (params ?? {}) as { targetInfo?: { targetId?: string; type?: string } };
const targetInfo = changed?.targetInfo;
const targetId = targetInfo?.targetId;
if (targetId && (targetInfo?.type ?? 'page') === 'page') {
for (const [sid, target] of relay.connectedTargets) {
if (target.targetId !== targetId) continue;
relay.connectedTargets.set(sid, {
...target,
targetInfo: { ...target.targetInfo, ...(targetInfo as object) },
});
}
}
}
broadcastToCdpClients(relay, { method, params, sessionId });
}
}
function handleExtensionClose(relay: UserRelay, ws: ServerWebSocket<WSData>) {
if (relay.extensionWs !== ws) return;
if (relay.pingInterval) {
clearInterval(relay.pingInterval);
relay.pingInterval = null;
}
relay.extensionWs = null;
for (const [, pending] of relay.pendingExtension) {
clearTimeout(pending.timer);
pending.reject(new Error('extension disconnected'));
}
relay.pendingExtension.clear();
scheduleExtensionDisconnectCleanup(relay);
}
function handleExtensionOpen(relay: UserRelay, ws: ServerWebSocket<WSData>) {
relay.extensionWs = ws;
clearGracePeriodTimer(relay);
flushExtensionReconnectWaiters(relay, true);
relay.pingInterval = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify({ method: 'ping' }));
}, 5000);
}
async function handleCdpMessage(relay: UserRelay, ws: ServerWebSocket<WSData>, data: string) {
let cmd: CdpCommand | null = null;
try {
cmd = JSON.parse(data) as CdpCommand;
} catch {
return;
}
if (!cmd || typeof cmd !== 'object') return;
if (typeof cmd.id !== 'number' || typeof cmd.method !== 'string') return;
if (!extensionConnected(relay)) {
const reconnected = await waitForExtensionReconnect(relay, DEFAULT_EXTENSION_COMMAND_RECONNECT_WAIT_MS);
if (!reconnected || !extensionConnected(relay)) {
sendResponseToCdp(ws, {
id: cmd.id,
sessionId: cmd.sessionId,
error: { message: 'Extension not connected' },
});
return;
}
}
try {
const result = await routeCdpCommand(relay, cmd);
if (cmd.method === 'Target.setAutoAttach' && !cmd.sessionId) {
ensureTargetEventsForClient(relay, ws, 'autoAttach');
}
if (cmd.method === 'Target.setDiscoverTargets') {
const discover = (cmd.params ?? {}) as { discover?: boolean };
if (discover.discover === true) {
ensureTargetEventsForClient(relay, ws, 'discover');
}
}
if (cmd.method === 'Target.attachToTarget') {
const params = (cmd.params ?? {}) as { targetId?: string };
const targetId = typeof params.targetId === 'string' ? params.targetId : undefined;
if (targetId) {
const target = Array.from(relay.connectedTargets.values()).find((t) => t.targetId === targetId);
if (target) {
ws.send(
JSON.stringify({
method: 'Target.attachedToTarget',
params: {
sessionId: target.sessionId,
targetInfo: { ...target.targetInfo, attached: true },
waitingForDebugger: false,
},
} satisfies CdpEvent),
);
}
}
}
sendResponseToCdp(ws, { id: cmd.id, sessionId: cmd.sessionId, result });
} catch (err) {
sendResponseToCdp(ws, {
id: cmd.id,
sessionId: cmd.sessionId,
error: { message: err instanceof Error ? err.message : String(err) },
});
}
}
function getTokenFromUrl(url: string): string | null {
try {
const parsed = new URL(url, 'http://localhost');
return parsed.searchParams.get('token')?.trim() ?? null;
} catch {
return null;
}
}
function getTokenFromHeaders(headers: Headers): string | null {
return headers.get(RELAY_AUTH_HEADER)?.trim() ?? null;
}
function getTokenFromRequest(req: Request): string | null {
return getTokenFromHeaders(req.headers) ?? getTokenFromUrl(req.url);
}
function authenticateRequest(req: Request): number | null {
const token = getTokenFromRequest(req);
if (!token) return null;
return resolveUserFromToken(token);
}
let relayServer: ReturnType<typeof Bun.serve> | null = null;
let relayPort = 0;
export function getRelayPort(): number {
return relayPort;
}
export function getUserRelayStatus(userId: number): { extensionConnected: boolean; targetCount: number } {
const relay = userRelays.get(userId);
if (!relay) return { extensionConnected: false, targetCount: 0 };
return {
extensionConnected: extensionConnected(relay),
targetCount: relay.connectedTargets.size,
};
}
export function getUserTargets(userId: number): ConnectedTarget[] {
const relay = userRelays.get(userId);
if (!relay) return [];
return Array.from(relay.connectedTargets.values());
}
export async function startBrowserRelay(port: number) {
relayPort = port;
// Rebuild the token map from the plugin's own table. Every paired browser's token is derived rather
// than stored, so this is what makes a restart transparent to an extension that is already configured.
const entries = await allPairings();
if (entries.length > 0) {
restoreUserTokens(entries, port);
}
const server = Bun.serve<WSData>({
port,
hostname: '0.0.0.0',
idleTimeout: 60,
routes: {
'/': (req: Request) => {
if (req.method === 'HEAD') return new Response(null, { status: 200 });
return new Response('OK', { status: 200, headers: { 'Content-Type': 'text/plain' } });
},
},
fetch(req, server) {
const url = new URL(req.url);
const path = url.pathname;
// CORS for chrome-extension origins
const origin = req.headers.get('origin');
const corsHeaders: Record<string, string> = {};
if (origin) {
corsHeaders['Access-Control-Allow-Origin'] = origin;
corsHeaders['Vary'] = 'Origin';
}
if (req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
...corsHeaders,
'Access-Control-Allow-Methods': 'GET, PUT, POST, OPTIONS',
'Access-Control-Allow-Headers': `Content-Type, ${RELAY_AUTH_HEADER}`,
'Access-Control-Max-Age': '86400',
},
});
}
if (path === '/extension/status') {
const connectedCount = Array.from(userRelays.values()).filter((r) => extensionConnected(r)).length;
return Response.json({ connected: connectedCount > 0, users: connectedCount }, { headers: corsHeaders });
}
// Auth-protected endpoints
const token = getTokenFromRequest(req);
const userId = token ? resolveUserFromToken(token) : null;
if (path === '/json/version' || path === '/json/version/') {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const relay = userRelays.get(userId);
const payload: Record<string, unknown> = {
Browser: 'Officer/browser-relay',
'Protocol-Version': '1.3',
};
if (relay && extensionConnected(relay)) {
const hostHeader = req.headers.get('host')?.trim() || `0.0.0.0:${port}`;
payload.webSocketDebuggerUrl = `ws://${hostHeader}/cdp?token=${token}`;
}
return Response.json(payload, { headers: corsHeaders });
}
const listPaths = new Set(['/json', '/json/', '/json/list', '/json/list/']);
if (listPaths.has(path)) {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const relay = userRelays.get(userId);
const hostHeader = req.headers.get('host')?.trim() || `0.0.0.0:${port}`;
const cdpWsUrl = `ws://${hostHeader}/cdp?token=${token}`;
const list = relay
? Array.from(relay.connectedTargets.values()).map((t) => ({
id: t.targetId,
type: t.targetInfo.type ?? 'page',
title: t.targetInfo.title ?? '',
description: t.targetInfo.title ?? '',
url: t.targetInfo.url ?? '',
webSocketDebuggerUrl: cdpWsUrl,
}))
: [];
return Response.json(list, { headers: corsHeaders });
}
const activateMatch = path.match(/^\/json\/activate\/(.+)$/);
if (activateMatch) {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const targetId = decodeURIComponent(activateMatch[1]!).trim();
if (!targetId) return new Response('targetId required', { status: 400, headers: corsHeaders });
const relay = userRelays.get(userId);
if (relay && extensionConnected(relay)) {
void sendToExtension(relay, {
id: relay.nextExtensionId++,
method: 'forwardCDPCommand',
params: { method: 'Target.activateTarget', params: { targetId } },
}).catch(() => {});
}
return new Response('OK', { status: 200, headers: corsHeaders });
}
const closeMatch = path.match(/^\/json\/close\/(.+)$/);
if (closeMatch) {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const targetId = decodeURIComponent(closeMatch[1]!).trim();
if (!targetId) return new Response('targetId required', { status: 400, headers: corsHeaders });
const relay = userRelays.get(userId);
if (relay && extensionConnected(relay)) {
void sendToExtension(relay, {
id: relay.nextExtensionId++,
method: 'forwardCDPCommand',
params: { method: 'Target.closeTarget', params: { targetId } },
}).catch(() => {});
}
return new Response('OK', { status: 200, headers: corsHeaders });
}
// WebSocket upgrades
if (path === '/extension') {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const relay = getUserRelay(userId);
// Close existing extension WS (stale or active) — last connection wins
if (relay.extensionWs) {
try {
relay.extensionWs.close(4000, 'replaced by new connection');
} catch {
// ignore
}
relay.extensionWs = null;
}
const ok = server.upgrade(req, { data: { kind: 'extension', userId, token: token! } });
if (!ok) return new Response('Upgrade failed', { status: 500, headers: corsHeaders });
return undefined as unknown as Response;
}
if (path === '/cdp') {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const relay = userRelays.get(userId);
if (!relay || !extensionConnected(relay)) {
return new Response('Extension not connected', { status: 503, headers: corsHeaders });
}
const ok = server.upgrade(req, { data: { kind: 'cdp', userId, token: token! } });
if (!ok) return new Response('Upgrade failed', { status: 500, headers: corsHeaders });
return undefined as unknown as Response;
}
return new Response('Not found', { status: 404, headers: corsHeaders });
},
websocket: {
open(ws) {
const { kind, userId } = ws.data;
const relay = getUserRelay(userId);
if (kind === 'extension') {
handleExtensionOpen(relay, ws);
} else {
relay.cdpClients.add(ws);
}
},
message(ws, raw) {
const { kind, userId } = ws.data;
const relay = userRelays.get(userId);
if (!relay) return;
const data = typeof raw === 'string' ? raw : new TextDecoder().decode(raw as unknown as ArrayBuffer);
if (kind === 'extension') {
handleExtensionMessage(relay, data);
} else {
void handleCdpMessage(relay, ws, data);
}
},
close(ws) {
const { kind, userId } = ws.data;
const relay = userRelays.get(userId);
if (!relay) return;
if (kind === 'extension') {
handleExtensionClose(relay, ws);
} else {
relay.cdpClients.delete(ws);
}
},
},
});
relayServer = server;
return server;
}
export function stopBrowserRelay() {
if (!relayServer) return;
for (const [, relay] of userRelays) {
clearGracePeriodTimer(relay);
if (relay.pingInterval) clearInterval(relay.pingInterval);
flushExtensionReconnectWaiters(relay, false);
for (const [, pending] of relay.pendingExtension) {
clearTimeout(pending.timer);
pending.reject(new Error('server stopping'));
}
relay.pendingExtension.clear();
try {
relay.extensionWs?.close(1001, 'server stopping');
} catch {
// ignore
}
for (const ws of relay.cdpClients) {
try {
ws.close(1001, 'server stopping');
} catch {
// ignore
}
}
}
userRelays.clear();
relayServer.stop();
relayServer = null;
}
+117
View File
@@ -0,0 +1,117 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router';
import { Globe, Loader2, X, Eye } from 'lucide-react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
import { browserTabPath, useBrowserTargets, useSelectedTabId, type BrowserTarget } from './shared';
export const TabList = () => {
const client = useClient();
const selectedId = useSelectedTabId();
const { data: status } = useQuery({
queryKey: ['browser-status'],
queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'),
refetchInterval: 5000,
});
const { data: targets, isLoading } = useBrowserTargets(!!status?.extensionConnected);
const targetList: BrowserTarget[] = targets ?? [];
const handleClose = async (targetId: string) => {
try {
await client.post(`/browser/targets/${targetId}/close`);
} catch {
toast.error('Failed to close tab');
}
};
const handleActivate = async (targetId: string) => {
try {
await client.post(`/browser/targets/${targetId}/activate`);
} catch {
toast.error('Failed to activate tab');
}
};
const isConnected = status?.extensionConnected ?? false;
return (
<div className="flex h-full flex-col overflow-y-auto">
<div className="flex items-center gap-2 border-b px-3 py-2">
<Globe className="h-4 w-4 opacity-60" />
<span className="text-sm font-medium">Browser</span>
<div
className={`ml-auto h-2 w-2 rounded-full ${isConnected ? 'bg-success' : 'bg-red-500'}`}
title={isConnected ? 'Extension connected' : 'Extension not connected'}
/>
</div>
{!isConnected && (
<div className="flex flex-col items-center justify-center gap-3 p-4 text-sm flex-1">
<Globe className="h-8 w-8 opacity-30" />
<p className="opacity-60 text-center">Extension not connected</p>
{/* A <Link>, not an <a href>: a raw href to an in-app route reloads the whole SPA. */}
<Link to="/settings/integrations" className="text-xs text-duck-teal underline">
Set up in Integrations
</Link>
</div>
)}
{isConnected && isLoading && (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin opacity-50" />
</div>
)}
{isConnected && !isLoading && targetList.length === 0 && (
<div className="flex h-32 flex-col items-center justify-center gap-2 text-sm opacity-50">
<Globe className="h-6 w-6" />
<span>No tabs attached</span>
<span className="text-xs">Click the extension icon on a tab</span>
</div>
)}
{isConnected && targetList.length > 0 && (
<div className="flex flex-1 flex-col divide-y divide-white/10">
{/* Focus and Close are siblings of the anchor, not children of it: an <a> may not contain a
<button>, and the old row was a <button> containing two more — invalid either way, and it
was only the stopPropagation calls holding it together. */}
{targetList.map((target) => (
<div
key={target.id}
className={`group relative flex transition-colors ${
selectedId === target.id ? 'bg-accent' : 'hover:bg-accent/50'
}`}
>
<Link
to={browserTabPath(target.id)}
className="flex min-w-0 flex-1 flex-col gap-0.5 px-3 py-2.5 text-left"
>
<span className="truncate pr-14 text-sm font-medium">{target.title || 'Untitled'}</span>
<span className="truncate text-xs opacity-50">{target.url}</span>
</Link>
<div className="absolute right-3 top-2.5 flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => handleActivate(target.id)}
className="rounded p-0.5 hover:bg-black/10 cursor-pointer"
title="Focus tab"
>
<Eye className="h-3.5 w-3.5" />
</button>
<button
onClick={() => handleClose(target.id)}
className="rounded p-0.5 hover:bg-black/10 cursor-pointer"
title="Close tab"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};
+174
View File
@@ -0,0 +1,174 @@
import { useState, useCallback } from 'react';
import { Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { Globe, RefreshCw, Send, Terminal, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useBrowserTargets, useSelectedTabId } from './shared';
export const TabPreview = () => {
const client = useClient();
// Reads the URL itself rather than being told by the list.
const selectedId = useSelectedTabId();
const { data: targets } = useBrowserTargets(!!selectedId);
// A target id dies with the browser session, so a link or a reload can name a tab that is no longer
// attached. Screenshotting it would 404 every ten seconds; better to say so once.
const isKnownTab = !!selectedId && !!targets?.some((t) => t.id === selectedId);
const [navUrl, setNavUrl] = useState('');
const [evalExpr, setEvalExpr] = useState('');
const [evalResult, setEvalResult] = useState<string | null>(null);
const [isNavigating, setIsNavigating] = useState(false);
const [isEvaluating, setIsEvaluating] = useState(false);
const {
data: screenshot,
isLoading,
refetch,
isFetching,
} = useQuery({
queryKey: ['browser-screenshot', selectedId],
queryFn: () => client.get<{ data: string; format: string }>(`/browser/targets/${selectedId}/screenshot`),
enabled: isKnownTab,
refetchInterval: 10000,
});
const handleNavigate = useCallback(async () => {
if (!selectedId || !navUrl.trim()) return;
setIsNavigating(true);
try {
await client.post(`/browser/targets/${selectedId}/navigate`, { url: navUrl.trim() });
setTimeout(() => void refetch(), 1000);
} catch {
toast.error('Navigation failed');
} finally {
setIsNavigating(false);
}
}, [client, selectedId, navUrl, refetch]);
const handleEvaluate = useCallback(async () => {
if (!selectedId || !evalExpr.trim()) return;
setIsEvaluating(true);
try {
const res = await client.post<{ result: unknown }>(`/browser/targets/${selectedId}/evaluate`, {
expression: evalExpr.trim(),
});
setEvalResult(JSON.stringify(res.result, null, 2));
} catch (err) {
setEvalResult(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setIsEvaluating(false);
}
}, [client, selectedId, evalExpr]);
if (!selectedId) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
<Globe className="h-8 w-8" />
<span>Select a tab to preview</span>
</div>
);
}
// Waiting on the list is not the same as knowing the tab is gone, so only say so once it has arrived.
if (targets && !isKnownTab) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
<Globe className="h-8 w-8" />
<span>That tab is no longer attached</span>
<Link to="/browser" className="text-xs underline">
Back to the tab list
</Link>
</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden">
{/* URL bar */}
<div className="flex items-center gap-2 border-b px-3 py-2">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
onClick={() => void refetch()}
disabled={isFetching}
>
{isFetching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
</Button>
<form
className="flex flex-1 items-center gap-2"
onSubmit={(ev) => {
ev.preventDefault();
void handleNavigate();
}}
>
<input
type="text"
value={navUrl}
onChange={(ev) => setNavUrl(ev.target.value)}
placeholder="Navigate to URL..."
className="flex-1 rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus:border-cyan-500"
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0"
type="submit"
disabled={isNavigating || !navUrl.trim()}
>
<Send className="h-3.5 w-3.5" />
</Button>
</form>
</div>
{/* Screenshot */}
<div className="flex-1 overflow-auto p-2">
{isLoading && !screenshot && (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin opacity-50" />
</div>
)}
{screenshot && (
<img
src={`data:image/${screenshot.format};base64,${screenshot.data}`}
alt="Tab screenshot"
className="w-full rounded-md border"
/>
)}
</div>
{/* JS Console */}
<div className="border-t">
<form
className="flex items-center gap-2 px-3 py-2"
onSubmit={(ev) => {
ev.preventDefault();
void handleEvaluate();
}}
>
<Terminal className="h-3.5 w-3.5 shrink-0 opacity-50" />
<input
type="text"
value={evalExpr}
onChange={(ev) => setEvalExpr(ev.target.value)}
placeholder="Evaluate JavaScript..."
className="flex-1 bg-transparent text-sm outline-none font-mono"
/>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 shrink-0"
type="submit"
disabled={isEvaluating || !evalExpr.trim()}
>
{isEvaluating ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Run'}
</Button>
</form>
{evalResult !== null && (
<pre className="max-h-32 overflow-auto border-t px-3 py-2 text-xs font-mono opacity-70">{evalResult}</pre>
)}
</div>
</div>
);
};
+27
View File
@@ -0,0 +1,27 @@
import type { LayoutNode } from 'officerdev';
// The tab list beside a preview, with chat underneath it — the shape the screen had before the
// extraction, with the `appType: null` placeholders replaced by the panels' real keys.
//
// Chat is deliberately still here. It is the platform's own panel, not this plugin's, and the point of
// the layout is driving a browser while talking to an agent about what is on it.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'browser-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'browser-tabs', appType: 'officerdev/browser-tabs' }, size: 25 },
{
node: {
type: 'group',
id: 'browser-right',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'browser-preview', appType: 'officerdev/browser-preview' }, size: 60 },
{ node: { type: 'panel', id: 'browser-chat', appType: 'officerdev/chat' }, size: 40 },
],
},
size: 75,
},
],
};
+25
View File
@@ -0,0 +1,25 @@
// `panels.ts`, not `.tsx` — discovery checks for that exact name.
import type { AppRegistryMeta } from 'officerdev';
import { Globe, LayoutList } from 'lucide-react';
import { TabList } from './TabList';
import { TabPreview } from './TabPreview';
// Two panels, both self-contained — they read their own data and take no props, which is why the screen
// that used to render them (`BrowserScreen.tsx`, with `appType: null` placeholders) had nothing to pass
// and could simply be deleted. The Workspace hosts them directly now.
export const appRegistryMetas: AppRegistryMeta[] = [
{
key: 'officerdev/browser-tabs',
name: 'Tabs',
icon: LayoutList,
component: TabList,
availableOnPanel: false,
},
{
key: 'officerdev/browser-preview',
name: 'Preview',
icon: Globe,
component: TabPreview,
availableOnPanel: false,
},
];
+34
View File
@@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'react-router';
import { useClient } from 'hooks/useClient';
// Which attached tab is being previewed is `/browser/:tabId`. It used to be a `BROWSER_SELECTED_TAB`
// global read by three components, which meant the address bar said `/browser` no matter what you were
// looking at, and a tab row was a `<button>` with the id in its onClick closure.
export type BrowserTarget = {
id: string;
sessionId: string;
type: string;
title: string;
url: string;
};
export const browserTabPath = (id: string) => `/browser/${encodeURIComponent(id)}`;
/** The previewed tab, or null on the bare `/browser` route — the list with nothing open. */
export const useSelectedTabId = (): string | null => useParams<{ tabId?: string }>().tabId ?? null;
// Both panels want this list and React Query serves both observers from one key, so the preview being
// able to tell "no tab open" from "that tab is gone" costs no extra request. It needs to: a CDP target
// id dies with the browser session, and now that the id is in the URL a reload can carry a stale one in
// — which the old global, living in memory, could never do.
export const useBrowserTargets = (enabled: boolean) => {
const client = useClient();
return useQuery({
queryKey: ['browser-targets'],
queryFn: () => client.get<BrowserTarget[]>('/browser/targets'),
refetchInterval: 3000,
enabled,
});
};