From d043e6650ab1849450084f87b4514ead7a3ba74e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 21 Aug 2026 01:52:24 +0000 Subject: [PATCH] name a published extension for itself, not "extension.zip" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extension.zip says nothing once it is in a Downloads folder — not what it is, not which server it came from, and not which of two it is. The name comes from the extension's own manifest.json, which already carries what a browser will show: "Officer Browser Relay" becomes officer-browser-relay.zip. Generic, since extensions are meant to be a marketplace of them: any plugin shipping extension/ gets a file named after its own manifest, falling back to -extension.zip when that cannot be read — still better than extension.zip, and it never throws, because a broken extension manifest should fail the install for its own reason rather than because a file could not be named. Uninstall removes both names. An install from earlier today published extension.zip, and leaving that behind would serve a stale extension for a plugin that is gone. Co-Authored-By: Claude Opus 5 --- src/servers/plugins/install.ts | 8 +++++--- src/servers/plugins/zip.ts | 36 +++++++++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 8b810750..f94d7ec6 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -1,3 +1,4 @@ +import { join } from 'node:path'; import { deleteScreenForAllUsers, getDockPaths, @@ -20,7 +21,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 { publishExtension, unpublishExtension, extensionFileName } from './zip'; import { generatePluginSchemas, pushPluginSchema } from './schema'; import { declaredTables, dropTables, existingTables } from './tables'; import type { OnLog } from './run-logged'; @@ -397,7 +398,8 @@ export async function installPlugin( // 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`); + const file = extensionFileName(appName, join(plugin.dir, 'extension')); + await step(steps, onStep, `extension: published to /plugins/${appName}/${file}`); } // What this plugin needs an AGENT to know about itself. A plugin is a new surface arriving at @@ -529,7 +531,7 @@ export async function uninstallPlugin(appName: string, onStep?: OnStep, onLog?: } await unpublishAssets(appName); - await unpublishExtension(appName).catch(() => {}); + await unpublishExtension(appName, plugin?.dir).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 index df607b61..2e6484a2 100644 --- a/src/servers/plugins/zip.ts +++ b/src/servers/plugins/zip.ts @@ -146,11 +146,41 @@ export async function publishExtension(appName: string, pluginDir: string): Prom const target = join(process.cwd(), 'public', 'plugins', appName); mkdirSync(target, { recursive: true }); - await Bun.write(join(target, 'extension.zip'), zipDirectory(source)); + await Bun.write(join(target, extensionFileName(appName, source)), zipDirectory(source)); return true; } +/** + * What the download is called, from the extension's OWN name. + * + * `extension.zip` is what this shipped as first, and it is the wrong name the moment it reaches a + * Downloads folder: it says nothing about what it is or which server it came from. The extension's + * `manifest.json` already carries the name a browser will show — "Officer Browser Relay" — so the file is + * named for that and a person can tell two of them apart. + * + * Falls back to `-extension.zip` when the manifest cannot be read, which is still better than + * `extension.zip` and never throws: a plugin that ships a broken extension manifest should fail at + * install for that reason, not because the zip could not be named. + */ +export function extensionFileName(appName: string, extensionDir: string): string { + try { + const raw = JSON.parse(readFileSync(join(extensionDir, 'manifest.json'), 'utf8')) as { name?: string }; + const slug = String(raw.name ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + if (slug) return `${slug}.zip`; + } catch { + /* fall through */ + } + return `${appName}-extension.zip`; +} + /** 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 }); +export async function unpublishExtension(appName: string, pluginDir?: string): Promise { + const dir = join(process.cwd(), 'public', 'plugins', appName); + // Both names: an install from before the naming change published `extension.zip`, and leaving that + // behind would serve a stale copy of an uninstalled plugin's extension for ever. + rmSync(join(dir, 'extension.zip'), { force: true }); + if (pluginDir) rmSync(join(dir, extensionFileName(appName, join(pluginDir, 'extension'))), { force: true }); }