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
+15 -4
View File
@@ -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 -9
View File
@@ -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;
+78 -15
View File
@@ -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 });
+23
View File
@@ -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,
},
});