diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx index 357be760..d660712d 100644 --- a/src/apps/officer-web/App.tsx +++ b/src/apps/officer-web/App.tsx @@ -120,10 +120,7 @@ export function App() { } /> } /> } /> - } /> - } /> } /> - } /> } /> } /> diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx deleted file mode 100644 index fa80f0ca..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Browser/BrowserScreen.tsx +++ /dev/null @@ -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 = () => ; - -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('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 ( -
- onMobileBack() : undefined} - /> -
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/TabList.tsx b/src/apps/officer-web/Screens/Dashboard/Browser/TabList.tsx deleted file mode 100644 index a5712f11..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Browser/TabList.tsx +++ /dev/null @@ -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 ( -
-
- - Browser -
-
- - {!isConnected && ( - - )} - - {isConnected && isLoading && ( -
- -
- )} - - {isConnected && !isLoading && targetList.length === 0 && ( -
- - No tabs attached - Click the extension icon on a tab -
- )} - - {isConnected && targetList.length > 0 && ( -
-
- ))} -
- )} - - ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx b/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx deleted file mode 100644 index cd4dec04..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Browser/TabPreview.tsx +++ /dev/null @@ -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(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 ( -
- - Select a tab to preview -
- ); - } - - // 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 ( -
- - That tab is no longer attached - - Back to the tab list - -
- ); - } - - return ( -
- {/* URL bar */} -
- -
{ - ev.preventDefault(); - void handleNavigate(); - }} - > - 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" - /> - -
-
- - {/* Screenshot */} -
- {isLoading && !screenshot && ( -
- -
- )} - {screenshot && ( - Tab screenshot - )} -
- - {/* JS Console */} -
-
{ - ev.preventDefault(); - void handleEvaluate(); - }} - > - - setEvalExpr(ev.target.value)} - placeholder="Evaluate JavaScript..." - className="flex-1 bg-transparent text-sm outline-none font-mono" - /> - - - {evalResult !== null && ( -
{evalResult}
- )} -
-
- ); -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Browser/defaultLayout.ts deleted file mode 100644 index 91243c09..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Browser/defaultLayout.ts +++ /dev/null @@ -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, - }, - ], -}; diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/index.tsx b/src/apps/officer-web/Screens/Dashboard/Browser/index.tsx deleted file mode 100644 index d31dc96a..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Browser/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { BrowserScreen } from './BrowserScreen'; diff --git a/src/apps/officer-web/Screens/Dashboard/Browser/shared.ts b/src/apps/officer-web/Screens/Dashboard/Browser/shared.ts deleted file mode 100644 index add75374..00000000 --- a/src/apps/officer-web/Screens/Dashboard/Browser/shared.ts +++ /dev/null @@ -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 ` - - -
- Default port: 18792. Extension connects to: http://<host>:<port>/. -
-
- - - - - - - diff --git a/src/extensions/browser-relay/options.js b/src/extensions/browser-relay/options.js deleted file mode 100644 index e14a9028..00000000 --- a/src/extensions/browser-relay/options.js +++ /dev/null @@ -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() diff --git a/src/server.tsx b/src/server.tsx index 53f3144b..76fa9bda 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -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 { diff --git a/src/servers/api/browser/cdp.ts b/src/servers/api/browser/cdp.ts deleted file mode 100644 index 5030ab95..00000000 --- a/src/servers/api/browser/cdp.ts +++ /dev/null @@ -1,99 +0,0 @@ -type CdpCommandOptions = { - relayPort: number; - userToken: string; - targetId?: string; -}; - -async function sendCdpCommand(opts: CdpCommandOptions, method: string, params?: unknown): Promise { - const { relayPort, userToken, targetId } = opts; - const url = `ws://127.0.0.1:${relayPort}/cdp?token=${encodeURIComponent(userToken)}`; - - return await new Promise((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 = { 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 { - const params: Record = { 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 { - 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 { - 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: '' }; - } -} diff --git a/src/servers/api/browser/relay-auth.ts b/src/servers/api/browser/relay-auth.ts deleted file mode 100644 index 729dff98..00000000 --- a/src/servers/api/browser/relay-auth.ts +++ /dev/null @@ -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(); - -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'; -} diff --git a/src/servers/api/browser/relay-types.ts b/src/servers/api/browser/relay-types.ts deleted file mode 100644 index 21525a21..00000000 --- a/src/servers/api/browser/relay-types.ts +++ /dev/null @@ -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; - connectedTargets: Map; - 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>; -}; diff --git a/src/servers/api/browser/relay.ts b/src/servers/api/browser/relay.ts deleted file mode 100644 index 27b21886..00000000 --- a/src/servers/api/browser/relay.ts +++ /dev/null @@ -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 | null; - cdpClients: Set>; - connectedTargets: Map; - pingInterval: Timer | null; - gracePeriodTimer: Timer | null; - pendingExtension: Map void; reject: (e: Error) => void; timer: Timer }>; - nextExtensionId: number; - extensionReconnectWaiters: Set<(connected: boolean) => void>; -}; - -const userRelays = new Map(); - -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 { - if (extensionConnected(relay)) return true; - return await new Promise((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 { - 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((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, res: CdpResponse) { - if (ws.readyState !== WebSocket.OPEN) return; - ws.send(JSON.stringify(res)); -} - -function ensureTargetEventsForClient(relay: UserRelay, ws: ServerWebSocket, 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 { - 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) { - 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) { - 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, 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 | 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({ - 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 = {}; - 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 = { - 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; -} diff --git a/src/servers/api/browser/router.ts b/src/servers/api/browser/router.ts deleted file mode 100644 index 148958c1..00000000 --- a/src/servers/api/browser/router.ts +++ /dev/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'); - } -}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index b20a823e..1c4cf3c2 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -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 ['/dock', dockRouter], ['/integrations', integrationsRouter], ['/queue', queueRouter], - // ['/browser', browserRouter], // switched off — see server.tsx ['/bug-report', bugReportRouter], ['/agent-status', agentStatusRouter], ['/chat', chatRouter], diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 77d74621..fe1f0f45 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -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 ──────────────────────────────────────────────────── diff --git a/src/servers/plugins/marketplace.ts b/src/servers/plugins/marketplace.ts index d0f309bd..46433b58 100644 --- a/src/servers/plugins/marketplace.ts +++ b/src/servers/plugins/marketplace.ts @@ -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',