// Plan §3.L3 / spec §10.3.5 / §9.2 — plugin asset endpoint. // // Validates the daemon-side half of the SandboxedComponentSurface // contract: // // - 404 when the plugin id is unknown. // - 400 when the relpath includes traversal segments. // - 200 with the §9.2 CSP + nosniff headers when the asset is // served from a real fsPath. // - Requests outside the plugin's fsPath are refused even when the // normalized path resolves to an existing file elsewhere. import type http from 'node:http'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import Database from 'better-sqlite3'; import { startServer } from '../src/server.js'; import { migratePlugins } from '../src/plugins/persistence.js'; import { defaultRegistryRoots, upsertInstalledPlugin } from '../src/plugins/registry.js'; let server: http.Server; let baseUrl: string; let shutdown: (() => Promise | void) | undefined; let pluginRoot: string; beforeAll(async () => { pluginRoot = await mkdtemp(path.join(os.tmpdir(), 'od-asset-')); const surfacesDir = path.join(pluginRoot, 'surfaces'); await mkdir(surfacesDir, { recursive: true }); await writeFile( path.join(surfacesDir, 'index.html'), 'fixture', ); await writeFile( path.join(pluginRoot, 'open-design.json'), JSON.stringify({ $schema: 'https://open-design.ai/schemas/plugin.v1.json', name: 'asset-plugin', title: 'Asset', version: '1.0.0', description: 'fixture', license: 'MIT', od: { kind: 'skill', capabilities: ['prompt:inject', 'genui:custom-component'] }, }), ); const started = (await startServer({ port: 0, returnServer: true })) as { url: string; server: http.Server; shutdown?: () => Promise | void; }; baseUrl = started.url; server = started.server; shutdown = started.shutdown; // Insert the plugin row into the running daemon's DB. We can't reach // the daemon's `db` handle directly, so we open a sibling SQLite // session against the same RUNTIME_DATA_DIR. Instead, simulate the // installer's effect by hitting the install API: // // For test simplicity we open a private DB and skip the daemon's // registry. The asset route reads through `getInstalledPlugin(db,…)` // backed by the daemon's own DB, so we must use the install route. // But install requires SAFE_BASENAME id matching the folder name — // achievable by pointing at our prepared fixture. const installResp = await fetch(`${baseUrl}/api/plugins/install`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, body: JSON.stringify({ source: pluginRoot }), }); // Drain SSE. if (installResp.body) { const reader = installResp.body.getReader(); while (true) { const { done } = await reader.read(); if (done) break; } } const secretPath = path.join(pluginRoot, 'secret.txt'); const outsideDir = path.join(pluginRoot, 'outside'); await mkdir(outsideDir, { recursive: true }); await writeFile(secretPath, 'outside secret'); await writeFile(path.join(outsideDir, 'nested-secret.txt'), 'nested outside secret'); const installedSurfacesDir = path.join(defaultRegistryRoots().userPluginsRoot, 'asset-plugin', 'surfaces'); const installedInternalDir = path.join(defaultRegistryRoots().userPluginsRoot, 'asset-plugin', 'internal-assets'); await mkdir(installedInternalDir, { recursive: true }); await writeFile(path.join(installedInternalDir, 'nested-internal.txt'), 'nested internal secret'); await symlink( secretPath, path.join(installedSurfacesDir, 'leak.txt'), ); await symlink(outsideDir, path.join(installedSurfacesDir, 'linked-outside'), 'dir'); await symlink(installedInternalDir, path.join(installedSurfacesDir, 'linked-internal'), 'dir'); void migratePlugins; void upsertInstalledPlugin; void Database; }); afterAll(async () => { await fetch(`${baseUrl}/api/plugins/asset-plugin/uninstall`, { method: 'POST' }).catch(() => undefined); await Promise.resolve(shutdown?.()); await new Promise((resolve) => server.close(() => resolve())); await rm(path.join(defaultRegistryRoots().userPluginsRoot, 'asset-plugin'), { recursive: true, force: true }); await rm(pluginRoot, { recursive: true, force: true }); }); describe('GET /api/plugins/:id/asset/*', () => { it('returns 404 for an unknown plugin', async () => { const resp = await fetch(`${baseUrl}/api/plugins/unknown/asset/index.html`); expect(resp.status).toBe(404); }); it('rejects path-traversal segments with 400', async () => { const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/..%2Fescape`); expect(resp.status).toBe(400); }); it('serves an asset with the §9.2 preview CSP + nosniff', async () => { const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/surfaces/index.html`); expect(resp.status).toBe(200); const csp = resp.headers.get('content-security-policy') ?? ''; expect(csp).toContain("default-src 'none'"); expect(csp).toContain("connect-src 'none'"); expect(csp).toContain("frame-ancestors 'self'"); expect(resp.headers.get('x-content-type-options')).toBe('nosniff'); expect(resp.headers.get('content-type')).toMatch(/text\/html/); const body = await resp.text(); expect(body).toContain('fixture'); }); it('returns 404 for a missing asset under a known plugin', async () => { const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/does/not/exist.html`); expect(resp.status).toBe(404); }); it('rejects symlinked assets inside the plugin root', async () => { const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/surfaces/leak.txt`); expect(resp.status).toBe(404); expect(await resp.text()).not.toContain('outside secret'); }); it('rejects assets reached through a symlinked directory inside the plugin root', async () => { const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/surfaces/linked-outside/nested-secret.txt`); expect(resp.status).toBe(404); expect(await resp.text()).not.toContain('nested outside secret'); }); it('rejects assets reached through an internal symlinked directory inside the plugin root', async () => { const resp = await fetch(`${baseUrl}/api/plugins/asset-plugin/asset/surfaces/linked-internal/nested-internal.txt`); expect(resp.status).toBe(404); expect(await resp.text()).not.toContain('nested internal secret'); }); });