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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user