pairing moves in, and the extension download stops being a snapshot

Two halves of the same coupling, both left behind by this morning's extraction.

The pairing UI was Settings/IntegrationsSettings/BrowserRelay.tsx — a PLATFORM screen driving this
plugin's pairing, calling /browser/relay-token, and working only because the plugin happens to serve
that path. It is a panel here now, beside the tab list rather than buried in settings: it shows
whether the extension is connected and how many tabs it holds, which is the first thing anyone looks
at when a capture does not work, and that is not configuration you set once.

The download link pointed at public/browser-relay-extension.zip, a static file. That file was 58 lines
behind the source it claimed to be — built in August from something that predated a February commit,
never rebuilt, and serving an extension missing the multi-session recovery path that the same February
commit added. Anyone downloading it got the version that cannot recover from exactly the failure they
were about to hit.

So the archive is built from the extension directory per request. There is no second copy to fall
behind, which is the only durable fix for that class.

zip.ts is hand-rolled and stored rather than deflated. The route's own history says it used to shell
out to `zip`; this manifest's "nothing to declare" is worth more than 30 KB on a download that happens
once, and Chrome accepts stored entries. Timestamps are fixed at the format's zero so the same
directory always produces identical bytes — which is what makes the ETag meaningful and "did this
change" answerable by comparing two files. Verified by unzipping the output and diffing background.js
against the source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 01:20:32 +00:00
co-authored by Claude Opus 5
parent 52934f52dc
commit c0e0848e6a
5 changed files with 381 additions and 4 deletions
+25 -2
View File
@@ -1,4 +1,6 @@
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { zipDirectory } from './zip';
import { createRouter } from '@@/create-router';
import * as errors from '@@/custom-errors';
import { sendSidecarCommand } from '@@/sidecar-registry';
@@ -44,8 +46,29 @@ async function getOpts(userId: number) {
return { relayPort: RELAY_PORT, userToken: deriveRelayToken(userId, RELAY_PORT, salt) };
}
// GET /extension-download used to zip the extension directory on the fly, shelling out to `zip`. It had
// no caller: BrowserRelay.tsx links straight at /browser-relay-extension.zip, a real file in public/.
// ── The extension, zipped from the directory on every request ──
//
// This route existed once, shelling out to `zip`, and was removed as having no caller: the UI linked at
// `/browser-relay-extension.zip`, a static file in public/. That file was then found to be 58 lines
// behind the source it claimed to be — built from something older and never rebuilt.
//
// So it is back, without the host binary (see zip.ts) and with the caller inside this plugin. There is
// no second copy to fall behind, which is the only durable fix for that.
router.get('/extension.zip', async (ctx) => {
const dir = join(import.meta.dir, '..', 'extension');
const bytes = zipDirectory(dir);
// `.buffer` because Bun's BodyInit does not accept a Uint8Array view directly.
return new Response(bytes.buffer as ArrayBuffer, {
headers: {
'content-type': 'application/zip',
'content-disposition': 'attachment; filename="officer-browser-relay.zip"',
// Deterministic bytes for an unchanged directory, so a repeat download is a 304 rather than 50 KB.
etag: `"${Bun.hash(bytes).toString(16)}"`,
'cache-control': 'private, no-cache',
},
});
});
router.get('/status', async (ctx) => {
const user = ctx.get('user');
+122
View File
@@ -0,0 +1,122 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
// A ZIP writer, in about seventy lines, so the extension download can never be stale.
//
// ── Why this exists rather than a file in public/ ──
//
// It was a file in public/: `browser-relay-extension.zip`, built by hand and served as a static asset.
// On 2026-08-21 it was found to be 58 lines behind the source it claimed to be — built in August from
// something that predated a February commit, and never rebuilt since. Anyone who downloaded it got an
// extension missing the multi-session recovery path, which is exactly the failure they would then hit.
//
// A snapshot drifts because nothing makes it not drift. Building from the directory at request time
// removes the class: there is no second copy to fall behind.
//
// ── Why hand-rolled, and why STORED rather than deflated ──
//
// The router's own history says the previous version of this shelled out to `zip`. That works and it
// makes the plugin need a host binary for a convenience feature — this manifest's "nothing to declare"
// is worth more than the compression. Bun ships no zip writer, and the stored format is genuinely small:
// a local header per file, a central directory, and an end record.
//
// Chrome accepts stored entries. The extension is ~50 KB of text; deflate would save perhaps 30 KB on a
// download that happens once.
/** CRC-32, the one thing in the format that is not just bytes in order. */
function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let i = 0; i < 8; i++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
return (crc ^ 0xffffffff) >>> 0;
}
type Entry = { name: string; data: Uint8Array; crc: number; offset: number };
const u16 = (v: number) => [v & 0xff, (v >>> 8) & 0xff];
const u32 = (v: number) => [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff];
/** Every file under `dir`, depth first, with paths relative to it. */
function collect(dir: string, root = dir): { name: string; data: Uint8Array }[] {
const out: { name: string; data: Uint8Array }[] = [];
for (const item of readdirSync(dir).sort()) {
const path = join(dir, item);
if (statSync(path).isDirectory()) out.push(...collect(path, root));
else out.push({ name: relative(root, path), data: new Uint8Array(readFileSync(path)) });
}
return out;
}
/**
* Zip a directory, stored, with a fixed timestamp.
*
* The timestamp is fixed (1980-01-01, the format's own zero) so the same directory always produces
* byte-identical output. That makes the download cacheable and, more usefully, makes "did this change"
* answerable by comparing two files rather than unpacking them.
*/
export function zipDirectory(dir: string): Uint8Array {
const files = collect(dir);
const parts: number[] = [];
const entries: Entry[] = [];
for (const file of files) {
const nameBytes = [...new TextEncoder().encode(file.name)];
const crc = crc32(file.data);
entries.push({ ...file, crc, offset: parts.length });
parts.push(
...u32(0x04034b50), // local file header
...u16(20), // version needed
...u16(0), // flags
...u16(0), // method 0 = stored
...u16(0), // time
...u16(0x0021), // date — 1980-01-01
...u32(crc),
...u32(file.data.length), // compressed size == uncompressed, stored
...u32(file.data.length),
...u16(nameBytes.length),
...u16(0), // extra length
...nameBytes,
...file.data,
);
}
const centralStart = parts.length;
for (const entry of entries) {
const nameBytes = [...new TextEncoder().encode(entry.name)];
parts.push(
...u32(0x02014b50), // central directory header
...u16(20), // version made by
...u16(20), // version needed
...u16(0),
...u16(0),
...u16(0),
...u16(0x0021),
...u32(entry.crc),
...u32(entry.data.length),
...u32(entry.data.length),
...u16(nameBytes.length),
...u16(0), // extra
...u16(0), // comment
...u16(0), // disk
...u16(0), // internal attrs
...u32(0), // external attrs
...u32(entry.offset),
...nameBytes,
);
}
parts.push(
...u32(0x06054b50), // end of central directory
...u16(0),
...u16(0),
...u16(entries.length),
...u16(entries.length),
...u32(parts.length - centralStart),
...u32(centralStart),
...u16(0), // comment length
);
return new Uint8Array(parts);
}
+208
View File
@@ -0,0 +1,208 @@
import { useState } from 'react';
// Pairing lives in the PLUGIN now.
//
// It was `Settings/IntegrationsSettings/BrowserRelay.tsx` — a platform screen driving a plugin's
// pairing, calling /browser/relay-token, which worked only because the plugin happened to serve that
// path. Same coupling the extraction removed everywhere else, and the last piece of it.
//
// The download link changed with it: it pointed at a static zip in the platform's public/ that nothing
// regenerated and that was 58 lines behind the source. It now asks the plugin, which builds the archive
// from the extension directory per request.
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';
import { copyToClipboard } from 'helpers/clipboard';
type RelayToken = {
token: string;
port: number;
};
export const PairingPanel = () => {
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 copyToClipboard(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-success 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="/api/browser/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-success" /> : <Copy className="h-3.5 w-3.5 opacity-50" />}
</button>
</div>
);
+14 -1
View File
@@ -10,7 +10,20 @@ export const defaultLayout: LayoutNode = {
id: 'browser-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'browser-tabs', appType: 'officerdev/browser-tabs' }, size: 25 },
{
node: {
type: 'group',
id: 'browser-left',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'browser-tabs', appType: 'officerdev/browser-tabs' }, size: 55 },
// Pairing under the tab list: on a server with nothing paired the tab list is empty, and the
// panel that explains why should be the one next to it.
{ node: { type: 'panel', id: 'browser-pairing', appType: 'officerdev/browser-pairing' }, size: 45 },
],
},
size: 25,
},
{
node: {
type: 'group',
+12 -1
View File
@@ -1,8 +1,9 @@
// `panels.ts`, not `.tsx` — discovery checks for that exact name.
import type { AppRegistryMeta } from 'officerdev';
import { Globe, LayoutList } from 'lucide-react';
import { Globe, LayoutList, Plug } from 'lucide-react';
import { TabList } from './TabList';
import { TabPreview } from './TabPreview';
import { PairingPanel } from './PairingPanel';
// Two panels, both self-contained — they read their own data and take no props, which is why the screen
// that used to render them (`BrowserScreen.tsx`, with `appType: null` placeholders) had nothing to pass
@@ -15,6 +16,16 @@ export const appRegistryMetas: AppRegistryMeta[] = [
component: TabList,
availableOnPanel: false,
},
{
// Pairing is a panel rather than a settings page, because it is not configuration you set once — it
// shows whether the extension is connected right now and how many tabs it holds, which is the first
// thing anyone looks at when a capture does not work.
key: 'officerdev/browser-pairing',
name: 'Pairing',
icon: Plug,
component: PairingPanel,
availableOnPanel: true,
},
{
key: 'officerdev/browser-preview',
name: 'Preview',