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:
2026-02-27 15:22:15 +00:00
co-authored by Claude Opus 4.6
parent 8f1963fedb
commit 4d30672adb
16 changed files with 696 additions and 89 deletions
@@ -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,
},
],
};
@@ -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 = () => {