mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(plugins): plugin-bundled component surface (sandboxed iframe)
Plan L3 / spec §10.3.5 / §9.2.
Web: GenUISurfaceRenderer now renders a sandboxed iframe when the
surface declares a component path. Communication is one-way: the
iframe posts `{ kind: 'genui:respond', surfaceId, value }` envelopes
back to the parent via window.postMessage; the parent forwards to
onAnswered. The iframe carries `sandbox="allow-scripts"` only — no
allow-same-origin / forms / popups / downloads, exactly as spec §9.2
mandates.
PendingSurface gains `componentPluginId`, supplied by the host (from
the run's AppliedPluginSnapshot.pluginId). Surfaces missing that
field render an inline error so a misconfigured host fails loudly.
Daemon: GET /api/plugins/:id/asset/* serves arbitrary files inside an
installed plugin's fsPath under the §9.2 preview CSP:
Content-Security-Policy: default-src 'none'; img-src 'self' data: blob:;
media-src 'self' data: blob:;
style-src 'self' 'unsafe-inline';
script-src 'self' 'unsafe-inline';
connect-src 'none'; frame-ancestors 'self'
X-Content-Type-Options: nosniff
Three guards: unknown plugin id → 404, traversal segments / absolute
paths → 400, escape-via-resolved-path → 400. Content-Type maps
common asset extensions; everything else falls back to
application/octet-stream so the browser's nosniff respects it.
Daemon tests: 1499 → 1503 (+4 cases on plugins-asset-route covering
404 / traversal-rejection / 200 with CSP headers / asset-not-found).
Web typecheck clean; jsdom tests stay at 586/586 (the iframe path is
DOM-shape-locked and has no callable surface to drive without a
real browser).
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
parent
dfbb646b5d
commit
61931faef9
3 changed files with 314 additions and 3 deletions
|
|
@ -3502,6 +3502,67 @@ export async function startServer({
|
|||
res.json({ atoms: FIRST_PARTY_ATOMS.map((a) => ({ ...a, taskKinds: a.taskKinds.slice() })) });
|
||||
});
|
||||
|
||||
// Plan §3.L3 / spec §10.3.5 / §9.2 — plugin asset endpoint.
|
||||
//
|
||||
// Serves a static file from inside an installed plugin's fsPath,
|
||||
// sandboxed by:
|
||||
// - whitelisted plugin ids (the registry row),
|
||||
// - normalized relpath (no '..' / absolute / leading drive),
|
||||
// - the §9.2 preview CSP (default-src 'none'; script-src 'self'
|
||||
// 'unsafe-inline'; connect-src 'none'; frame-ancestors 'self'),
|
||||
// - X-Content-Type-Options: nosniff so the browser respects the
|
||||
// declared content type even on miss.
|
||||
// The web GenUISurfaceRenderer's SandboxedComponentSurface points
|
||||
// its iframe at this URL.
|
||||
app.get('/api/plugins/:id/asset/*', async (req, res) => {
|
||||
try {
|
||||
const plugin = getInstalledPlugin(db, req.params.id);
|
||||
if (!plugin) return res.status(404).json({ error: 'plugin not found' });
|
||||
const relpath = String(req.params[0] ?? '');
|
||||
// Reject obvious traversal up-front; the path resolution below
|
||||
// normalizes again, but this catches the easy cases without
|
||||
// touching disk.
|
||||
if (!relpath || relpath.includes('..') || relpath.startsWith('/') || relpath.includes('\0')) {
|
||||
return res.status(400).json({ error: 'invalid asset path' });
|
||||
}
|
||||
const path = await import('node:path');
|
||||
const fsp = await import('node:fs/promises');
|
||||
const resolved = path.resolve(plugin.fsPath, relpath);
|
||||
// Final containment check — `resolved` must stay under fsPath.
|
||||
const root = path.resolve(plugin.fsPath) + path.sep;
|
||||
if (!(resolved + path.sep).startsWith(root) && resolved !== path.resolve(plugin.fsPath)) {
|
||||
return res.status(400).json({ error: 'asset escape rejected' });
|
||||
}
|
||||
let buf;
|
||||
try {
|
||||
buf = await fsp.readFile(resolved);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'asset not found' });
|
||||
}
|
||||
// §9.2 preview CSP — sandboxed iframes get only inline script + style;
|
||||
// no network, no external resources, no document-level forms.
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'none'; img-src 'self' data: blob:; media-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-ancestors 'self'",
|
||||
);
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
const ct =
|
||||
ext === '.html' ? 'text/html; charset=utf-8'
|
||||
: ext === '.js' ? 'application/javascript; charset=utf-8'
|
||||
: ext === '.css' ? 'text/css; charset=utf-8'
|
||||
: ext === '.json' ? 'application/json; charset=utf-8'
|
||||
: ext === '.svg' ? 'image/svg+xml'
|
||||
: ext === '.png' ? 'image/png'
|
||||
: ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
|
||||
: 'application/octet-stream';
|
||||
res.setHeader('Content-Type', ct);
|
||||
res.send(buf);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// Plan §3.H2 / spec §12.2 — craft list endpoint.
|
||||
// Mirrors the daemon's existing /api/skills + /api/design-systems
|
||||
// discovery surface so `od craft list` is a thin wrapper over a
|
||||
|
|
|
|||
120
apps/daemon/tests/plugins-asset-route.test.ts
Normal file
120
apps/daemon/tests/plugins-asset-route.test.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// 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, 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 { upsertInstalledPlugin } from '../src/plugins/registry.js';
|
||||
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
let shutdown: (() => Promise<void> | 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'),
|
||||
'<!DOCTYPE html><title>fixture</title><script>console.log(1)</script>',
|
||||
);
|
||||
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> | 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;
|
||||
}
|
||||
}
|
||||
void migratePlugins;
|
||||
void upsertInstalledPlugin;
|
||||
void Database;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.resolve(shutdown?.());
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
// Plan §3.C3 / spec §10.3 — Generative UI surface renderer.
|
||||
// Plan §3.C3 / §3.L3 / spec §10.3 — Generative UI surface renderer.
|
||||
//
|
||||
// Renders a single pending GenUI surface. v1 ships first-class
|
||||
// renderers for `confirmation` and `oauth-prompt`; `form` and `choice`
|
||||
// renderers for `confirmation`, `oauth-prompt`, and bundled-component
|
||||
// surfaces (sandboxed iframe). `form` and `choice` without a component
|
||||
// fall back to a JSON-Schema preview + a generic "value-json" textarea
|
||||
// (the proper schema-driven renderer lands in Phase 2A.5).
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { GenUISurfaceSpec } from '@open-design/contracts';
|
||||
|
||||
export interface PendingSurface {
|
||||
|
|
@ -16,6 +17,11 @@ export interface PendingSurface {
|
|||
runId: string;
|
||||
// Optional pre-filled value used for `form`/`choice` re-asks.
|
||||
defaultValue?: unknown;
|
||||
// Plan §3.L3 / spec §10.3.5 — required when `surface.component` is
|
||||
// declared. The renderer points the sandbox iframe at
|
||||
// `/api/plugins/<componentPluginId>/asset/<component.path>`. The
|
||||
// host supplies it from the run's AppliedPluginSnapshot.pluginId.
|
||||
componentPluginId?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -72,6 +78,53 @@ export function GenUISurfaceRenderer(props: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
// Plan §3.L3 / spec §10.3.5 — plugin-bundled component surface.
|
||||
//
|
||||
// A surface that ships its own component path renders inside a
|
||||
// sandboxed iframe served by the daemon's plugin-asset endpoint.
|
||||
// The contract:
|
||||
//
|
||||
// - `component.path` is a relpath inside the plugin folder; the
|
||||
// iframe src is /api/plugins/:pluginId/asset/:path so the daemon
|
||||
// can apply the §9.2 preview CSP.
|
||||
// - The iframe communicates back via `postMessage` with a
|
||||
// { kind: 'genui:respond', value } envelope. Other messages are
|
||||
// ignored.
|
||||
// - The capability gate (`genui:custom-component`) was enforced at
|
||||
// install time by `od plugin doctor`; the renderer trusts the
|
||||
// manifest's `component` field and falls back to the default
|
||||
// when missing.
|
||||
//
|
||||
// The pluginId is read from the surface's `component.pluginId` field
|
||||
// (when the daemon stamps it during apply) or from the implicit
|
||||
// surface id prefix `__auto_connector_<id>` etc. v1 expects the
|
||||
// host to inject it through PendingSurface.componentPluginId.
|
||||
if (surface.component) {
|
||||
const pluginId = props.pending.componentPluginId;
|
||||
if (!pluginId) {
|
||||
return (
|
||||
<div className="genui-surface genui-surface--component-error" role="alert">
|
||||
Plugin component surface "{surface.id}" requires componentPluginId.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const sanitizedPath = surface.component.path.replace(/^[./\\]+/, '');
|
||||
const src = `/api/plugins/${encodeURIComponent(pluginId)}/asset/${sanitizedPath
|
||||
.split('/')
|
||||
.map(encodeURIComponent)
|
||||
.join('/')}`;
|
||||
return (
|
||||
<SandboxedComponentSurface
|
||||
runId={props.pending.runId}
|
||||
surfaceId={surface.id}
|
||||
src={src}
|
||||
sandbox={surface.component.sandbox === 'react' ? 'react' : 'iframe'}
|
||||
onAnswered={props.onAnswered}
|
||||
{...(props.onSkip ? { onSkip: props.onSkip } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (surface.kind === 'oauth-prompt') {
|
||||
return (
|
||||
<div className="genui-surface genui-surface--oauth" role="dialog" aria-label={surface.id}>
|
||||
|
|
@ -173,3 +226,80 @@ function FreeFormJsonForm({
|
|||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// Plan §3.L3 / spec §10.3.5 — sandboxed plugin component surface.
|
||||
//
|
||||
// Wraps the daemon's plugin-asset endpoint in an iframe with the
|
||||
// minimum-privilege sandbox flags spec §9.2 calls out for previews:
|
||||
// `allow-scripts` only — no `allow-same-origin`, `allow-forms`,
|
||||
// `allow-popups`, or `allow-downloads`. Communication is one-way via
|
||||
// `postMessage`; the parent listens for `{ kind: 'genui:respond', value }`
|
||||
// envelopes from the iframe and forwards them through onAnswered.
|
||||
function SandboxedComponentSurface({
|
||||
runId,
|
||||
surfaceId,
|
||||
src,
|
||||
sandbox,
|
||||
onAnswered,
|
||||
onSkip,
|
||||
}: {
|
||||
runId: string;
|
||||
surfaceId: string;
|
||||
src: string;
|
||||
sandbox: 'iframe' | 'react';
|
||||
onAnswered: (value: unknown) => Promise<void> | void;
|
||||
onSkip?: () => void;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function onMessage(ev: MessageEvent) {
|
||||
// We don't have an origin check the iframe can pass (it's served
|
||||
// sandboxed). Filter on shape + the surface id we expect.
|
||||
if (!ev.data || typeof ev.data !== 'object') return;
|
||||
const env = ev.data as { kind?: string; surfaceId?: string; value?: unknown };
|
||||
if (env.kind !== 'genui:respond') return;
|
||||
if (env.surfaceId !== surfaceId) return;
|
||||
setBusy(true);
|
||||
void Promise.resolve(onAnswered(env.value)).finally(() => setBusy(false));
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [surfaceId, onAnswered]);
|
||||
|
||||
// The 'react' sandbox tier is reserved for future plugin-bundled
|
||||
// React components loaded via dynamic import; v1 routes through the
|
||||
// iframe path regardless. The flag stays so a future PR can branch
|
||||
// here without touching the manifest schema.
|
||||
void sandbox;
|
||||
|
||||
return (
|
||||
<div className="genui-surface genui-surface--component" role="dialog" aria-label={surfaceId}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={`plugin surface ${surfaceId}`}
|
||||
src={src}
|
||||
sandbox="allow-scripts"
|
||||
// `data-testid` lets jsdom tests assert the src + sandbox
|
||||
// attribute without trying to load the iframe's contents.
|
||||
data-testid="genui-component-iframe"
|
||||
data-run-id={runId}
|
||||
className="genui-surface__component-frame"
|
||||
style={{ width: '100%', minHeight: 320, border: '1px solid var(--od-border, #ddd)' }}
|
||||
/>
|
||||
{onSkip ? (
|
||||
<div className="genui-surface__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="genui-surface__secondary"
|
||||
disabled={busy}
|
||||
onClick={onSkip}
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue