- 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>
223 lines
8.4 KiB
TypeScript
223 lines
8.4 KiB
TypeScript
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 };
|
|
}
|
|
}
|