plugins can ship a browser extension, published unauthenticated
The browser relay is the first extension, not the last, so `extension/` is now a directory any plugin may ship — like `assets/` — and the installer zips it to public/plugins/<name>/extension.zip, served by the existing /plugins/* wildcard with no auth. Unauthenticated deliberately. An `<a download>` is a plain navigation with no Bearer header, so an authenticated URL makes Chrome 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 an extension is secret; the pairing token is, it is entered afterwards, and it is not in the archive. Built at install rather than committed. The file it replaces was hand-built and had drifted 58 lines behind its source, serving an extension missing the multi-session recovery path. zip.ts writes stored entries with fixed timestamps — no host `zip` binary, and identical bytes for an unchanged directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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/<appName>/extension.zip`, which `/plugins/*` serves with NO authentication.
|
||||
* That is the point rather than an oversight: an `<a download>` 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<boolean> {
|
||||
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<void> {
|
||||
rmSync(join(process.cwd(), 'public', 'plugins', appName, 'extension.zip'), { force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user