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:
Binary file not shown.
@@ -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`.
|
||||
@@ -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 { 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
|
||||
|
||||
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