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:
2026-08-21 01:36:43 +00:00
co-authored by Claude Opus 5
parent 45d4823238
commit cfb2f98ccb
2 changed files with 164 additions and 0 deletions
+8
View File
@@ -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.