mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
feat(plugins): od.genui.surfaces[].component manifest field (Phase 4)
Plan K3 / spec §10.3.5 alignment-roadmap row 2.
Plugin authors can now declare a per-surface React component path:
od.genui.surfaces[*].component: {
path: string, # required, no traversal segments
export?: string, # optional named export
sandbox?: 'iframe' | 'react'
}
The capability gate is wired in three places:
- packages/contracts: GenUISurfaceSpecSchema.component is a passthrough
Zod object so the validator accepts the new field on round-trip.
- apps/daemon/src/plugins/trust.ts: 'genui:custom-component' joins the
KNOWN_TOP_LEVEL_CAPABILITIES set so od plugin trust can grant it.
- apps/daemon/src/plugins/doctor.ts: doctorPlugin emits
genui.component-capability when a surface ships a component without
the matching capability declared, and genui.component-traversal when
the path includes '..'.
The web GenUISurfaceRenderer (apps/web/src/components/GenUISurfaceRenderer.tsx)
keeps its built-in renderer for v1; loading the bundled component is
the next slice and depends on a sandbox wrapper that doesn't exist
yet (the spec §9.2 preview sandbox is the inspiration but
insufficient — components need React boundaries).
Daemon tests: 1490 → 1496 (+6 cases on plugins-genui-component:
schema accepts the new field shape, rejects empty path, capability
appears in the §5.3 vocabulary, doctor errors when capability is
missing, doctor passes when granted, doctor catches traversal).
Co-authored-by: Tom Huang <1043269994@qq.com>
This commit is contained in:
parent
9e12d146e2
commit
6b114910e4
4 changed files with 187 additions and 0 deletions
|
|
@ -118,6 +118,31 @@ export function doctorPlugin(
|
|||
}
|
||||
}
|
||||
|
||||
// Plan §3.K3 / spec §10.3.5 — surface.component capability gate.
|
||||
// A plugin that ships a custom React component must declare the
|
||||
// `genui:custom-component` capability so the trust gate at apply
|
||||
// time can refuse it for restricted installs.
|
||||
for (const surface of manifest.od?.genui?.surfaces ?? []) {
|
||||
if (!surface.component) continue;
|
||||
const declared = new Set(manifest.od?.capabilities ?? []);
|
||||
if (!declared.has('genui:custom-component')) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'genui.component-capability',
|
||||
message: `Surface '${surface.id}' ships a component but the manifest does not declare the 'genui:custom-component' capability.`,
|
||||
field: 'od.genui.surfaces',
|
||||
});
|
||||
}
|
||||
if (surface.component.path.includes('..')) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
code: 'genui.component-traversal',
|
||||
message: `Surface '${surface.id}' component path must be relative without traversal segments.`,
|
||||
field: 'od.genui.surfaces',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveContext(manifest, {
|
||||
registry,
|
||||
warnOnMissing: options?.warnOnMissingRefs ?? true,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ const KNOWN_TOP_LEVEL_CAPABILITIES = new Set<string>([
|
|||
'bash',
|
||||
'network',
|
||||
'connector',
|
||||
// Plan §3.K3 / spec §10.3.5 — plugin-bundled React component
|
||||
// surfaces require an explicit capability so a restricted plugin
|
||||
// cannot smuggle arbitrary UI through the GenUI layer.
|
||||
'genui:custom-component',
|
||||
]);
|
||||
|
||||
const SCOPED_CONNECTOR_RE = /^connector:[a-z0-9][a-z0-9_-]*$/;
|
||||
|
|
|
|||
139
apps/daemon/tests/plugins-genui-component.test.ts
Normal file
139
apps/daemon/tests/plugins-genui-component.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Plan §3.K3 / spec §10.3.5 — od.genui.surfaces[].component manifest field.
|
||||
//
|
||||
// Two contracts:
|
||||
// 1. The Zod schema in @open-design/contracts accepts the new
|
||||
// `component: { path, export?, sandbox? }` field on a surface.
|
||||
// 2. doctorPlugin() flags a surface that ships a component without
|
||||
// the matching `genui:custom-component` capability, and rejects
|
||||
// path-traversal segments.
|
||||
// 3. validateCapabilityList accepts `genui:custom-component` as a
|
||||
// first-class top-level capability.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GenUISurfaceSpecSchema } from '@open-design/contracts';
|
||||
import { validateSafe } from '@open-design/plugin-runtime';
|
||||
import { doctorPlugin } from '../src/plugins/doctor.js';
|
||||
import { validateCapabilityList } from '../src/plugins/trust.js';
|
||||
import { FIRST_PARTY_ATOMS, type AtomCatalogEntry } from '../src/plugins/atoms.js';
|
||||
import type { InstalledPluginRecord, PluginManifest } from '@open-design/contracts';
|
||||
|
||||
const REGISTRY = {
|
||||
skills: [],
|
||||
designSystems: [],
|
||||
craft: [],
|
||||
atoms: FIRST_PARTY_ATOMS.map((a: AtomCatalogEntry) => ({ id: a.id, label: a.label })),
|
||||
};
|
||||
|
||||
function pluginRecord(manifest: PluginManifest): InstalledPluginRecord {
|
||||
return {
|
||||
id: manifest.name,
|
||||
title: manifest.title ?? manifest.name,
|
||||
version: manifest.version,
|
||||
sourceKind: 'local',
|
||||
source: '/tmp/test',
|
||||
pinnedRef: undefined,
|
||||
sourceMarketplaceId: undefined,
|
||||
trust: 'restricted',
|
||||
capabilitiesGranted: ['prompt:inject'],
|
||||
manifest,
|
||||
fsPath: '/tmp/test',
|
||||
installedAt: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GenUISurfaceSpec.component (manifest schema)', () => {
|
||||
it('accepts a component path + export + sandbox triple', () => {
|
||||
const result = GenUISurfaceSpecSchema.safeParse({
|
||||
id: 'critique-panel',
|
||||
kind: 'choice',
|
||||
persist: 'run',
|
||||
component: { path: './surfaces/critique-panel.tsx', export: 'CritiquePanel', sandbox: 'react' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an empty component.path', () => {
|
||||
const result = GenUISurfaceSpecSchema.safeParse({
|
||||
id: 'critique-panel',
|
||||
kind: 'choice',
|
||||
persist: 'run',
|
||||
component: { path: '' },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCapabilityList — genui:custom-component', () => {
|
||||
it('treats genui:custom-component as a first-class top-level capability', () => {
|
||||
const { accepted, rejected } = validateCapabilityList([
|
||||
'prompt:inject',
|
||||
'genui:custom-component',
|
||||
]);
|
||||
expect(accepted.sort()).toEqual(['genui:custom-component', 'prompt:inject']);
|
||||
expect(rejected).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doctorPlugin — component capability gate', () => {
|
||||
const baseManifest: PluginManifest = {
|
||||
name: 'sample-plugin',
|
||||
title: 'Sample',
|
||||
version: '1.0.0',
|
||||
description: 'fixture',
|
||||
od: {
|
||||
kind: 'skill',
|
||||
genui: {
|
||||
surfaces: [
|
||||
{
|
||||
id: 'critique-panel',
|
||||
kind: 'choice',
|
||||
persist: 'run',
|
||||
component: { path: './surfaces/critique-panel.tsx' },
|
||||
},
|
||||
],
|
||||
},
|
||||
capabilities: ['prompt:inject'],
|
||||
},
|
||||
};
|
||||
|
||||
it('errors when a surface ships a component without genui:custom-component', () => {
|
||||
expect(validateSafe(baseManifest).ok).toBe(true);
|
||||
const report = doctorPlugin(pluginRecord(baseManifest), REGISTRY);
|
||||
const codes = report.issues.map((d) => d.code);
|
||||
expect(codes).toContain('genui.component-capability');
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('passes when the matching capability is declared', () => {
|
||||
const m: PluginManifest = {
|
||||
...baseManifest,
|
||||
od: { ...baseManifest.od, capabilities: ['prompt:inject', 'genui:custom-component'] },
|
||||
};
|
||||
const report = doctorPlugin(pluginRecord(m), REGISTRY);
|
||||
expect(report.issues.find((d) => d.code === 'genui.component-capability')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('errors on path-traversal segments inside the component path', () => {
|
||||
const m: PluginManifest = {
|
||||
...baseManifest,
|
||||
od: {
|
||||
...baseManifest.od,
|
||||
capabilities: ['prompt:inject', 'genui:custom-component'],
|
||||
genui: {
|
||||
surfaces: [
|
||||
{
|
||||
id: 'critique-panel',
|
||||
kind: 'choice',
|
||||
persist: 'run',
|
||||
component: { path: '../escape/panel.tsx' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const report = doctorPlugin(pluginRecord(m), REGISTRY);
|
||||
expect(report.issues.find((d) => d.code === 'genui.component-traversal')).toBeDefined();
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -71,6 +71,25 @@ export const GenUISurfaceSpecSchema = z.object({
|
|||
connectorId: z.string().optional(),
|
||||
mcpServerId: z.string().optional(),
|
||||
}).passthrough().optional(),
|
||||
// Phase 4 / spec §10.3.5 alignment-roadmap row 2 — plugin-bundled
|
||||
// React component path. Capability-gated by `genui:custom-component`
|
||||
// (a future patch to the §5.3 capability vocabulary). The web
|
||||
// GenUISurfaceRenderer falls back to the built-in renderer when the
|
||||
// capability is not granted; the field stays an opaque relpath in
|
||||
// v1 contracts so the UI loader / sandbox can evolve without
|
||||
// touching the manifest schema.
|
||||
component: z.object({
|
||||
// Path to the entry module relative to the plugin folder, e.g.
|
||||
// `./surfaces/critique-panel.tsx`. The host loader is responsible
|
||||
// for compilation + sandboxing.
|
||||
path: z.string().min(1),
|
||||
// Optional named export the host should mount; defaults to the
|
||||
// module's default export.
|
||||
export: z.string().optional(),
|
||||
// Sandbox tier the surface needs. v1 only ships 'iframe' but the
|
||||
// contract leaves room for a Phase 4 React-component sandbox.
|
||||
sandbox: z.enum(['iframe', 'react']).optional(),
|
||||
}).passthrough().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type GenUISurfaceSpec = z.infer<typeof GenUISurfaceSpecSchema>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue