diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 4f2cd968..8b810750 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -20,6 +20,7 @@ import { publishAffordances, unpublishAffordances } from '../affordances/publish import { publishNotifications, unpublishNotifications } from '../notifications/publish'; import { publishSkills, unpublishSkills } from '../skills/publish'; import { refreshPluginMounts, snapshotPlugins } from './mount'; +import { publishExtension, unpublishExtension } from './zip'; import { generatePluginSchemas, pushPluginSchema } from './schema'; import { declaredTables, dropTables, existingTables } from './tables'; import type { OnLog } from './run-logged'; @@ -393,6 +394,12 @@ export async function installPlugin( await step(steps, onStep, `assets: published to /plugins/${appName}/`); } + // A browser extension, zipped from `extension/` and served UNAUTHENTICATED — see zip.ts. Built here + // rather than committed, so it cannot fall behind the directory it is made from. + if (await publishExtension(appName, plugin.dir).catch(() => false)) { + await step(steps, onStep, `extension: published to /plugins/${appName}/extension.zip`); + } + // What this plugin needs an AGENT to know about itself. A plugin is a new surface arriving at // runtime, and nothing else carries that across: install gitea and no agent learns that `tea` is // authenticated unless the plugin says so. Same shape as the assets above — a directory in the @@ -522,6 +529,7 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?: } await unpublishAssets(appName); + await unpublishExtension(appName).catch(() => {}); // Same reasoning: a copy, with the original still in the plugin's directory. Removed so an agent stops // being told about a surface that is no longer there — an affordance for an uninstalled plugin is // worse than none, because it reads as true. diff --git a/src/servers/plugins/zip.ts b/src/servers/plugins/zip.ts new file mode 100644 index 00000000..df607b61 --- /dev/null +++ b/src/servers/plugins/zip.ts @@ -0,0 +1,156 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +// A ZIP writer, in about seventy lines, so a plugin can ship a browser extension people can download. +// +// ── Why the platform owns this rather than one plugin ── +// +// The browser relay is the first extension, not the last — the intent is a marketplace of them. So +// `extension/` is a directory a plugin may ship, like `assets/` and `affordances/`, and publishing it +// is the installer's job rather than something each plugin reimplements. +// +// ── Why it is built rather than committed ── +// +// It was a committed file: `public/browser-relay-extension.zip`, built by hand. On 2026-08-21 it was +// found to be 58 lines behind the source it claimed to be — built in August from something predating a +// February commit, never rebuilt, and missing the multi-session recovery path that is exactly the +// failure its downloader would then hit. +// +// A snapshot drifts because nothing makes it not drift. Building it from the directory during install +// removes the class: it is rewritten every install, update and enable, from the only copy there is. +// +// ── 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); +} + +/** + * Publish a plugin's `extension/` directory as a downloadable archive. + * + * Written to `public/plugins//extension.zip`, which `/plugins/*` serves with NO authentication. + * That is the point rather than an oversight: an `` is a plain navigation and cannot carry a + * Bearer token, so an authenticated route makes the browser say "Try to sign in to the site. Then, + * download again" — advice that cannot work, because signing in is not what is missing. + * + * Nothing in a browser extension is secret. It is client-side code the user installs into their own + * browser; the pairing token is the secret, it is entered afterwards, and it is not in here. + * + * Returns false when the plugin ships no `extension/`, which is almost all of them. + */ +export async function publishExtension(appName: string, pluginDir: string): Promise { + const source = join(pluginDir, 'extension'); + if (!existsSync(source)) return false; + + const target = join(process.cwd(), 'public', 'plugins', appName); + mkdirSync(target, { recursive: true }); + await Bun.write(join(target, 'extension.zip'), zipDirectory(source)); + return true; +} + +/** Remove it when the plugin goes. A copy, like the assets beside it — the source is in the plugin. */ +export async function unpublishExtension(appName: string): Promise { + rmSync(join(process.cwd(), 'public', 'plugins', appName, 'extension.zip'), { force: true }); +}