diff --git a/lib/admin/plugin-dev.ts b/lib/admin/plugin-dev.ts index 6a475c14..079182ef 100644 --- a/lib/admin/plugin-dev.ts +++ b/lib/admin/plugin-dev.ts @@ -77,6 +77,32 @@ function resolveBundlePath(pluginDir: string, entrypoint: string): ResolvedBundl return null; } +async function bundleEntrypoint(bundlePath: string): Promise { + const esbuild = await import('esbuild'); + const result = await esbuild.build({ + entryPoints: [bundlePath], + bundle: true, + // CJS format matches the sandbox runtime's evaluator + // (`new Function('module', 'exports', 'require', 'React', ...)`). + format: 'cjs', + platform: 'neutral', + write: false, + logLevel: 'silent', + sourcemap: 'inline', + target: ['es2020'], + // The runtime's `require` shim resolves these at evaluation time: + // react / react-dom / react-dom/client / react/jsx-runtime → host copies + // @plugin-host → the per-plugin `api` object + external: [ + 'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', + '@plugin-host', + ], + }); + const out = result.outputFiles?.[0]?.text; + if (!out) throw new Error('esbuild produced no output'); + return out; +} + /** * Load and bundle a dev plugin's code. For `src/` sources this runs esbuild * on every call so saves are reflected immediately. Errors are surfaced as @@ -88,29 +114,7 @@ export async function readDevBundle(entry: DevPluginEntry): Promise { return readFile(entry.bundlePath, 'utf-8'); } try { - const esbuild = await import('esbuild'); - const result = await esbuild.build({ - entryPoints: [entry.bundlePath], - bundle: true, - // CJS format matches the sandbox runtime's evaluator - // (`new Function('module', 'exports', 'require', 'React', ...)`). - format: 'cjs', - platform: 'neutral', - write: false, - logLevel: 'silent', - sourcemap: 'inline', - target: ['es2020'], - // The runtime's `require` shim resolves these at evaluation time: - // react / react-dom / react-dom/client / react/jsx-runtime → host copies - // @plugin-host → the per-plugin `api` object - external: [ - 'react', 'react-dom', 'react-dom/client', 'react/jsx-runtime', - '@plugin-host', - ], - }); - const out = result.outputFiles?.[0]?.text; - if (!out) throw new Error('esbuild produced no output'); - return out; + return await bundleEntrypoint(entry.bundlePath); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.warn(`[plugin-dev] esbuild failed for ${entry.plugin.id}`, { error: message }); @@ -149,15 +153,18 @@ async function loadDevPlugin(pluginDir: string): Promise return null; } - // Hash from the on-disk source so any edit propagates. For src/ sources - // we hash the source - close enough for dev-time change detection (we - // don't need to re-hash transitive imports). + // Hash from the exact bytes the bundle endpoint will serve so the client's + // verifyBundle check passes. For src/ sources that means running esbuild + // here too — slightly more work per manifest list, but unavoidable since + // the source hash wouldn't match the served bundle. let bundleHash: string; try { - const code = await readFile(resolved.bundlePath); - bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16); + const bytes = resolved.needsBundle + ? await bundleEntrypoint(resolved.bundlePath) + : await readFile(resolved.bundlePath); + bundleHash = createHash('sha256').update(bytes).digest('hex'); } catch (err) { - logger.warn(`[plugin-dev] failed to read ${resolved.bundlePath} for ${id}`, { + logger.warn(`[plugin-dev] failed to hash bundle at ${resolved.bundlePath} for ${id}`, { error: err instanceof Error ? err.message : String(err), }); return null; diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index 1d914b0e..e1754f87 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -56,9 +56,12 @@ export interface ServerPlugin { installedAt: string; updatedAt: string; /** - * SHA-256 hex of the bundle code (first 16 chars). Refreshed every save so + * Full SHA-256 hex of the bundle code (64 chars). Refreshed every save so * the same version re-uploaded with new code still appears as a change to - * the client. Also doubles as the HTTP ETag for the bundle endpoint. + * the client. Also doubles as the HTTP ETag for the bundle endpoint and is + * verified by the sandbox loader on every load + * (`lib/plugin-sandbox/bundle-integrity.ts`), so it must match the served + * bytes exactly. */ bundleHash?: string; /** @@ -130,8 +133,38 @@ async function writeJsonFile(filePath: string, data: unknown): Promise { const pluginRegistryPath = () => path.join(getPluginsDir(), 'registry.json'); +const FULL_HASH_RE = /^[0-9a-f]{64}$/; + +/** + * Older builds wrote a 16-char SHA-256 prefix into `bundleHash`. The current + * client-side verifyBundle requires equal-length hex (and the full digest for + * real integrity), so any registry entry with a truncated or otherwise + * malformed hash needs to be re-hashed from the on-disk bundle. If the bundle + * file is missing the hash is cleared so verifyBundle skips the check rather + * than refusing to load. + */ +async function migrateBundleHashes(registry: PluginRegistry): Promise { + let changed = false; + for (const plugin of registry.plugins) { + if (!plugin.bundleHash || FULL_HASH_RE.test(plugin.bundleHash)) continue; + const bundlePath = path.join(getPluginsDir(), `${plugin.id}.js`); + try { + const code = await readFile(bundlePath); + plugin.bundleHash = createHash('sha256').update(code).digest('hex'); + } catch { + delete plugin.bundleHash; + } + changed = true; + } + return changed; +} + export async function getPluginRegistry(): Promise { - return readJsonFile(pluginRegistryPath(), { plugins: [] }); + const registry = await readJsonFile(pluginRegistryPath(), { plugins: [] }); + if (await migrateBundleHashes(registry)) { + try { await writeJsonFile(pluginRegistryPath(), registry); } catch { /* read-only fs ok */ } + } + return registry; } export async function getPlugin(id: string): Promise { @@ -158,8 +191,9 @@ export async function savePlugin( // Stamp content hash + updatedAt so clients can detect re-uploads even // when the manifest version hasn't changed. Preserve the original - // installedAt across re-uploads. - const bundleHash = createHash('sha256').update(code).digest('hex').slice(0, 16); + // installedAt across re-uploads. The full SHA-256 is required because the + // client-side verifyBundle compares the entire digest length-checked. + const bundleHash = createHash('sha256').update(code).digest('hex'); const now = new Date().toISOString(); const registry = await getPluginRegistry();