mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(daemon,web): forward pure plugin applies and honor persisted capability grants Pure apply returns snapshotId "" for existing projects, so the web run path now forwards pluginId and pluginInputs when no snapshot id exists. The daemon trust vocabulary now accepts pipeline:* and applyPlugin honors stored capabilitiesGranted with prompt:inject floor. Fixes #6186 * test(web): cover pure-apply plugin identity fallback in run requests
169 lines
6.0 KiB
TypeScript
169 lines
6.0 KiB
TypeScript
// Daemon `applyPlugin` purity test (plan F4).
|
|
//
|
|
// applyPlugin must:
|
|
// - Compute a deterministic snapshot for the same inputs.
|
|
// - Refuse to mutate the registry / FS / SQLite — caller owns persistence.
|
|
// - Throw MissingInputError when a required input is absent.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import path from 'node:path';
|
|
import { applyPlugin, MissingInputError } from '../src/plugins/apply.js';
|
|
import { defaultRegistryRoots } from '../src/plugins/registry.js';
|
|
import { TRUSTED_DEFAULT_CAPABILITIES } from '../src/plugins/trust.js';
|
|
import type { ContextItem, InstalledPluginRecord } from '@open-design/contracts';
|
|
|
|
function pluginFixture(extra: Partial<InstalledPluginRecord> = {}): InstalledPluginRecord {
|
|
return {
|
|
id: 'sample-plugin',
|
|
title: 'Sample Plugin',
|
|
version: '1.0.0',
|
|
sourceKind: 'local',
|
|
source: '/tmp/sample-plugin',
|
|
sourceMarketplaceId: undefined,
|
|
pinnedRef: undefined,
|
|
sourceDigest: undefined,
|
|
trust: 'trusted',
|
|
capabilitiesGranted: ['prompt:inject'],
|
|
fsPath: '/tmp/sample-plugin',
|
|
installedAt: 0,
|
|
updatedAt: 0,
|
|
manifest: {
|
|
name: 'sample-plugin',
|
|
title: 'Sample Plugin',
|
|
version: '1.0.0',
|
|
description: 'Fixture for apply tests.',
|
|
od: {
|
|
kind: 'skill',
|
|
taskKind: 'new-generation',
|
|
useCase: { query: 'Generate a {{topic}} brief.' },
|
|
inputs: [
|
|
{ name: 'topic', type: 'string', required: true },
|
|
{ name: 'audience', type: 'string', default: 'general' },
|
|
],
|
|
context: {
|
|
skills: [{ ref: 'sample-skill' }],
|
|
atoms: ['todo-write'],
|
|
},
|
|
capabilities: ['prompt:inject'],
|
|
},
|
|
},
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
const REGISTRY = {
|
|
skills: [{ id: 'sample-skill', title: 'Sample Skill' }],
|
|
designSystems: [],
|
|
craft: [],
|
|
atoms: [{ id: 'todo-write', label: 'Todo write' }],
|
|
};
|
|
|
|
describe('applyPlugin', () => {
|
|
it('produces a deterministic snapshot for the same inputs', () => {
|
|
const a = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
|
|
const b = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
|
|
expect(a.manifestSourceDigest).toBe(b.manifestSourceDigest);
|
|
expect(a.result.appliedPlugin.manifestSourceDigest).toBe(b.result.appliedPlugin.manifestSourceDigest);
|
|
expect(a.result.appliedPlugin.appliedAt).not.toBe(0);
|
|
});
|
|
|
|
it('throws MissingInputError when a required input is missing', () => {
|
|
expect(() => applyPlugin({ plugin: pluginFixture(), inputs: {}, registry: REGISTRY })).toThrow(MissingInputError);
|
|
});
|
|
|
|
it('coerces optional inputs by defaulting when blank', () => {
|
|
const result = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
|
|
expect(result.result.appliedPlugin.inputs.audience).toBe('general');
|
|
});
|
|
|
|
it('resolves localized use-case queries at apply time', () => {
|
|
const base = pluginFixture();
|
|
const result = applyPlugin({
|
|
plugin: {
|
|
...base,
|
|
manifest: {
|
|
...base.manifest,
|
|
od: {
|
|
...base.manifest.od,
|
|
useCase: {
|
|
query: {
|
|
en: 'Generate a {{topic}} brief.',
|
|
'zh-CN': '生成一份关于 {{topic}} 的简报。',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
inputs: { topic: 'design' },
|
|
registry: REGISTRY,
|
|
locale: 'zh-CN',
|
|
});
|
|
|
|
expect(result.result.query).toBe('生成一份关于 {{topic}} 的简报。');
|
|
expect(result.result.appliedPlugin.query).toBe('生成一份关于 {{topic}} 的简报。');
|
|
});
|
|
|
|
it('uses persisted capabilitiesGranted for a restricted plugin', () => {
|
|
const capabilitiesGranted = ['pipeline:*', 'prompt:inject'];
|
|
const result = applyPlugin({
|
|
plugin: pluginFixture({ trust: 'restricted', capabilitiesGranted }),
|
|
inputs: { topic: 'design' },
|
|
registry: REGISTRY,
|
|
});
|
|
|
|
expect(result.result.capabilitiesGranted).toEqual(capabilitiesGranted);
|
|
expect(result.result.appliedPlugin.capabilitiesGranted).toEqual(capabilitiesGranted);
|
|
});
|
|
|
|
it('does not resurrect a persisted capability revoked from a trusted plugin', () => {
|
|
const capabilitiesGranted = TRUSTED_DEFAULT_CAPABILITIES.filter((cap) => cap !== 'pipeline:*');
|
|
const result = applyPlugin({
|
|
plugin: pluginFixture({ capabilitiesGranted }),
|
|
inputs: { topic: 'design' },
|
|
registry: REGISTRY,
|
|
});
|
|
|
|
expect(result.result.capabilitiesGranted).not.toContain('pipeline:*');
|
|
expect(result.result.appliedPlugin.capabilitiesGranted).not.toContain('pipeline:*');
|
|
});
|
|
|
|
it('emits skill+atom items in resolvedContext.items', () => {
|
|
const result = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
|
|
const kinds = result.result.contextItems.map((c: ContextItem) => c.kind);
|
|
expect(kinds).toContain('skill');
|
|
expect(kinds).toContain('atom');
|
|
});
|
|
|
|
it('does not require a registry roots argument (no FS access at apply time)', () => {
|
|
// Sanity: the function must not reach for the on-disk plugin folder.
|
|
const roots = defaultRegistryRoots();
|
|
const expectedDataDir = path.resolve(process.env.OD_DATA_DIR ?? path.join(process.cwd(), '.od'));
|
|
expect(roots.userPluginsRoot).toBe(path.join(expectedDataDir, 'plugins'));
|
|
const result = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
|
|
expect(result.result.appliedPlugin.pluginId).toBe('sample-plugin');
|
|
});
|
|
|
|
it('does not interpret a community strategy extension as a bundled declaration', () => {
|
|
const base = pluginFixture();
|
|
const plugin = pluginFixture({
|
|
sourceKind: 'local',
|
|
manifest: {
|
|
...base.manifest,
|
|
od: {
|
|
...base.manifest.od,
|
|
strategy: {
|
|
schema: 'community.example/v9',
|
|
customRecipe: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(applyPlugin({
|
|
plugin,
|
|
inputs: { topic: 'design' },
|
|
registry: REGISTRY,
|
|
}).result.appliedPlugin.pluginId).toBe('sample-plugin');
|
|
});
|
|
});
|