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
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
---
name: browser
label: Browser Control
description: Control connected Chrome browser tabs via the Officer Browser Relay. Use this tool to list tabs, take screenshots, navigate to URLs, evaluate JavaScript, get page info, activate (focus) tabs, or close tabs. Requires the user to have connected the Browser Relay extension in Settings → Integrations.
language: typescript
inputs:
action:
type: string
description: "Action to perform: list_tabs, screenshot, navigate, evaluate, page_info, activate, close"
tab_id:
type: string
description: Target tab ID. Optional — defaults to the first connected tab.
optional: true
url:
type: string
description: URL to navigate to (required for navigate action)
optional: true
expression:
type: string
description: JavaScript expression to evaluate in the tab (required for evaluate action)
optional: true
---
# Browser Tool
Control the user's Chrome browser tabs through the Officer Browser Relay and Chrome DevTools Protocol.
## Available Actions
- **list_tabs**: List all connected tabs with their title, URL, and ID.
- **screenshot**: Capture a screenshot of a tab. Returns the image directly. Use this when asked about page content.
- **navigate**: Navigate a tab to a URL. Requires `url`.
- **evaluate**: Run JavaScript in a tab and return the result. Requires `expression`.
- **page_info**: Get the title and URL of a tab.
- **activate**: Bring a tab to the foreground (focus it).
- **close**: Close a tab.
## Tips
- When asked about what's on a page, take a screenshot first.
- Use `evaluate` for extracting structured data from pages (DOM queries, reading text content, etc.).
- If no `tab_id` is provided, the first connected tab is used.
- Tab IDs can be obtained from `list_tabs`.
+222
View File
@@ -0,0 +1,222 @@
type ToolResult = {
content: Array<{ type: string; text?: string; source?: { type: string; media_type: string; data: string } }>;
isError?: boolean;
};
type Params = {
action: string;
tab_id?: string;
url?: string;
expression?: string;
};
type TabInfo = {
id: string;
title: string;
url: string;
};
const RELAY_AUTH_HEADER = 'x-officer-relay-token';
function getConfig(): { port: number; token: string } | null {
const port = process.env.OFFICER_BROWSER_RELAY_PORT;
const token = process.env.OFFICER_BROWSER_RELAY_TOKEN;
if (!port || !token) return null;
return { port: Number(port), token };
}
async function listTabs(port: number, token: string): Promise<TabInfo[]> {
const res = await fetch(`http://127.0.0.1:${port}/json/list`, {
headers: { [RELAY_AUTH_HEADER]: token },
});
if (!res.ok) throw new Error(`Failed to list tabs (${res.status})`);
const tabs = (await res.json()) as Array<{ id: string; title: string; url: string }>;
return tabs.map((t) => ({ id: t.id, title: t.title, url: t.url }));
}
async function resolveTab(port: number, token: string, tabId?: string): Promise<TabInfo> {
const tabs = await listTabs(port, token);
if (tabs.length === 0) throw new Error('No browser tabs connected. The user needs to attach tabs via the Browser Relay extension.');
if (tabId) {
const tab = tabs.find((t) => t.id === tabId);
if (!tab) throw new Error(`Tab ${tabId} not found. Available tabs: ${tabs.map((t) => t.id).join(', ')}`);
return tab;
}
return tabs[0]!;
}
async function sendCdpCommand(port: number, token: string, tabId: string, method: string, params?: unknown): Promise<unknown> {
const url = `ws://127.0.0.1:${port}/cdp?token=${encodeURIComponent(token)}`;
return await new Promise<unknown>((resolve, reject) => {
const ws = new WebSocket(url);
let settled = false;
const timeout = setTimeout(() => {
if (settled) return;
settled = true;
ws.close();
reject(new Error(`CDP command timeout: ${method}`));
}, 30_000);
ws.addEventListener('open', () => {
const cmd: Record<string, unknown> = { id: 1, method };
if (params) cmd.params = params;
cmd.sessionId = tabId;
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 — is the Browser Relay running?'));
});
ws.addEventListener('close', () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
reject(new Error('CDP WebSocket closed before response'));
});
});
}
// --- Actions ---
async function actionListTabs(port: number, token: string): Promise<ToolResult> {
const tabs = await listTabs(port, token);
if (tabs.length === 0) {
return { content: [{ type: 'text', text: 'No browser tabs connected. The user needs to attach tabs via the Browser Relay extension.' }] };
}
const lines = tabs.map((t, i) => `${i + 1}. ${t.title}\n URL: ${t.url}\n ID: ${t.id}`);
return { content: [{ type: 'text', text: `Connected tabs (${tabs.length}):\n\n${lines.join('\n\n')}` }] };
}
async function actionScreenshot(port: number, token: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
const result = (await sendCdpCommand(port, token, tab.id, 'Page.captureScreenshot', { format: 'png' })) as { data: string };
return {
content: [
{ type: 'text', text: `Screenshot of "${tab.title}" (${tab.url})` },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: result.data } },
],
};
}
async function actionNavigate(port: number, token: string, url: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
await sendCdpCommand(port, token, tab.id, 'Page.navigate', { url });
return { content: [{ type: 'text', text: `Navigated tab "${tab.title}" to ${url}` }] };
}
async function actionEvaluate(port: number, token: string, expression: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
const result = (await sendCdpCommand(port, token, tab.id, 'Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
})) as { result?: { value?: unknown; description?: string }; exceptionDetails?: { text?: string } };
if (result.exceptionDetails) {
return { content: [{ type: 'text', text: `Error evaluating JS: ${result.exceptionDetails.text ?? 'Evaluation failed'}` }], isError: true };
}
const value = result.result?.value;
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
return { content: [{ type: 'text', text: `Result from "${tab.title}":\n${text}` }] };
}
async function actionPageInfo(port: number, token: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
const result = (await sendCdpCommand(port, token, tab.id, 'Runtime.evaluate', {
expression: 'JSON.stringify({ title: document.title, url: location.href })',
returnByValue: true,
})) as { result?: { value?: string } };
let info: { title: string; url: string };
try {
info = JSON.parse(result.result?.value ?? '{}');
} catch {
info = { title: tab.title, url: tab.url };
}
return { content: [{ type: 'text', text: `Title: ${info.title}\nURL: ${info.url}\nTab ID: ${tab.id}` }] };
}
async function actionActivate(port: number, token: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
const res = await fetch(`http://127.0.0.1:${port}/json/activate/${encodeURIComponent(tab.id)}`, {
headers: { [RELAY_AUTH_HEADER]: token },
});
if (!res.ok) throw new Error(`Failed to activate tab (${res.status})`);
return { content: [{ type: 'text', text: `Activated tab "${tab.title}"` }] };
}
async function actionClose(port: number, token: string, tabId?: string): Promise<ToolResult> {
const tab = await resolveTab(port, token, tabId);
const res = await fetch(`http://127.0.0.1:${port}/json/close/${encodeURIComponent(tab.id)}`, {
headers: { [RELAY_AUTH_HEADER]: token },
});
if (!res.ok) throw new Error(`Failed to close tab (${res.status})`);
return { content: [{ type: 'text', text: `Closed tab "${tab.title}"` }] };
}
// --- Main ---
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
const config = getConfig();
if (!config) {
return {
content: [{ type: 'text', text: 'Browser Relay is not available. The user needs to connect the Browser Relay extension in Settings → Integrations.' }],
isError: true,
};
}
const { port, token } = config;
const { action, tab_id, url, expression } = params;
try {
switch (action) {
case 'list_tabs':
return await actionListTabs(port, token);
case 'screenshot':
return await actionScreenshot(port, token, tab_id);
case 'navigate':
if (!url) return { content: [{ type: 'text', text: 'url is required for navigate action' }], isError: true };
return await actionNavigate(port, token, url, tab_id);
case 'evaluate':
if (!expression) return { content: [{ type: 'text', text: 'expression is required for evaluate action' }], isError: true };
return await actionEvaluate(port, token, expression, tab_id);
case 'page_info':
return await actionPageInfo(port, token, tab_id);
case 'activate':
return await actionActivate(port, token, tab_id);
case 'close':
return await actionClose(port, token, tab_id);
default:
return {
content: [{ type: 'text', text: `Unknown action: ${action}. Use list_tabs, screenshot, navigate, evaluate, page_info, activate, or close.` }],
isError: true,
};
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { content: [{ type: 'text', text: `Browser error: ${message}` }], isError: true };
}
}
@@ -8,6 +8,8 @@ import { defaultLayout } from './defaultLayout';
import { TabList } from './TabList'; import { TabList } from './TabList';
import { TabPreview } from './TabPreview'; 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 = () => { export const BrowserScreen = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null); const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
@@ -30,6 +32,7 @@ export const BrowserScreen = () => {
workspace={workspace} workspace={workspace}
locked locked
components={components} components={components}
promptPrefix={PROMPT_PREFIX}
mobilePanelId={mobilePanelId} mobilePanelId={mobilePanelId}
onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined} onMobilePanelChange={mobilePanelId ? () => onMobileBack() : undefined}
/> />
@@ -1,16 +1,9 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; 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 { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
type RelayToken = {
token: string;
port: number;
};
type Target = { type Target = {
id: string; id: string;
sessionId: string; sessionId: string;
@@ -22,7 +15,6 @@ type Target = {
export const TabList = () => { export const TabList = () => {
const client = useClient(); const client = useClient();
const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null); const [selectedId, setSelectedId] = useGlobal<string | null>('BROWSER_SELECTED_TAB', null);
const [copied, setCopied] = useState(false);
const { data: status } = useQuery({ const { data: status } = useQuery({
queryKey: ['browser-status'], queryKey: ['browser-status'],
@@ -30,11 +22,6 @@ export const TabList = () => {
refetchInterval: 5000, refetchInterval: 5000,
}); });
const { data: tokenData } = useQuery({
queryKey: ['browser-relay-token'],
queryFn: () => client.get<RelayToken>('/browser/relay-token'),
});
const { data: targets, isLoading } = useQuery({ const { data: targets, isLoading } = useQuery({
queryKey: ['browser-targets'], queryKey: ['browser-targets'],
queryFn: () => client.get<Target[]>('/browser/targets'), queryFn: () => client.get<Target[]>('/browser/targets'),
@@ -44,18 +31,6 @@ export const TabList = () => {
const targetList: Target[] = targets ?? []; 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) => { const handleClose = async (targetId: string) => {
try { try {
await client.post(`/browser/targets/${targetId}/close`); await client.post(`/browser/targets/${targetId}/close`);
@@ -86,35 +61,15 @@ export const TabList = () => {
</div> </div>
{!isConnected && ( {!isConnected && (
<div className="flex flex-col gap-3 p-4 text-sm"> <div className="flex flex-col items-center justify-center gap-3 p-4 text-sm flex-1">
<p className="opacity-60">Connect the Officer Browser Relay extension to start.</p> <Globe className="h-8 w-8 opacity-30" />
<p className="opacity-60 text-center">Extension not connected</p>
{tokenData && ( <a
<div className="flex flex-col gap-2 rounded-lg border p-3"> href="/settings/integrations"
<div className="flex items-center justify-between"> className="text-xs text-duck-teal underline"
<span className="text-xs font-medium opacity-60">Server address</span> >
<code className="text-xs">{window.location.hostname}</code> Set up in Integrations
</div> </a>
<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> </div>
)} )}
@@ -6,6 +6,17 @@ export const defaultLayout: LayoutNode = {
direction: 'horizontal', direction: 'horizontal',
children: [ children: [
{ node: { type: 'panel', id: 'browser-tabs', appType: null }, size: 25 }, { 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 { 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 type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from 'officerdev'; import { WorkspaceLayout } from 'officerdev';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
@@ -15,6 +15,7 @@ import { TelegramBotConfig } from './TelegramBotConfig';
import { TelegramAccount } from './TelegramAccount'; import { TelegramAccount } from './TelegramAccount';
import { WhatsAppBotConfig } from './WhatsAppBotConfig'; import { WhatsAppBotConfig } from './WhatsAppBotConfig';
import { WhatsAppAccount } from './WhatsAppAccount'; import { WhatsAppAccount } from './WhatsAppAccount';
import { BrowserRelay } from './BrowserRelay';
const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED'; const GLOBAL_KEY = 'INTEGRATIONS_SETTINGS_SELECTED';
const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB'; const TAB_KEY = 'INTEGRATIONS_SETTINGS_TAB';
@@ -79,6 +80,13 @@ const personalSections: SettingsSection[] = [
description: 'Link your WhatsApp account', description: 'Link your WhatsApp account',
content: <WhatsAppAccount />, content: <WhatsAppAccount />,
}, },
{
key: 'browser-relay',
icon: Globe,
title: 'Browser Relay',
description: 'Connect your Chrome browser',
content: <BrowserRelay />,
},
]; ];
const IntegrationsSidebar = () => { const IntegrationsSidebar = () => {
+1
View File
@@ -29,6 +29,7 @@ export {
deleteServerIntegration, deleteServerIntegration,
getUserIntegrations, getUserIntegrations,
getUserIntegration, getUserIntegration,
getIntegrationsByProvider,
upsertUserIntegration, upsertUserIntegration,
deleteUserIntegration, deleteUserIntegration,
findUserByIntegrationConfig, findUserByIntegrationConfig,
@@ -38,6 +38,10 @@ export async function getUserIntegrations(userId: number): Promise<UserIntegrati
return db.select().from(userIntegrations).where(eq(userIntegrations.userId, userId)); 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> { export async function getUserIntegration(userId: number, provider: string): Promise<UserIntegrationSelect | undefined> {
const [row] = await db const [row] = await db
.select() .select()
+60 -2
View File
@@ -39,6 +39,9 @@ const tabOperationLocks = new Set()
/** @type {Set<number>} */ /** @type {Set<number>} */
const reattachPending = new Set() const reattachPending = new Set()
/** @type {Set<number>} */
const reattachingTabs = new Set()
let reconnectAttempt = 0 let reconnectAttempt = 0
let reconnectTimer = null let reconnectTimer = null
@@ -162,8 +165,12 @@ async function ensureRelayConnection() {
} }
}) })
ws.onclose = () => { ws.onclose = (ev) => {
if (ws !== relayWs) return if (ws !== relayWs) return
if (ev.code === 4000) {
onRelayReplaced()
return
}
onRelayClosed('closed') onRelayClosed('closed')
} }
ws.onerror = () => { ws.onerror = () => {
@@ -205,6 +212,30 @@ function onRelayClosed(reason) {
scheduleReconnect() 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() { function scheduleReconnect() {
if (reconnectTimer) { if (reconnectTimer) {
clearTimeout(reconnectTimer) clearTimeout(reconnectTimer)
@@ -534,6 +565,15 @@ async function detachTab(tabId, reason) {
await persistState() 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() { async function connectOrToggleForActiveTab() {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true }) const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
const tabId = active?.id const tabId = active?.id
@@ -576,7 +616,7 @@ async function connectOrToggleForActiveTab() {
setBadge(tabId, 'error') setBadge(tabId, 'error')
void chrome.action.setTitle({ void chrome.action.setTitle({
tabId, tabId,
title: 'Officer Browser Relay: relay not running (open options for setup)', title: `Officer Browser Relay: ${describeError(err)}`,
}) })
void maybeOpenHelpOnce() void maybeOpenHelpOnce()
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
@@ -660,7 +700,24 @@ async function handleForwardCdpCommand(msg) {
? { ...debuggee, sessionId } ? { ...debuggee, sessionId }
: debuggee : debuggee
try {
return await chrome.debugger.sendCommand(debuggerSession, method, params) 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) { function onDebuggerEvent(source, method, params) {
@@ -695,6 +752,7 @@ async function onDebuggerDetach(source, reason) {
const tabId = source.tabId const tabId = source.tabId
if (!tabId) return if (!tabId) return
if (!tabs.has(tabId)) return if (!tabs.has(tabId)) return
if (reattachingTabs.has(tabId)) return
if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') { if (reason === 'canceled_by_user' || reason === 'replaced_with_devtools') {
void detachTab(tabId, reason) void detachTab(tabId, reason)
+1 -1
View File
@@ -202,7 +202,7 @@ console.log(`🚀 Server running at ${server.url}`);
const BROWSER_RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? '18792'); const BROWSER_RELAY_PORT = Number(process.env.BROWSER_RELAY_PORT ?? '18792');
try { try {
startBrowserRelay(BROWSER_RELAY_PORT); await startBrowserRelay(BROWSER_RELAY_PORT);
console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`); console.log(`[browser-relay] listening on port ${BROWSER_RELAY_PORT}`);
} catch (err) { } catch (err) {
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err); console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
+15 -4
View File
@@ -8,20 +8,31 @@ if (!JWT_SECRET) {
throw new Error('JWT_SECRET is required for browser relay auth'); 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!) return createHmac('sha256', JWT_SECRET!)
.update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}`) .update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`)
.digest('hex'); .digest('hex');
} }
const tokenToUser = new Map<string, number>(); const tokenToUser = new Map<string, number>();
export function registerUserToken(userId: number, port: number): string { export function registerUserToken(userId: number, port: number, salt: string): string {
const token = deriveRelayToken(userId, port); const token = deriveRelayToken(userId, port, salt);
tokenToUser.set(token, userId); tokenToUser.set(token, userId);
return token; 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 { export function resolveUserFromToken(token: string): number | null {
return tokenToUser.get(token) ?? null; return tokenToUser.get(token) ?? null;
} }
+16 -9
View File
@@ -16,7 +16,8 @@ import type {
ExtensionMessage, ExtensionMessage,
ExtensionPongMessage, ExtensionPongMessage,
} from './relay-types'; } 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 RELAY_AUTH_HEADER = getRelayAuthHeader();
const DEFAULT_EXTENSION_RECONNECT_GRACE_MS = 5_000; 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>) { function handleExtensionClose(relay: UserRelay, ws: ServerWebSocket<WSData>) {
if (relay.extensionWs !== ws) return;
if (relay.pingInterval) { if (relay.pingInterval) {
clearInterval(relay.pingInterval); clearInterval(relay.pingInterval);
relay.pingInterval = null; relay.pingInterval = null;
} }
if (relay.extensionWs !== ws) return;
relay.extensionWs = null; relay.extensionWs = null;
for (const [, pending] of relay.pendingExtension) { for (const [, pending] of relay.pendingExtension) {
clearTimeout(pending.timer); clearTimeout(pending.timer);
@@ -449,9 +450,18 @@ export function getUserTargets(userId: number): ConnectedTarget[] {
return Array.from(relay.connectedTargets.values()); return Array.from(relay.connectedTargets.values());
} }
export function startBrowserRelay(port: number) { export async function startBrowserRelay(port: number) {
relayPort = port; 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>({ const server = Bun.serve<WSData>({
port, port,
hostname: '0.0.0.0', hostname: '0.0.0.0',
@@ -566,18 +576,15 @@ export function startBrowserRelay(port: number) {
if (path === '/extension') { if (path === '/extension') {
if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders }); if (userId === null) return new Response('Unauthorized', { status: 401, headers: corsHeaders });
const relay = getUserRelay(userId); const relay = getUserRelay(userId);
// Close stale extension WS // Close existing extension WS (stale or active) — last connection wins
if (relay.extensionWs && relay.extensionWs.readyState !== WebSocket.OPEN) { if (relay.extensionWs) {
try { try {
(relay.extensionWs as ServerWebSocket<WSData>).close(); relay.extensionWs.close(4000, 'replaced by new connection');
} catch { } catch {
// ignore // ignore
} }
relay.extensionWs = null; 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! } }); const ok = server.upgrade(req, { data: { kind: 'extension', userId, token: token! } });
if (!ok) return new Response('Upgrade failed', { status: 500, headers: corsHeaders }); if (!ok) return new Response('Upgrade failed', { status: 500, headers: corsHeaders });
return undefined as unknown as Response; return undefined as unknown as Response;
+78 -15
View File
@@ -1,17 +1,44 @@
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { createRouter } from '@@/create-router'; import { createRouter } from '@@/create-router';
import * as errors from '@@/custom-errors'; 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 { getRelayPort, getUserRelayStatus, getUserTargets } from './relay';
import { captureScreenshot, evaluateJS, navigateTo } from './cdp'; import { captureScreenshot, evaluateJS, navigateTo } from './cdp';
export const browserRouter = createRouter(); 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 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 }; 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) => { browserRouter.get('/status', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
const status = getUserRelayStatus(user.id); const status = getUserRelayStatus(user.id);
@@ -21,10 +48,48 @@ browserRouter.get('/status', async (ctx) => {
browserRouter.get('/relay-token', async (ctx) => { browserRouter.get('/relay-token', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
const port = getRelayPort(); 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 }); 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) => { browserRouter.get('/targets', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
const targets = getUserTargets(user.id); const targets = getUserTargets(user.id);
@@ -44,7 +109,7 @@ browserRouter.get('/targets/:id/screenshot', async (ctx) => {
const targetId = ctx.req.param('id'); const targetId = ctx.req.param('id');
const format = (ctx.req.query('format') as 'png' | 'jpeg') || 'png'; 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 // Find session ID for the target
const targets = getUserTargets(user.id); const targets = getUserTargets(user.id);
const target = targets.find((t) => t.targetId === targetId); 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'); 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 targets = getUserTargets(user.id);
const target = targets.find((t) => t.targetId === targetId); const target = targets.find((t) => t.targetId === targetId);
if (!target) throw errors.NOT_FOUND('Target not found'); 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'); 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 targets = getUserTargets(user.id);
const target = targets.find((t) => t.targetId === targetId); const target = targets.find((t) => t.targetId === targetId);
if (!target) throw errors.NOT_FOUND('Target not found'); 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 user = ctx.get('user');
const targetId = ctx.req.param('id'); const targetId = ctx.req.param('id');
const port = getRelayPort(); const { relayPort, userToken } = await getOpts(user.id);
const token = registerUserToken(user.id, port);
try { try {
const res = await fetch(`http://127.0.0.1:${port}/json/activate/${encodeURIComponent(targetId)}`, { const res = await fetch(`http://127.0.0.1:${relayPort}/json/activate/${encodeURIComponent(targetId)}`, {
headers: { 'x-officer-relay-token': token }, headers: { 'x-officer-relay-token': userToken },
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
return ctx.json({ ok: true }); return ctx.json({ ok: true });
@@ -120,12 +184,11 @@ browserRouter.post('/targets/:id/close', async (ctx) => {
const user = ctx.get('user'); const user = ctx.get('user');
const targetId = ctx.req.param('id'); const targetId = ctx.req.param('id');
const port = getRelayPort(); const { relayPort, userToken } = await getOpts(user.id);
const token = registerUserToken(user.id, port);
try { try {
const res = await fetch(`http://127.0.0.1:${port}/json/close/${encodeURIComponent(targetId)}`, { const res = await fetch(`http://127.0.0.1:${relayPort}/json/close/${encodeURIComponent(targetId)}`, {
headers: { 'x-officer-relay-token': token }, headers: { 'x-officer-relay-token': userToken },
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
return ctx.json({ ok: true }); return ctx.json({ ok: true });
+23
View File
@@ -9,6 +9,8 @@ import { ensureDockerContainer } from "../terminal/websocket";
import { getServerIntegration, getUserIntegration } from "officerdb"; import { getServerIntegration, getUserIntegration } from "officerdb";
import { logger } from "./logger"; import { logger } from "./logger";
import { parseFrontmatter } from "../skills/skills"; import { parseFrontmatter } from "../skills/skills";
import { getRelayPort } from "../browser/relay";
import { registerUserToken } from "../browser/relay-auth";
export type PiEventHandler = (event: PiEvent) => void; export type PiEventHandler = (event: PiEvent) => void;
@@ -188,6 +190,23 @@ async function ensureGoogleTokenFile(userId: number, email: string): Promise<str
return filePath; 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 = { type SandboxOptions = {
userId: number; userId: number;
username: string; username: string;
@@ -245,6 +264,7 @@ export async function spawnPi(
const googleConfigHost = await ensureGoogleConfigFile(); const googleConfigHost = await ensureGoogleConfigFile();
await ensureGoogleTokenFile(sandbox.userId, sandbox.email); await ensureGoogleTokenFile(sandbox.userId, sandbox.email);
const browserRelayEnv = await getBrowserRelayEnv(sandbox.userId);
const envFlags = [ const envFlags = [
'-e', `HOME=${containerHome}`, '-e', `HOME=${containerHome}`,
@@ -256,6 +276,7 @@ export async function spawnPi(
'-e', `OFFICER_GOOGLE_CONFIG_PATH=/officer/google-oauth.json`, '-e', `OFFICER_GOOGLE_CONFIG_PATH=/officer/google-oauth.json`,
'-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`, '-e', `OFFICER_GOOGLE_TOKEN_PATH=/officer/user/integrations/google.json`,
'-e', `OFFICER_EMAIL_DB=/officer/emails.db`, '-e', `OFFICER_EMAIL_DB=/officer/emails.db`,
...Object.entries(browserRelayEnv).flatMap(([k, v]) => ['-e', `${k}=${v}`]),
]; ];
const rel = relative(sandbox.homeDir, cwd); const rel = relative(sandbox.homeDir, cwd);
@@ -299,6 +320,7 @@ export async function spawnPi(
const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':'); const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':');
const googleConfigPath = await ensureGoogleConfigFile(); const googleConfigPath = await ensureGoogleConfigFile();
const googleTokenPath = await ensureGoogleTokenFile(userId, email); const googleTokenPath = await ensureGoogleTokenFile(userId, email);
const browserRelayEnv = await getBrowserRelayEnv(userId);
proc = Bun.spawn(args, { proc = Bun.spawn(args, {
cwd, cwd,
@@ -317,6 +339,7 @@ export async function spawnPi(
OFFICER_GOOGLE_CONFIG_PATH: googleConfigPath, OFFICER_GOOGLE_CONFIG_PATH: googleConfigPath,
OFFICER_GOOGLE_TOKEN_PATH: googleTokenPath, OFFICER_GOOGLE_TOKEN_PATH: googleTokenPath,
OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'),
...browserRelayEnv,
}, },
}); });