browser relay: multi-session override, stale session recovery, better error feedback
- relay accepts new extension connections by closing old one (last wins, code 4000) - extension recognizes code 4000 and stops auto-reconnect (shows "replaced" badge) - fix ping interval race where old WS close handler killed new connection's pings - retry CDP commands on "Session with given id not found" by re-attaching debugger - describeError() maps known errors to user-friendly badge tooltips - persisted relay tokens restored on server start - user-scoped token salts, token regeneration/deletion endpoints - extension download endpoint, integrations UI for browser relay setup - browser relay env vars passed to pi-bridge sandboxes - browser screen layout with chat panel and prompt prefix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@ import { defaultLayout } from './defaultLayout';
|
||||
import { TabList } from './TabList';
|
||||
import { TabPreview } from './TabPreview';
|
||||
|
||||
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.`;
|
||||
|
||||
export const BrowserScreen = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
|
||||
@@ -30,6 +32,7 @@ export const BrowserScreen = () => {
|
||||
workspace={workspace}
|
||||
locked
|
||||
components={components}
|
||||
promptPrefix={PROMPT_PREFIX}
|
||||
mobilePanelId={mobilePanelId}
|
||||
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Globe, Copy, Check, Loader2, X, Eye } from 'lucide-react';
|
||||
import { Globe, Loader2, X, Eye } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useGlobal } from 'hooks/useGlobal';
|
||||
|
||||
type RelayToken = {
|
||||
token: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type Target = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
@@ -22,7 +15,6 @@ type Target = {
|
||||
export const TabList = () => {
|
||||
const client = useClient();
|
||||
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['browser-status'],
|
||||
@@ -30,11 +22,6 @@ export const TabList = () => {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: tokenData } = useQuery({
|
||||
queryKey: ['browser-relay-token'],
|
||||
queryFn: () => client.get<RelayToken>('/browser/relay-token'),
|
||||
});
|
||||
|
||||
const { data: targets, isLoading } = useQuery({
|
||||
queryKey: ['browser-targets'],
|
||||
queryFn: () => client.get<Target[]>('/browser/targets'),
|
||||
@@ -44,18 +31,6 @@ export const TabList = () => {
|
||||
|
||||
const targetList: Target[] = targets ?? [];
|
||||
|
||||
const handleCopyToken = async () => {
|
||||
if (!tokenData?.token) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(tokenData.token);
|
||||
setCopied(true);
|
||||
toast.success('Token copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async (targetId: string) => {
|
||||
try {
|
||||
await client.post(`/browser/targets/${targetId}/close`);
|
||||
@@ -86,35 +61,15 @@ export const TabList = () => {
|
||||
</div>
|
||||
|
||||
{!isConnected && (
|
||||
<div className="flex flex-col gap-3 p-4 text-sm">
|
||||
<p className="opacity-60">Connect the Officer Browser Relay extension to start.</p>
|
||||
|
||||
{tokenData && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium opacity-60">Server address</span>
|
||||
<code className="text-xs">{window.location.hostname}</code>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium opacity-60">Relay port</span>
|
||||
<code className="text-xs">{tokenData.port}</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium opacity-60">Relay token</span>
|
||||
<Button variant="ghost" size="sm" className="ml-auto h-6 px-2" onClick={handleCopyToken}>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
<span className="ml-1 text-xs">{copied ? 'Copied' : 'Copy'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ol className="list-inside list-decimal space-y-1 text-xs opacity-50">
|
||||
<li>Load the extension from <code>src/extensions/browser-relay/</code></li>
|
||||
<li>Open extension options</li>
|
||||
<li>Enter the server address, port, and token</li>
|
||||
<li>Click the extension icon on a tab</li>
|
||||
</ol>
|
||||
<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
|
||||
href="/settings/integrations"
|
||||
className="text-xs text-duck-teal underline"
|
||||
>
|
||||
Set up in Integrations
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,6 +6,17 @@ export const defaultLayout: LayoutNode = {
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'browser-tabs', appType: null }, size: 25 },
|
||||
{ node: { type: 'panel', id: 'browser-preview', appType: null }, size: 75 },
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type RelayToken = {
|
||||
token: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
export const BrowserRelay = () => {
|
||||
const client = useClient();
|
||||
const queryClient = useQueryClient();
|
||||
const [copiedField, setCopiedField] = useState<string | null>(null);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['browser-status'],
|
||||
queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const { data: tokenData } = useQuery({
|
||||
queryKey: ['browser-relay-token'],
|
||||
queryFn: () => client.get<RelayToken>('/browser/relay-token'),
|
||||
});
|
||||
|
||||
const regenerate = useMutation({
|
||||
mutationFn: () => client.post<RelayToken>('/browser/relay-token/regenerate'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['browser-relay-token'] });
|
||||
toast.success('Token regenerated — update the extension with the new token');
|
||||
},
|
||||
onError: () => toast.error('Failed to regenerate token'),
|
||||
});
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: () => client.delete('/browser/relay-token'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['browser-relay-token'] });
|
||||
toast.success('Token revoked');
|
||||
},
|
||||
onError: () => toast.error('Failed to revoke token'),
|
||||
});
|
||||
|
||||
const handleCopy = async (value: string, field: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedField(field);
|
||||
toast.success('Copied to clipboard');
|
||||
setTimeout(() => setCopiedField(null), 2000);
|
||||
} catch {
|
||||
toast.error('Failed to copy');
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = status?.extensionConnected ?? false;
|
||||
const serverAddress = window.location.hostname;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{isConnected ? (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
{status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached
|
||||
{' — '}
|
||||
<a href="/browser" className="text-duck-teal underline">view tabs</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
|
||||
Connect your Chrome browser to Officer so AI agents can view and interact with your tabs.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Step 1: Install */}
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">1. Install the extension</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Download and unzip the extension, then load it in Chrome:
|
||||
</p>
|
||||
<a
|
||||
href="https://static.officer.dev/browser-relay-extension.zip"
|
||||
download
|
||||
className="flex items-center justify-center gap-2 w-full h-11 rounded-md border border-duck-dark/15 dark:border-foreground/15 text-sm font-medium hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download Extension
|
||||
</a>
|
||||
<ol className="list-decimal list-inside space-y-1.5 text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
<li>
|
||||
Open{' '}
|
||||
<code className="bg-duck-dark/5 dark:bg-foreground/5 px-1.5 py-0.5 rounded text-[11px]">chrome://extensions</code>{' '}
|
||||
in Chrome
|
||||
</li>
|
||||
<li>
|
||||
Enable <strong>Developer mode</strong> (top-right toggle)
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Load unpacked</strong> and select the unzipped folder
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Configure */}
|
||||
{tokenData && (
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">2. Configure the extension</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Open the extension options (right-click icon → Options) and enter these values:
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<CredentialRow
|
||||
label="Server address"
|
||||
value={serverAddress}
|
||||
copied={copiedField === 'host'}
|
||||
onCopy={() => handleCopy(serverAddress, 'host')}
|
||||
/>
|
||||
<CredentialRow
|
||||
label="Port"
|
||||
value={String(tokenData.port)}
|
||||
copied={copiedField === 'port'}
|
||||
onCopy={() => handleCopy(String(tokenData.port), 'port')}
|
||||
/>
|
||||
<CredentialRow
|
||||
label="Relay token"
|
||||
value={tokenData.token}
|
||||
masked
|
||||
copied={copiedField === 'token'}
|
||||
onCopy={() => handleCopy(tokenData.token, 'token')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => regenerate.mutate()}
|
||||
disabled={regenerate.isPending}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Regenerate
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => revoke.mutate()}
|
||||
disabled={revoke.isPending}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Attach */}
|
||||
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-3">
|
||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">3. Attach a tab</p>
|
||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
||||
Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan <strong>ON</strong> badge means
|
||||
the tab is connected. Then go to{' '}
|
||||
<a href="/browser" className="text-duck-teal underline inline-flex items-center gap-0.5">
|
||||
/browser <ExternalLink className="h-3 w-3" />
|
||||
</a>{' '}
|
||||
to view your tabs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CredentialRowProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
masked?: boolean;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
};
|
||||
|
||||
const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => (
|
||||
<div className="flex items-center gap-2 rounded-md bg-duck-dark/5 dark:bg-foreground/5 px-3 py-2">
|
||||
<span className="text-xs text-duck-dark/50 dark:text-foreground/50 shrink-0 w-24">{label}</span>
|
||||
<code className="flex-1 text-xs truncate">
|
||||
{masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value}
|
||||
</code>
|
||||
<button onClick={onCopy} className="shrink-0 p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer">
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Puzzle, KeyRound, UserCircle, MessageCircle } from 'lucide-react';
|
||||
import { Puzzle, KeyRound, UserCircle, MessageCircle, Globe } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
@@ -15,6 +15,7 @@ import { TelegramBotConfig } from './TelegramBotConfig';
|
||||
import { TelegramAccount } from './TelegramAccount';
|
||||
import { WhatsAppBotConfig } from './WhatsAppBotConfig';
|
||||
import { WhatsAppAccount } from './WhatsAppAccount';
|
||||
import { BrowserRelay } from './BrowserRelay';
|
||||
|
||||
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
|
||||
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
|
||||
@@ -79,6 +80,13 @@ const personalSections: SettingsSection[] = [
|
||||
description: 'Link your WhatsApp account',
|
||||
content: <WhatsAppAccount />,
|
||||
},
|
||||
{
|
||||
key: 'browser-relay',
|
||||
icon: Globe,
|
||||
title: 'Browser Relay',
|
||||
description: 'Connect your Chrome browser',
|
||||
content: <BrowserRelay />,
|
||||
},
|
||||
];
|
||||
|
||||
const IntegrationsSidebar = () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
deleteServerIntegration,
|
||||
getUserIntegrations,
|
||||
getUserIntegration,
|
||||
getIntegrationsByProvider,
|
||||
upsertUserIntegration,
|
||||
deleteUserIntegration,
|
||||
findUserByIntegrationConfig,
|
||||
|
||||
@@ -38,6 +38,10 @@ export async function getUserIntegrations(userId: number): Promise<UserIntegrati
|
||||
return db.select().from(userIntegrations).where(eq(userIntegrations.userId, userId));
|
||||
}
|
||||
|
||||
export async function getIntegrationsByProvider(provider: string): Promise<UserIntegrationSelect[]> {
|
||||
return db.select().from(userIntegrations).where(eq(userIntegrations.provider, provider));
|
||||
}
|
||||
|
||||
export async function getUserIntegration(userId: number, provider: string): Promise<UserIntegrationSelect | undefined> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
|
||||
@@ -39,6 +39,9 @@ const tabOperationLocks = new Set()
|
||||
/** @type {Set<number>} */
|
||||
const reattachPending = new Set()
|
||||
|
||||
/** @type {Set<number>} */
|
||||
const reattachingTabs = new Set()
|
||||
|
||||
let reconnectAttempt = 0
|
||||
let reconnectTimer = null
|
||||
|
||||
@@ -162,8 +165,12 @@ async function ensureRelayConnection() {
|
||||
}
|
||||
})
|
||||
|
||||
ws.onclose = () => {
|
||||
ws.onclose = (ev) => {
|
||||
if (ws !== relayWs) return
|
||||
if (ev.code === 4000) {
|
||||
onRelayReplaced()
|
||||
return
|
||||
}
|
||||
onRelayClosed('closed')
|
||||
}
|
||||
ws.onerror = () => {
|
||||
@@ -205,6 +212,30 @@ function onRelayClosed(reason) {
|
||||
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)
|
||||
@@ -534,6 +565,15 @@ async function detachTab(tabId, reason) {
|
||||
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
|
||||
@@ -576,7 +616,7 @@ async function connectOrToggleForActiveTab() {
|
||||
setBadge(tabId, 'error')
|
||||
void chrome.action.setTitle({
|
||||
tabId,
|
||||
title: 'Officer Browser Relay: relay not running (open options for setup)',
|
||||
title: `Officer Browser Relay: ${describeError(err)}`,
|
||||
})
|
||||
void maybeOpenHelpOnce()
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
@@ -660,7 +700,24 @@ async function handleForwardCdpCommand(msg) {
|
||||
? { ...debuggee, sessionId }
|
||||
: debuggee
|
||||
|
||||
return await chrome.debugger.sendCommand(debuggerSession, method, params)
|
||||
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) {
|
||||
@@ -695,6 +752,7 @@ 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)
|
||||
|
||||
+1
-1
@@ -202,7 +202,7 @@ console.log(`🚀 Server running at ${server.url}`);
|
||||
|
||||
const BROWSER_RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? '18792');
|
||||
try {
|
||||
startBrowserRelay(BROWSER_RELAY_PORT);
|
||||
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);
|
||||
|
||||
@@ -8,20 +8,31 @@ if (!JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET is required for browser relay auth');
|
||||
}
|
||||
|
||||
export function deriveRelayToken(userId: number, port: number): string {
|
||||
export function deriveRelayToken(userId: number, port: number, salt: string): string {
|
||||
return createHmac('sha256', JWT_SECRET!)
|
||||
.update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}`)
|
||||
.update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
const tokenToUser = new Map<string, number>();
|
||||
|
||||
export function registerUserToken(userId: number, port: number): string {
|
||||
const token = deriveRelayToken(userId, port);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ import type {
|
||||
ExtensionMessage,
|
||||
ExtensionPongMessage,
|
||||
} from './relay-types';
|
||||
import { getRelayAuthHeader, resolveUserFromToken } from './relay-auth';
|
||||
import { getIntegrationsByProvider } from 'officerdb';
|
||||
import { getRelayAuthHeader, resolveUserFromToken, restoreUserTokens } from './relay-auth';
|
||||
|
||||
const RELAY_AUTH_HEADER = getRelayAuthHeader();
|
||||
const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 5_000;
|
||||
@@ -315,11 +316,11 @@ function handleExtensionMessage(relay: UserRelay, data: string) {
|
||||
}
|
||||
|
||||
function handleExtensionClose(relay: UserRelay, ws: ServerWebSocket<WSData>) {
|
||||
if (relay.extensionWs !== ws) return;
|
||||
if (relay.pingInterval) {
|
||||
clearInterval(relay.pingInterval);
|
||||
relay.pingInterval = null;
|
||||
}
|
||||
if (relay.extensionWs !== ws) return;
|
||||
relay.extensionWs = null;
|
||||
for (const [, pending] of relay.pendingExtension) {
|
||||
clearTimeout(pending.timer);
|
||||
@@ -449,9 +450,18 @@ export function getUserTargets(userId: number): ConnectedTarget[] {
|
||||
return Array.from(relay.connectedTargets.values());
|
||||
}
|
||||
|
||||
export function startBrowserRelay(port: number) {
|
||||
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',
|
||||
@@ -566,18 +576,15 @@ export function startBrowserRelay(port: number) {
|
||||
if (path === '/extension') {
|
||||
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
|
||||
const relay = getUserRelay(userId);
|
||||
// Close stale extension WS
|
||||
if (relay.extensionWs && relay.extensionWs.readyState !== WebSocket.OPEN) {
|
||||
// Close existing extension WS (stale or active) — last connection wins
|
||||
if (relay.extensionWs) {
|
||||
try {
|
||||
(relay.extensionWs as ServerWebSocket<WSData>).close();
|
||||
relay.extensionWs.close(4000, 'replaced by new connection');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
relay.extensionWs = null;
|
||||
}
|
||||
if (extensionConnected(relay)) {
|
||||
return new Response('Extension already connected', { status: 409, headers: corsHeaders });
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { registerUserToken } from './relay-auth';
|
||||
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();
|
||||
|
||||
function getOpts(userId: number) {
|
||||
const EXTENSION_DIR = join(import.meta.dir, '../../../extensions/browser-relay');
|
||||
|
||||
async function getOpts(userId: number) {
|
||||
const port = getRelayPort();
|
||||
const token = registerUserToken(userId, port);
|
||||
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 };
|
||||
}
|
||||
|
||||
browserRouter.get('/extension-download', async (ctx) => {
|
||||
const proc = Bun.spawn(['zip', '-r', '-', '.'], {
|
||||
cwd: EXTENSION_DIR,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
const blob = await new Response(proc.stdout).blob();
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create zip');
|
||||
return new Response(blob, {
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': 'attachment; filename="officer-browser-relay.zip"',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
browserRouter.get('/status', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const status = getUserRelayStatus(user.id);
|
||||
@@ -21,10 +48,48 @@ browserRouter.get('/status', async (ctx) => {
|
||||
browserRouter.get('/relay-token', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const port = getRelayPort();
|
||||
const token = registerUserToken(user.id, port);
|
||||
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);
|
||||
@@ -44,7 +109,7 @@ browserRouter.get('/targets/:id/screenshot', async (ctx) => {
|
||||
const targetId = ctx.req.param('id');
|
||||
const format = (ctx.req.query('format') as 'png' | 'jpeg') || 'png';
|
||||
|
||||
const opts = getOpts(user.id);
|
||||
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);
|
||||
@@ -65,7 +130,7 @@ browserRouter.post('/targets/:id/evaluate', async (ctx) => {
|
||||
|
||||
if (!body.expression) throw errors.BAD_REQUEST('expression is required');
|
||||
|
||||
const opts = getOpts(user.id);
|
||||
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');
|
||||
@@ -85,7 +150,7 @@ browserRouter.post('/targets/:id/navigate', async (ctx) => {
|
||||
|
||||
if (!body.url) throw errors.BAD_REQUEST('url is required');
|
||||
|
||||
const opts = getOpts(user.id);
|
||||
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');
|
||||
@@ -102,12 +167,11 @@ browserRouter.post('/targets/:id/activate', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const targetId = ctx.req.param('id');
|
||||
|
||||
const port = getRelayPort();
|
||||
const token = registerUserToken(user.id, port);
|
||||
const { relayPort, userToken } = await getOpts(user.id);
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/json/activate/${encodeURIComponent(targetId)}`, {
|
||||
headers: { 'x-officer-relay-token': token },
|
||||
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 });
|
||||
@@ -120,12 +184,11 @@ browserRouter.post('/targets/:id/close', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const targetId = ctx.req.param('id');
|
||||
|
||||
const port = getRelayPort();
|
||||
const token = registerUserToken(user.id, port);
|
||||
const { relayPort, userToken } = await getOpts(user.id);
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/json/close/${encodeURIComponent(targetId)}`, {
|
||||
headers: { 'x-officer-relay-token': token },
|
||||
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 });
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ensureDockerContainer } from "../terminal/websocket";
|
||||
import { getServerIntegration, getUserIntegration } from "officerdb";
|
||||
import { logger } from "./logger";
|
||||
import { parseFrontmatter } from "../skills/skills";
|
||||
import { getRelayPort } from "../browser/relay";
|
||||
import { registerUserToken } from "../browser/relay-auth";
|
||||
|
||||
export type PiEventHandler = (event: PiEvent) => void;
|
||||
|
||||
@@ -188,6 +190,23 @@ async function ensureGoogleTokenFile(userId: number, email: string): Promise<str
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function getBrowserRelayEnv(userId: number): Promise<Record<string, string>> {
|
||||
const port = getRelayPort();
|
||||
if (!port) return {};
|
||||
try {
|
||||
const integration = await getUserIntegration(userId, 'browser-relay');
|
||||
const salt = (integration?.config as { tokenSalt?: string })?.tokenSalt;
|
||||
if (!salt) return {};
|
||||
const token = registerUserToken(userId, port, salt);
|
||||
return {
|
||||
OFFICER_BROWSER_RELAY_PORT: String(port),
|
||||
OFFICER_BROWSER_RELAY_TOKEN: token,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
type SandboxOptions = {
|
||||
userId: number;
|
||||
username: string;
|
||||
@@ -245,6 +264,7 @@ export async function spawnPi(
|
||||
|
||||
const googleConfigHost = await ensureGoogleConfigFile();
|
||||
await ensureGoogleTokenFile(sandbox.userId, sandbox.email);
|
||||
const browserRelayEnv = await getBrowserRelayEnv(sandbox.userId);
|
||||
|
||||
const envFlags = [
|
||||
'-e', `HOME=${containerHome}`,
|
||||
@@ -256,6 +276,7 @@ export async function spawnPi(
|
||||
'-e', `OFFICER_GOOGLE_CONFIG_PATH=/officer/google-oauth.json`,
|
||||
'-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`,
|
||||
'-e', `OFFICER_EMAIL_DB=/officer/emails.db`,
|
||||
...Object.entries(browserRelayEnv).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
|
||||
];
|
||||
|
||||
const rel = relative(sandbox.homeDir, cwd);
|
||||
@@ -299,6 +320,7 @@ export async function spawnPi(
|
||||
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
|
||||
const googleConfigPath = await ensureGoogleConfigFile();
|
||||
const googleTokenPath = await ensureGoogleTokenFile(userId, email);
|
||||
const browserRelayEnv = await getBrowserRelayEnv(userId);
|
||||
|
||||
proc = Bun.spawn(args, {
|
||||
cwd,
|
||||
@@ -317,6 +339,7 @@ export async function spawnPi(
|
||||
OFFICER_GOOGLE_CONFIG_PATH: googleConfigPath,
|
||||
OFFICER_GOOGLE_TOKEN_PATH: googleTokenPath,
|
||||
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
|
||||
...browserRelayEnv,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user