the browser relay leaves, and /scrape stops pretending to be it
plugins/browser now owns the extension, the relay, the CDP bridge, the pairing routes and the two panels. All of it was on disk and switched off since 2026-08-13 — nothing was recovered from history, and nothing regressed, because none of it had been running. What the platform keeps is /scrape, and the two were never one feature split in half. Scrape launches a headless chromium through playwright as the OWNER and knows nobody; the plugin attaches to the browser a person is already signed into, scoped per pairing. The permission is renamed "Scraping" so the registry stops describing one thing in the other's words, and it drops the /browser frontend route it could no longer serve. Two dead screens went with it. BrowserScreen and DesktopScreen were officer-web screens rendering panels through appType placeholders — a plugin supplies its own route and panels through Plugins.gen.tsx, so both were redundant the moment their plugins existed. DesktopScreen was a leftover from this morning's extraction that typechecked cleanly while rendering a panel the AppRegistry no longer had: dead at runtime, invisible to the compiler. The long comment in server.tsx explaining BROWSER_RELAY_PORT goes too, replaced by a pointer to where it lives now. It was written for whoever did this extraction, and this is that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -120,10 +120,7 @@ export function App() {
|
||||
<Route path="/jobs/:id" element={<Dashboard.JobsPage />} />
|
||||
<Route path="/dashboards" element={<Dashboard.DashboardsScreen />} />
|
||||
<Route path="/dashboards/:id" element={<Dashboard.DashboardScreen />} />
|
||||
<Route path="/browser" element={<Dashboard.BrowserScreen />} />
|
||||
<Route path="/browser/:tabId" element={<Dashboard.BrowserScreen />} />
|
||||
<Route path="/terminal" element={<Dashboard.TerminalScreen />} />
|
||||
<Route path="/desktop" element={<Dashboard.DesktopScreen />} />
|
||||
<Route path="/auth/signout" element={<Authentication.SignoutScreen />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceView, ChatPanelWrapper } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
import { TabList } from './TabList';
|
||||
import { TabPreview } from './TabPreview';
|
||||
import { useSelectedTabId } from './shared';
|
||||
|
||||
const PROMPT_PREFIX = `You are a browser assistant. The user has connected Chrome tabs via the Officer Browser Relay. You have a 'browser' tool — use it to list tabs, take screenshots, navigate, evaluate JavaScript, and get page info. When asked about a page, take a screenshot first.`;
|
||||
|
||||
// This screen's prompt, so this screen's chat panel. See the note in `EmailScreen`.
|
||||
const BrowserChatPanel = () => <ChatPanelWrapper panelId="browser-chat" promptPrefix={PROMPT_PREFIX} />;
|
||||
|
||||
export const BrowserScreen = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
// No redirect guard: like /email, the bare /browser is the list with nothing open, and a tab id that
|
||||
// no longer resolves gets the preview's "no longer attached" state rather than a rewritten address.
|
||||
const selectedId = useSelectedTabId();
|
||||
const workspace = useDashboardState<LayoutNode>('screens/browser', defaultLayout);
|
||||
|
||||
const components: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'browser-tabs': TabList,
|
||||
'browser-preview': TabPreview,
|
||||
'browser-chat': BrowserChatPanel,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const mobilePanelId = isMobile && selectedId ? 'browser-preview' : undefined;
|
||||
const onMobileBack = useCallback(() => navigate('/browser'), [navigate]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full pt-2">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
components={components}
|
||||
mobilePanelId={mobilePanelId}
|
||||
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,117 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,174 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'browser-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'browser-tabs', appType: null }, size: 25 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'browser-right',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'browser-preview', appType: null }, size: 60 },
|
||||
{ node: { type: 'panel', id: 'browser-chat', appType: 'officerdev/chat' }, size: 40 },
|
||||
],
|
||||
},
|
||||
size: 75,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { BrowserScreen } from './BrowserScreen';
|
||||
@@ -1,34 +0,0 @@
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
export const DesktopScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/desktop', defaultLayout);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<WorkspaceView
|
||||
workspace={workspace}
|
||||
locked
|
||||
appTypes={{ allowed: ['officerdev/desktop'], fallback: 'officerdev/desktop' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'desktop-screen',
|
||||
appType: 'officerdev/desktop',
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './DesktopScreen';
|
||||
@@ -20,7 +20,5 @@ export * from './CodeEditor';
|
||||
export * from './ChatHistory';
|
||||
export * from './Dashboards';
|
||||
export * from './Terminal';
|
||||
export * from './Browser';
|
||||
export * from './Desktop';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,978 +0,0 @@
|
||||
// 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.
|
Before Width: | Height: | Size: 614 B |
Binary file not shown.
|
Before Width: | Height: | Size: 265 B |
Binary file not shown.
|
Before Width: | Height: | Size: 318 B |
Binary file not shown.
|
Before Width: | Height: | Size: 370 B |
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"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 }
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// 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.`,
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
<!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">🌐</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://<host>:<port>/</code>.
|
||||
</div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="options.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,77 +0,0 @@
|
||||
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()
|
||||
+6
-24
@@ -23,7 +23,6 @@ import { startNudger } from './servers/api/chat/nudger';
|
||||
import { taskRunnerWebsocket } from './servers/api/tasks/task-executor';
|
||||
import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor';
|
||||
import officerWeb from './apps/officer-web/index.gen.html';
|
||||
// import { startBrowserRelay } from './servers/api/browser/relay'; // switched off — see below
|
||||
import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry';
|
||||
import { sendProvisionableUsers } from './servers/plugins/user-provisioning';
|
||||
import './servers/api/chat/opencode/sidecar-server'; // subscribe to the opencode sidecar's port report
|
||||
@@ -512,30 +511,13 @@ console.log(`🚀 Server running at ${server.url}`);
|
||||
//
|
||||
// The Chrome extension, its CDP bridge (api/browser/) and the /browser screen all remain on disk: this
|
||||
// is going to be extracted into a plugin, and deleting it would mean writing it again. What is switched
|
||||
// off is the second listener and the /api/browser mount (see hono.ts) — the platform serves one port.
|
||||
// The browser relay left for `plugins/browser` on 2026-08-21. It ran a SECOND listener from this process,
|
||||
// which is why it was switched off here on 2026-08-13 — the platform serves one port. As a plugin it is a
|
||||
// sidecar, which is a process allowed its own listeners, so the objection no longer applies.
|
||||
//
|
||||
// It used to read BROWSER_RELAY_PORT, default 18792. The name is recorded here because there is nothing
|
||||
// left to grep for, and whoever does the extraction needs to know what it was called.
|
||||
//
|
||||
// ── Read this before turning it back on ──
|
||||
//
|
||||
// The port is not only configuration. relay-auth.ts derives each extension's token as
|
||||
// HMAC(JWT_SECRET, `officer-browser-relay-v1:${port}:${userId}:${salt}`), so the port is an INPUT TO A
|
||||
// CREDENTIAL. Bringing the relay back on a different number silently invalidates every paired browser,
|
||||
// and the extension reports it as "Relay not reachable" — which SETUP.md attributes to a wrong address,
|
||||
// port or token. Re-pairing means re-copying from Settings → Browser Relay.
|
||||
//
|
||||
// Whatever it comes back as, it cannot be a kernel-assigned port:0 the way the other sidecars are. The
|
||||
// extension is configured by hand and stores the value, so a port that changes each restart breaks the
|
||||
// pairing every restart. It has to be predictable — PORT + 2 is the shape the Anthropic proxy already
|
||||
// uses for the same reason.
|
||||
//
|
||||
// try {
|
||||
// await startBrowserRelay(BROWSER_RELAY_PORT);
|
||||
// console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`);
|
||||
// } catch (err) {
|
||||
// console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
|
||||
// }
|
||||
// The port and everything that depended on it moved with it, including the reason it cannot be
|
||||
// kernel-assigned: the extension stores the number by hand AND the number is an input to the token HMAC.
|
||||
// See plugins/browser/sidecar/protocol.ts.
|
||||
|
||||
// Initialize queue engine in API server process
|
||||
import {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
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: '' };
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { getKey } from 'officerdb/secret-store';
|
||||
|
||||
const RELAY_TOKEN_CONTEXT = 'officer-browser-relay-v1';
|
||||
|
||||
// Nothing imports this file today — the browser relay is switched off pending extraction into a plugin
|
||||
// (see server.tsx). Updated with everything else so it is coherent when it comes back: it read
|
||||
// JWT_SECRET from the environment, which no longer exists, and would have thrown at module load.
|
||||
//
|
||||
// When it is extracted it should take its own purpose rather than borrowing 'jwt' — a plugin deriving
|
||||
// tokens from the platform's signing key is the coupling the per-purpose split exists to remove.
|
||||
|
||||
export function deriveRelayToken(userId: number, port: number, salt: string): string {
|
||||
return createHmac('sha256', getKey('jwt')).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';
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
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>;
|
||||
};
|
||||
@@ -1,677 +0,0 @@
|
||||
// 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 { getIntegrationsByProvider } from 'officerdb';
|
||||
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;
|
||||
|
||||
// Restore persisted relay tokens from DB
|
||||
const integrations = await getIntegrationsByProvider('browser-relay');
|
||||
const entries = integrations
|
||||
.map((i) => ({ userId: i.userId, salt: (i.config as { tokenSalt?: string })?.tokenSalt }))
|
||||
.filter((e): e is { userId: number; salt: string } => Boolean(e.salt));
|
||||
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;
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { getUserIntegration, upsertUserIntegration, deleteUserIntegration } from 'officerdb';
|
||||
import { deriveRelayToken, registerUserToken, unregisterUserToken } from './relay-auth';
|
||||
import { getRelayPort, getUserRelayStatus, getUserTargets } from './relay';
|
||||
import { captureScreenshot, evaluateJS, navigateTo } from './cdp';
|
||||
|
||||
export const browserRouter = createRouter();
|
||||
|
||||
async function getOpts(userId: number) {
|
||||
const port = getRelayPort();
|
||||
const integration = await getUserIntegration(userId, 'browser-relay');
|
||||
const salt = (integration?.config as { tokenSalt?: string } | null)?.tokenSalt ?? randomUUID();
|
||||
if (!integration) {
|
||||
await upsertUserIntegration({ userId, provider: 'browser-relay', config: { tokenSalt: salt } });
|
||||
}
|
||||
const token = registerUserToken(userId, port, salt);
|
||||
return { relayPort: port, userToken: token };
|
||||
}
|
||||
|
||||
// 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/.
|
||||
|
||||
browserRouter.get('/status', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const status = getUserRelayStatus(user.id);
|
||||
return ctx.json(status);
|
||||
});
|
||||
|
||||
browserRouter.get('/relay-token', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const port = getRelayPort();
|
||||
let integration = await getUserIntegration(user.id, 'browser-relay');
|
||||
if (!integration) {
|
||||
const salt = randomUUID();
|
||||
integration = await upsertUserIntegration({
|
||||
userId: user.id,
|
||||
provider: 'browser-relay',
|
||||
config: { tokenSalt: salt },
|
||||
});
|
||||
}
|
||||
const salt = (integration.config as { tokenSalt: string }).tokenSalt;
|
||||
const token = registerUserToken(user.id, port, salt);
|
||||
return ctx.json({ token, port });
|
||||
});
|
||||
|
||||
browserRouter.post('/relay-token/regenerate', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const port = getRelayPort();
|
||||
const oldIntegration = await getUserIntegration(user.id, 'browser-relay');
|
||||
if (oldIntegration) {
|
||||
const oldSalt = (oldIntegration.config as { tokenSalt?: string })?.tokenSalt;
|
||||
if (oldSalt) {
|
||||
const oldToken = deriveRelayToken(user.id, port, oldSalt);
|
||||
unregisterUserToken(oldToken);
|
||||
}
|
||||
}
|
||||
const newSalt = randomUUID();
|
||||
await upsertUserIntegration({ userId: user.id, provider: 'browser-relay', config: { tokenSalt: newSalt } });
|
||||
const token = registerUserToken(user.id, port, newSalt);
|
||||
return ctx.json({ token, port });
|
||||
});
|
||||
|
||||
browserRouter.delete('/relay-token', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const port = getRelayPort();
|
||||
const integration = await getUserIntegration(user.id, 'browser-relay');
|
||||
if (integration) {
|
||||
const salt = (integration.config as { tokenSalt?: string })?.tokenSalt;
|
||||
if (salt) {
|
||||
const oldToken = deriveRelayToken(user.id, port, salt);
|
||||
unregisterUserToken(oldToken);
|
||||
}
|
||||
await deleteUserIntegration(user.id, 'browser-relay');
|
||||
}
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
browserRouter.get('/targets', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const targets = getUserTargets(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 ?? '',
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
browserRouter.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);
|
||||
// Find session ID for the target
|
||||
const targets = getUserTargets(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');
|
||||
}
|
||||
});
|
||||
|
||||
browserRouter.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 = getUserTargets(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');
|
||||
}
|
||||
});
|
||||
|
||||
browserRouter.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 = getUserTargets(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');
|
||||
}
|
||||
});
|
||||
|
||||
browserRouter.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');
|
||||
}
|
||||
});
|
||||
|
||||
browserRouter.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');
|
||||
}
|
||||
});
|
||||
@@ -39,7 +39,6 @@ import { integrationsRouter, googleCallbackHandler } from './api/integrations/in
|
||||
import { queueRouter } from './api/queue/queue';
|
||||
// The browser relay is switched off — see server.tsx. Restoring this mount means restoring the
|
||||
// registry's claim on '/browser' in the same commit, or assertPermissionTotality refuses to boot.
|
||||
// import { browserRouter } from './api/browser/router';
|
||||
import { bugReportRouter } from './api/bug-report/bug-report';
|
||||
import { agentStatusRouter } from './api/agent-status/router';
|
||||
import { chatRouter } from './api/chat/chat';
|
||||
@@ -148,7 +147,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/dock', dockRouter],
|
||||
['/integrations', integrationsRouter],
|
||||
['/queue', queueRouter],
|
||||
// ['/browser', browserRouter], // switched off — see server.tsx
|
||||
['/bug-report', bugReportRouter],
|
||||
['/agent-status', agentStatusRouter],
|
||||
['/chat', chatRouter],
|
||||
|
||||
@@ -326,19 +326,17 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// `kind: 'execution'` to a plugin's own, and the property survived the move: a plugin permission has no
|
||||
// kind, so "runs as the owner and cannot be shared" is expressed as `ownerOnly: ['/']` in the plugin's
|
||||
// manifest instead. Same answer, stated where the paths are.
|
||||
// `/browser` — the Chrome-extension relay — is unmounted as of 2026-08-13 and dropped from this claim,
|
||||
// because check 2 of assertPermissionTotality refuses to boot on a permission claiming a prefix nothing
|
||||
// serves. Put both back together or neither.
|
||||
//
|
||||
// `/scrape` is untouched and is what this permission still covers. It shares nothing with the relay: it
|
||||
// launches its own headless chromium through playwright and never speaks to the extension.
|
||||
// The relay left for `plugins/browser` on 2026-08-21, taking `/browser` and its frontend route with it.
|
||||
// What stays is `/scrape`, and the two were never the same thing wearing one word: scrape launches a
|
||||
// headless chromium through playwright as the OWNER and knows nobody, which is why this is still
|
||||
// `execution`. The plugin attaches to whoever is holding the keyboard, scoped per pairing, and is
|
||||
// grantable for exactly that reason.
|
||||
{
|
||||
key: 'browser',
|
||||
label: 'Browser',
|
||||
description: 'Drives a real browser on the host',
|
||||
label: 'Scraping',
|
||||
description: 'Fetches and renders a page in a headless browser, as the server owner',
|
||||
kind: 'execution',
|
||||
api: ['/scrape'],
|
||||
routes: ['/browser'],
|
||||
},
|
||||
|
||||
// ── admin: the platform administering itself ────────────────────────────────────────────────────
|
||||
|
||||
@@ -191,6 +191,27 @@ const BUNDLED: CatalogueEntry[] = [
|
||||
// favourites and the cached browse tree are this plugin's own three tables.
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'browser',
|
||||
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',
|
||||
source: 'https://gitea.officer.dev/plugins/browser.git',
|
||||
permissions: [
|
||||
{
|
||||
key: 'browser-relay',
|
||||
label: 'Browser',
|
||||
description: 'Pair a browser and drive its tabs — screenshots, navigation, evaluated scripts',
|
||||
},
|
||||
],
|
||||
// No host packages: the relay is Bun, and Chrome is on whatever machine the person is sitting at
|
||||
// rather than necessarily this one.
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'desktop',
|
||||
publisher: 'officerdev',
|
||||
|
||||
Reference in New Issue
Block a user