fix(plugins): apply exact local plugin source

This commit is contained in:
lefarcen
2026-08-12 16:57:16 +08:00
parent 82147ed854
commit e776a7d079
25 changed files with 645 additions and 139 deletions
+1
View File
@@ -3,6 +3,7 @@
// and accidentally bypasses the snapshot writer (spec §8.2.1).
export * from './atoms.js';
export * from './apply.js';
export * from './local-source.js';
export {
validatePluginFolder,
flattenValidationDiagnostics,
+66
View File
@@ -0,0 +1,66 @@
import type Database from 'better-sqlite3';
import type { InstalledPluginRecord } from '@open-design/contracts';
import {
readTeamResourceMaterialization,
teamResourceMaterializationDir,
} from '../collab/team-resource-materialization.js';
import { isSafePluginId } from './installer.js';
import { getInstalledPlugin, resolvePluginFolder } from './registry.js';
const TEAM_PLUGIN_SOURCE_PREFIX = 'team:plugin:';
function workspaceIdFromTeamPluginSource(
source: string,
pluginId: string,
): string | null {
const suffix = `:${pluginId}`;
if (!source.startsWith(TEAM_PLUGIN_SOURCE_PREFIX) || !source.endsWith(suffix)) {
return null;
}
const workspaceId = source.slice(
TEAM_PLUGIN_SOURCE_PREFIX.length,
-suffix.length,
).trim();
return workspaceId || null;
}
/**
* Resolve the exact already-local record selected by the UI. This deliberately
* does not perform Workspace authorization: remote discovery and sync own that
* boundary, while apply only reads bytes already managed by this daemon.
*/
export async function resolveLocalPluginBySource(input: {
db: Database.Database;
id: string;
source: string;
userPluginsRoot: string;
}): Promise<InstalledPluginRecord | null> {
const { db, id, source, userPluginsRoot } = input;
const installed = getInstalledPlugin(db, id);
if (installed?.source === source) return installed;
if (!isSafePluginId(id)) return null;
const workspaceId = workspaceIdFromTeamPluginSource(source, id);
if (!workspaceId) return null;
const marker = await readTeamResourceMaterialization(
userPluginsRoot,
workspaceId,
id,
id,
);
if (
!marker
|| marker.kind !== 'plugin'
|| marker.resourceId !== id
|| marker.workspaceId !== workspaceId
|| marker.sourceKey !== source
) return null;
const resolved = await resolvePluginFolder({
folder: teamResourceMaterializationDir(userPluginsRoot, workspaceId, id, id),
folderId: id,
sourceKind: 'user',
source,
});
return resolved.ok ? resolved.record : null;
}
+5 -1
View File
@@ -62,6 +62,8 @@ export interface ResolveSnapshotInput {
// Pluggable for tests; in production these are the daemon's live
// skill / design-system catalogs (server.ts wires them).
registry: RegistryView;
/** Exact already-local record selected by a record-aware caller. */
plugin?: InstalledPluginRecord | undefined;
connectorProbe?: ConnectorProbe | undefined;
// Optional active-project DS binding. Forwarded to `applyPlugin` so
// plugins that declared `od.context.designSystem.primary: true` get
@@ -220,7 +222,9 @@ export function resolvePluginSnapshot(input: ResolveSnapshotInput): ResolveSnaps
}
// Path 2: pluginId — run apply, persist a new snapshot.
const plugin = getInstalledPlugin(input.db, fields.pluginId!);
const plugin = input.plugin?.id === fields.pluginId
? input.plugin
: getInstalledPlugin(input.db, fields.pluginId!);
if (!plugin) {
return {
ok: false,
+57 -21
View File
@@ -255,6 +255,11 @@ export interface RegisterPluginRoutesDeps {
workspaceId: string | null,
workspaceMemberId?: string | null,
) => InstalledPluginLike | null | Promise<InstalledPluginLike | null>;
getLocalPluginBySource?: (
db: SqliteDbLike,
id: string,
source: string,
) => InstalledPluginLike | null | Promise<InstalledPluginLike | null>;
installPlugin: (db: SqliteDbLike, args: unknown) => AsyncIterable<unknown>;
isSafePluginId: (id: string) => boolean;
uninstallPlugin: (db: SqliteDbLike, id: string, roots: string[]) => Promise<{ ok: boolean; removedFolder?: boolean; warning?: string }>;
@@ -404,6 +409,34 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep
? plugins.getWorkspacePlugin(db, id, workspaceId, authority?.workspaceMemberId ?? null)
: plugins.getInstalledPlugin(db, id);
};
const applyResolvedPlugin = async (
req: Request,
res: Response,
plugin: InstalledPluginLike,
registry: unknown,
) => {
const body = req.body && typeof req.body === 'object'
? req.body as Record<string, unknown>
: {};
const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : {};
const grantCaps = Array.isArray(body.grantCaps)
? body.grantCaps.filter((c: unknown): c is string => typeof c === 'string')
: [];
const locale = typeof body.locale === 'string' ? body.locale : undefined;
const connectorProbe = helpers.buildConnectorProbe(helpers.connectorService);
const computed = plugins.applyPlugin({ plugin, inputs, registry, locale, connectorProbe });
if (grantCaps.length > 0) {
const merged = new Set([...computed.result.capabilitiesGranted, ...grantCaps]);
computed.result.capabilitiesGranted = Array.from(merged);
computed.result.appliedPlugin.capabilitiesGranted = Array.from(merged);
}
return res.json({
ok: true,
...computed.result,
warnings: computed.warnings,
manifestSourceDigest: computed.manifestSourceDigest,
});
};
const isTeamPlugin = (plugin: InstalledPluginLike | null | undefined): boolean =>
typeof plugin?.source === 'string' && plugin.source.startsWith('team:plugin:');
const hasActiveTeamPluginBinding = (
@@ -579,20 +612,35 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep
)) return;
return helpers.installOrUpgradePlugin(req, res, 'upgrade', authority);
});
app.post('/api/plugins/:id/apply-local', async (req, res) => {
res.setHeader('x-od-plugin-apply-local', '1');
try {
const body = req.body && typeof req.body === 'object'
? req.body as Record<string, unknown>
: {};
const source = typeof body.source === 'string' ? body.source.trim() : '';
if (!source || !plugins.getLocalPluginBySource) {
return res.status(404).json({ error: 'plugin not found' });
}
const plugin = await plugins.getLocalPluginBySource(db, req.params.id, source);
if (!plugin) {
return res.status(404).json({ error: 'plugin not found' });
}
const registry = await helpers.loadPluginRegistryView();
return applyResolvedPlugin(req, res, plugin, registry);
} catch (err: unknown) {
if (err instanceof plugins.MissingInputError) {
return res.status(422).json({ error: 'missing_inputs', fields: err.fields });
}
return res.status(500).json({ error: String(err) });
}
});
app.post('/api/plugins/:id/apply', async (req, res) => {
try {
const authority = await resolveWorkspaceAuthority(req, res);
if (authority === undefined) return;
const plugin = await resolveRequestPlugin(req.params.id, authority);
if (!plugin) return res.status(404).json({ error: 'plugin not found' });
const body = req.body && typeof req.body === 'object'
? req.body as Record<string, unknown>
: {};
const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : {};
const grantCaps = Array.isArray(body.grantCaps)
? body.grantCaps.filter((c: unknown): c is string => typeof c === 'string')
: [];
const locale = typeof body.locale === 'string' ? body.locale : undefined;
const registry = await helpers.loadPluginRegistryView({
workspaceId: authority?.workspaceId ?? null,
workspaceMemberId: authority?.workspaceMemberId ?? null,
@@ -613,19 +661,7 @@ export function registerPluginRoutes(app: Express, deps: RegisterPluginRoutesDep
) {
return res.status(404).json({ error: 'plugin not found' });
}
const connectorProbe = helpers.buildConnectorProbe(helpers.connectorService);
const computed = plugins.applyPlugin({ plugin, inputs, registry, locale, connectorProbe });
if (grantCaps.length > 0) {
const merged = new Set([...computed.result.capabilitiesGranted, ...grantCaps]);
computed.result.capabilitiesGranted = Array.from(merged);
computed.result.appliedPlugin.capabilitiesGranted = Array.from(merged);
}
res.json({
ok: true,
...computed.result,
warnings: computed.warnings,
manifestSourceDigest: computed.manifestSourceDigest,
});
return applyResolvedPlugin(req, res, plugin, registry);
} catch (err: unknown) {
if (err instanceof plugins.MissingInputError) {
return res.status(422).json({ error: 'missing_inputs', fields: err.fields });
+28 -3
View File
@@ -128,6 +128,10 @@ export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | '
id: string,
options: { workspaceId: string | null; workspaceMemberId: string | null },
) => Promise<unknown | null>;
getLocalPluginBySource?: (
id: string,
source: string,
) => Promise<Parameters<typeof resolvePluginSnapshot>[0]['plugin'] | null>;
};
teamProjectCatalog?: VelaTeamProjectCatalogClient;
/** Bounded authoritative verifier for idempotent Workspace project reads. */
@@ -3490,10 +3494,30 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
typeof req.body?.pluginId === 'string' && req.body.pluginId.trim().length > 0
? req.body.pluginId.trim()
: null;
const requestedPluginSource =
typeof req.body?.pluginSource === 'string' && req.body.pluginSource.trim().length > 0
? req.body.pluginSource.trim()
: null;
const exactLocalPlugin = requestedPluginId && requestedPluginSource
? await ctx.pluginScope?.getLocalPluginBySource?.(
requestedPluginId,
requestedPluginSource,
) ?? null
: null;
if (
requestedPluginId
&& requestedPluginSource
&& ctx.pluginScope?.getLocalPluginBySource
&& !exactLocalPlugin
) {
return sendApiError(res, 404, 'PLUGIN_NOT_FOUND', 'plugin not found');
}
if (requestedPluginId) {
const visiblePlugin = ctx.pluginScope
? await ctx.pluginScope.getPlugin(requestedPluginId, creationWorkspaceScope)
: getInstalledPlugin(db, requestedPluginId);
const visiblePlugin = exactLocalPlugin ?? (
ctx.pluginScope
? await ctx.pluginScope.getPlugin(requestedPluginId, creationWorkspaceScope)
: getInstalledPlugin(db, requestedPluginId)
);
if (!visiblePlugin) {
return sendApiError(res, 404, 'PLUGIN_NOT_FOUND', 'plugin not found');
}
@@ -3649,6 +3673,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
? { id: normalizedDesignSystemId }
: undefined,
connectorProbe: buildConnectorProbe(connectorService),
...(exactLocalPlugin ? { plugin: exactLocalPlugin } : {}),
});
if (resolved && !resolved.ok) {
if (!explicitPlugin) {
+17
View File
@@ -372,6 +372,7 @@ import {
registerBuiltInAtomWorkers,
registerBundledPlugins,
registryRootsForDataDir,
resolveLocalPluginBySource,
restoreProjectSnapshotLink,
resolvePluginSnapshot,
runPipelineForRun,
@@ -7274,6 +7275,11 @@ export async function startServer({
options.workspaceId,
options.workspaceMemberId,
),
getLocalPluginBySource: (id, source) => getLocalPluginBySource(
db,
id,
source,
),
},
events: projectEventDeps,
ids: idDeps,
@@ -8182,6 +8188,16 @@ export async function startServer({
(plugin) => plugin.id === id,
) ?? null;
};
const getLocalPluginBySource = async (
dbHandle,
id: string,
source: string,
) => resolveLocalPluginBySource({
db: dbHandle,
id,
source,
userPluginsRoot: PLUGIN_REGISTRY_ROOTS.userPluginsRoot,
});
registerPluginRoutes(app, {
db,
@@ -8204,6 +8220,7 @@ export async function startServer({
listInstalledPlugins: listWorkspacePlugins,
getInstalledPlugin,
getWorkspacePlugin: getWorkspacePluginForRequest,
getLocalPluginBySource,
installPlugin,
isSafePluginId,
uninstallPlugin,
@@ -105,4 +105,83 @@ describe('Team plugin apply retraction gate', () => {
expect(response.status).toBe(404);
expect(applyPlugin).not.toHaveBeenCalled();
});
it('applies the exact local Team materialization without Workspace headers', async () => {
const app = express();
app.use(express.json());
const applyPlugin = vi.fn((input: { plugin: { source?: string } }) => ({
result: {
query: input.plugin.source,
capabilitiesGranted: [],
appliedPlugin: { capabilitiesGranted: [] },
},
warnings: [],
manifestSourceDigest: 'team-digest',
}));
const middleware: express.RequestHandler = (_req, _res, next) => next();
registerPluginRoutes(app, {
db: {
prepare: () => ({ all: () => [], get: () => null, run: () => undefined }),
transaction: (run: () => unknown) => () => run(),
},
paths: { PROJECTS_DIR: '', PLUGIN_REGISTRY_ROOTS: [], PLUGIN_LOCKFILE_PATH: '' },
ids: { randomId: () => 'unused' },
projectStore: {},
conversations: {},
plugins: {
getInstalledPlugin: () => ({
id: 'shared-id',
source: 'local:personal:shared-id',
}),
getWorkspacePlugin: async () => null,
getLocalPluginBySource: async (_db: unknown, id: string, source: string) =>
id === 'shared-id' && source === 'team:plugin:ws-a:shared-id'
? { id, source }
: null,
listInstalledPlugins: () => [],
applyPlugin,
MissingInputError: class MissingInputError extends Error {
fields: string[] = [];
},
},
helpers: {
requireLocalDaemonRequest: middleware,
pluginUpload: {
single: () => middleware,
array: () => middleware,
},
loadPluginRegistryView: async () => ({}),
buildConnectorProbe: () => ({}),
connectorService: {},
sendApiError: (res: express.Response, status: number, code: string, message: string) =>
res.status(status).json({ error: { code, message } }),
},
} as unknown as Parameters<typeof registerPluginRoutes>[1]);
const server = app.listen(0, '127.0.0.1');
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
const response = await fetch(
`http://127.0.0.1:${port}/api/plugins/shared-id/apply-local`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
source: 'team:plugin:ws-a:shared-id',
inputs: {},
}),
},
);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
ok: true,
query: 'team:plugin:ws-a:shared-id',
});
expect(applyPlugin).toHaveBeenCalledWith(expect.objectContaining({
plugin: expect.objectContaining({ source: 'team:plugin:ws-a:shared-id' }),
}));
});
});
@@ -0,0 +1,121 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { openDatabase } from '../src/db.js';
import {
materializeWorkspaceScopedTeamResource,
teamResourceMaterializationDir,
} from '../src/collab/team-resource-materialization.js';
import {
resolveLocalPluginBySource,
resolvePluginFolder,
resolvePluginSnapshot,
upsertInstalledPlugin,
} from '../src/plugins/index.js';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) =>
rm(root, { recursive: true, force: true }),
));
});
async function pluginManifest(folder: string, title: string): Promise<void> {
await mkdir(folder, { recursive: true });
await writeFile(
path.join(folder, 'open-design.json'),
JSON.stringify({ name: 'shared-id', title, version: '1.0.0' }),
);
}
describe('resolveLocalPluginBySource', () => {
it('selects an exact Team materialization when a Personal plugin has the same id', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'od-local-plugin-source-'));
roots.push(root);
const dataDir = path.join(root, 'data');
const pluginsRoot = path.join(dataDir, 'plugins');
const db = openDatabase(root, { dataDir });
const personalFolder = path.join(root, 'personal-plugin');
await pluginManifest(personalFolder, 'Personal copy');
const personal = await resolvePluginFolder({
folder: personalFolder,
folderId: 'shared-id',
sourceKind: 'local',
source: 'local:personal:shared-id',
});
if (!personal.ok) throw new Error(personal.errors.join('; '));
upsertInstalledPlugin(db, personal.record);
const team = await materializeWorkspaceScopedTeamResource({
kindRoot: pluginsRoot,
identity: {
kind: 'plugin',
workspaceId: 'workspace-a',
resourceId: 'shared-id',
hubResourceId: 'hub-team-shared-id',
},
storageName: 'shared-id',
pullInto: (dir) => pluginManifest(dir, 'Team copy'),
verifyWorkspaceScope: async () => true,
verifyStillShared: async () => true,
});
if (team.status !== 'committed') throw new Error('fixture did not materialize');
const exactTeamPlugin = await resolveLocalPluginBySource({
db,
id: 'shared-id',
source: team.sourceKey,
userPluginsRoot: pluginsRoot,
});
expect(exactTeamPlugin).toMatchObject({
id: 'shared-id',
title: 'Team copy',
source: 'team:plugin:workspace-a:shared-id',
fsPath: teamResourceMaterializationDir(
pluginsRoot,
'workspace-a',
'shared-id',
'shared-id',
),
});
await expect(resolveLocalPluginBySource({
db,
id: 'shared-id',
source: 'local:personal:shared-id',
userPluginsRoot: pluginsRoot,
})).resolves.toMatchObject({ title: 'Personal copy' });
db.prepare(`
INSERT INTO projects (id, name, created_at, updated_at)
VALUES (?, ?, ?, ?)
`).run('team-project', 'Team project', 1, 1);
const snapshot = resolvePluginSnapshot({
db,
body: { pluginId: 'shared-id' },
projectId: 'team-project',
registry: { skills: [], designSystems: [], craft: [], atoms: [] },
plugin: exactTeamPlugin ?? undefined,
});
expect(snapshot?.ok).toBe(true);
if (snapshot?.ok) {
expect(snapshot.snapshot.pluginTitle).toBe('Team copy');
}
});
it('rejects a forged Team source without an exact materialization marker', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'od-local-plugin-source-'));
roots.push(root);
const dataDir = path.join(root, 'data');
const db = openDatabase(root, { dataDir });
await expect(resolveLocalPluginBySource({
db,
id: 'shared-id',
source: 'team:plugin:workspace-forged:shared-id',
userPluginsRoot: path.join(dataDir, 'plugins'),
})).resolves.toBeNull();
});
});
+2
View File
@@ -221,6 +221,7 @@ type AppCreateProjectInput = Omit<CreateInput, 'metadata'> & {
metadata?: CreateInput['metadata'];
pendingPrompt?: string;
pluginId?: string;
pluginSource?: string;
pluginType?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
@@ -2892,6 +2893,7 @@ function AppInner() {
metadata,
...(input.conversationMode ? { conversationMode: input.conversationMode } : {}),
...(input.pluginId ? { pluginId: input.pluginId } : {}),
...(input.pluginSource ? { pluginSource: input.pluginSource } : {}),
...(input.appliedPluginSnapshotId
? { appliedPluginSnapshotId: input.appliedPluginSnapshotId }
: {}),
+2
View File
@@ -283,6 +283,7 @@ type EntryCreateProjectInput = Omit<CreateInput, 'metadata'> & {
metadata?: CreateInput['metadata'];
pendingPrompt?: string;
pluginId?: string;
pluginSource?: string;
pluginType?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
@@ -1415,6 +1416,7 @@ export function EntryShell({
metadata,
pendingPrompt: payload.prompt,
...(payload.pluginId ? { pluginId: payload.pluginId } : {}),
...(payload.pluginSource ? { pluginSource: payload.pluginSource } : {}),
...(payload.pluginType ? { pluginType: payload.pluginType } : {}),
...(payload.appliedPluginSnapshotId
? { appliedPluginSnapshotId: payload.appliedPluginSnapshotId }
+1
View File
@@ -49,6 +49,7 @@ type EntryCreateProjectInput = Omit<CreateInput, 'metadata'> & {
metadata?: CreateInput['metadata'];
pendingPrompt?: string;
pluginId?: string;
pluginSource?: string;
pluginType?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
+8 -32
View File
@@ -95,11 +95,8 @@ import { setPendingDesignSystemCreateEntry } from '../analytics/ds-create-entry'
import { workspaceContextLinkedDirs } from './workspace-context';
import {
currentWorkspaceAccountGeneration,
type CurrentWorkspaceContextReadWitness,
resolveCurrentWorkspaceContextReadWitness,
useTeamProjects,
useWorkspaceContext,
workspaceContextReadWitnessFromState,
} from '../collab/useWorkspaceContext';
import { useWorkspaceInvalidation } from '../collab/workspace-events';
import { useWorkspaceSnapshotActivation } from '../collab/workspace-snapshot-activation';
@@ -1374,41 +1371,17 @@ export function HomeView({
setPendingChipId(null);
}
}
let writeWorkspaceContext;
let workspaceWitness: CurrentWorkspaceContextReadWitness | null = null;
const requiresWorkspaceIdentity = record.source.startsWith('team:plugin:');
if (!requiresWorkspaceIdentity) {
try {
if (workspaceContextState.identityChangePending) {
throw new Error('workspace identity change pending');
}
writeWorkspaceContext = resolvedWorkspaceContextForWrite(workspaceContextState);
} catch {
writeWorkspaceContext = null;
}
} else {
try {
workspaceWitness = workspaceContextReadWitnessFromState(workspaceContextState)
?? await resolveCurrentWorkspaceContextReadWitness();
} catch {
workspaceWitness = null;
}
if (!workspaceWitness?.isStillCurrent() || !workspaceWitness.context) {
clearPendingApply();
setError(
'Workspace context is unavailable. Try again when workspace sync finishes.',
);
return null;
}
writeWorkspaceContext = workspaceWitness.context;
}
const writeWorkspaceContext = resolvedWorkspaceContextForWrite(
workspaceContextState,
{ unavailablePolicy: 'unscoped' },
);
const result = await applyPlugin(record.id, {
locale,
inputs,
pluginSource: record.source,
workspaceContext: writeWorkspaceContext,
});
clearPendingApply();
if (workspaceWitness && !workspaceWitness.isStillCurrent()) return null;
return result;
}
@@ -2483,6 +2456,9 @@ export function HomeView({
const accepted = await onSubmit({
prompt: trimmed,
pluginId: routedPluginId,
...(submittedActive?.record.source
? { pluginSource: submittedActive.record.source }
: {}),
pluginType: submittedActive?.record.marketplaceTrust ?? (routedPluginId ? 'official' : null),
skillId: resolvedSkillId,
appliedPluginSnapshotId: submittedActive?.result?.appliedPlugin?.snapshotId ?? null,
@@ -172,6 +172,7 @@ export function InlinePluginsRail(props: Props) {
const result = await applyPlugin(record.id, {
...(props.projectId ? { projectId: props.projectId } : {}),
locale,
pluginSource: record.source,
workspaceContext: writeWorkspaceContext,
});
if (workspaceIdentityRef.current !== issuedIdentity) return;
+6 -1
View File
@@ -298,9 +298,14 @@ export function PluginDetailView(props: Props) {
});
setApplying(true);
setError(null);
const workspaceContext = resolvedWorkspaceContextForWrite(
workspaceContextState,
{ unavailablePolicy: 'unscoped' },
);
const result = await applyPlugin(plugin.id, {
locale,
workspaceContext: resolvedWorkspaceContextForWrite(workspaceContextState),
pluginSource: plugin.source,
workspaceContext,
});
setApplying(false);
if (!result) {
+7 -1
View File
@@ -30,6 +30,7 @@ import { useWorkspaceContext } from '../collab/useWorkspaceContext';
export interface PluginLoopSubmit {
prompt: string;
pluginId: string | null;
pluginSource?: string | null;
// Marketplace trust of the routed plugin (official / community / …), used
// to attribute project_create_result to a plugin type. Null when no plugin.
pluginType?: string | null;
@@ -132,9 +133,14 @@ export function PluginLoopHome({ onSubmit }: Props) {
) {
setPendingApplyId(record.id);
setError(null);
const workspaceContext = resolvedWorkspaceContextForWrite(
workspaceContextState,
{ unavailablePolicy: 'unscoped' },
);
const result = await applyPlugin(record.id, {
locale,
workspaceContext: resolvedWorkspaceContextForWrite(workspaceContextState),
pluginSource: record.source,
workspaceContext,
});
setPendingApplyId(null);
if (!result) {
+3 -1
View File
@@ -167,11 +167,13 @@ export const PluginsSection = forwardRef<PluginsSectionHandle, Props>(
try {
workspaceContext = workspaceContextForAction();
} catch {
return null;
if (!record?.source) return null;
workspaceContext = null;
}
const result = await applyPlugin(pluginId, {
...(props.projectId ? { projectId: props.projectId } : {}),
locale,
...(record?.source ? { pluginSource: record.source } : {}),
workspaceContext,
});
if (!result) return null;
+1
View File
@@ -404,6 +404,7 @@ export function PluginsView({
setNotice(null);
const result = await applyPlugin(record.id, {
locale,
pluginSource: record.source,
workspaceContext: pluginsContextRef.current,
});
setPendingApplyId(null);
+43 -13
View File
@@ -602,6 +602,7 @@ export async function createProject(
// (or pre-applied snapshot id) to resolve and pin a plugin to the new
// project. Used by the PluginLoopHome flow on Home.
pluginId?: string;
pluginSource?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
workspaceContext?: WorkspaceCollabContext | null;
@@ -1571,9 +1572,10 @@ export async function persistTabsToDaemonNow(
// Plan §3.C1 — plugin discovery + apply.
//
// applyPlugin() is the canonical entry point for both the inline rail
// (NewProjectPanel + ChatComposer) and the marketplace detail page. It
// hits POST /api/plugins/:id/apply, which is the same pure resolver
// the daemon uses; the response carries everything the composer needs:
// (NewProjectPanel + ChatComposer) and the marketplace detail page. Record-aware
// callers use the exact local source resolver; id-only compatibility callers
// retain POST /api/plugins/:id/apply. Both responses carry everything the
// composer needs:
// - query (pre-filled brief)
// - contextItems (chip strip)
// - inputs (form fields)
@@ -2428,13 +2430,46 @@ export async function applyPlugin(
projectId?: string;
grantCaps?: string[];
locale?: string;
pluginSource?: string;
workspaceContext?: WorkspaceCollabContext | null;
} = {},
): Promise<ApplyResult | null> {
try {
const resp = await fetch(
`/api/plugins/${encodeURIComponent(pluginId)}/apply`,
const requestBody = JSON.stringify({
...(options.pluginSource ? { source: options.pluginSource } : {}),
inputs: options.inputs ?? {},
projectId: options.projectId,
grantCaps: options.grantCaps ?? [],
locale: options.locale,
});
const pluginUrl = `/api/plugins/${encodeURIComponent(pluginId)}`;
let resp = await fetch(
`${pluginUrl}/${options.pluginSource ? 'apply-local' : 'apply'}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(!options.pluginSource && options.workspaceContext
? workspaceProjectHeaders(options.workspaceContext)
: {}),
},
body: requestBody,
},
);
// Compatibility with a daemon that predates exact local-source apply. The
// legacy route keeps its original Workspace-scoped behavior and response;
// new daemons never take this branch for a valid catalog record.
const localApplyUnsupported = Boolean(
options.pluginSource
&& resp.status === 404
&& resp.headers.get('x-od-plugin-apply-local') !== '1',
);
const legacyFallbackCanResolveSelectedSource = Boolean(
!options.pluginSource?.startsWith('team:plugin:')
|| options.workspaceContext,
);
if (localApplyUnsupported && legacyFallbackCanResolveSelectedSource) {
resp = await fetch(`${pluginUrl}/apply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -2442,14 +2477,9 @@ export async function applyPlugin(
? workspaceProjectHeaders(options.workspaceContext)
: {}),
},
body: JSON.stringify({
inputs: options.inputs ?? {},
projectId: options.projectId,
grantCaps: options.grantCaps ?? [],
locale: options.locale,
}),
},
);
body: requestBody,
});
}
if (!resp.ok) return null;
const json = (await resp.json()) as ApplyResult & { ok?: boolean };
return json;
@@ -446,7 +446,7 @@ describe('HomeView media composer options', () => {
})));
});
it('re-applies the selected plugin from directory identity while rich context is loading', async () => {
it('re-applies the exact local source without waiting for rich Workspace context', async () => {
const fetchMock = stubFetch({ teamMediaPlugin: true });
const onSubmit = vi.fn();
const props = homeProps({ onSubmit });
@@ -465,12 +465,15 @@ describe('HomeView media composer options', () => {
await submitHome();
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const scopedApply = fetchMock.mock.calls.filter(([url, init]) => (
const localApply = fetchMock.mock.calls.filter(([url]) => (
typeof url === 'string'
&& url.includes('/api/plugins/od-media-generation/apply')
&& new Headers(init?.headers).get('x-od-workspace-id') === 'workspace-cold'
&& url.includes('/api/plugins/od-media-generation/apply-local')
)).at(-1);
expect(scopedApply).toBeTruthy();
expect(localApply).toBeTruthy();
expect(new Headers(localApply?.[1]?.headers).has('x-od-workspace-id')).toBe(false);
expect(JSON.parse(String(localApply?.[1]?.body))).toMatchObject({
source: 'team:plugin:workspace-a:od-media-generation',
});
});
it('keeps bundled plugins usable when identity is pending and the directory is empty', async () => {
@@ -558,7 +561,7 @@ describe('HomeView media composer options', () => {
))).toBe(false);
});
it('does not apply a team plugin unscoped when the settled workspace directory is empty', async () => {
it('applies an already-local Team plugin when the settled workspace directory is empty', async () => {
const fetchMock = stubFetch({
emptyWorkspaceDirectory: true,
teamMediaPlugin: true,
@@ -577,9 +580,11 @@ describe('HomeView media composer options', () => {
await waitFor(() => {
expect(screen.getByTestId('home-hero-submit').getAttribute('aria-busy')).toBe('false');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/od-media-generation/apply')
))).toBe(false);
const localApply = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/od-media-generation/apply-local')
));
expect(localApply).toBeTruthy();
expect(new Headers(localApply?.[1]?.headers).has('x-od-workspace-id')).toBe(false);
});
it('preserves od-media-generation required inputs when applying media chips', async () => {
@@ -618,7 +618,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')) {
return applyResponse;
}
throw new Error(`unexpected fetch ${url}`);
@@ -646,7 +646,7 @@ describe('HomeView prompt handoff', () => {
expect(inputCard?.style.getPropertyValue('--home-hero-prompt-max-height')).toBe('132px');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-plugin-authoring/apply',
'/api/plugins/od-plugin-authoring/apply-local',
expect.anything(),
));
resolveApply(new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
@@ -680,7 +680,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')) {
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -718,7 +718,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')) {
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -745,12 +745,12 @@ describe('HomeView prompt handoff', () => {
const dialog = await screen.findByRole('dialog', { name: /replace current prompt/i });
expect(homeHeroPromptText()).toBe('Keep my custom plugin brief');
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply')
typeof url === 'string' && url.includes('/api/plugins/od-plugin-authoring/apply-local')
))).toBe(false);
fireEvent.click(within(dialog).getByRole('button', { name: 'Replace' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-plugin-authoring/apply',
'/api/plugins/od-plugin-authoring/apply-local',
expect.anything(),
));
await waitFor(() => expect(homeHeroPromptText()).toBe(PLUGIN_AUTHORING_PROMPT));
@@ -765,7 +765,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -795,7 +795,7 @@ describe('HomeView prompt handoff', () => {
expect(screen.getByTestId('home-hero-active-plugin')).toBeTruthy();
});
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
await screen.findByTestId('home-hero-input');
@@ -813,6 +813,7 @@ describe('HomeView prompt handoff', () => {
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: 'Use the selected starter as the driver',
pluginId: 'example-web-prototype',
pluginSource: WEB_PROTOTYPE_PLUGIN.source,
appliedPluginSnapshotId: 'snap-web-prototype',
})));
});
@@ -861,7 +862,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -884,11 +885,11 @@ describe('HomeView prompt handoff', () => {
await clickHomeShortcut('create-plugin');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-new-generation/apply',
'/api/plugins/od-new-generation/apply-local',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply')
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply-local')
));
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
inputs: {
@@ -925,7 +926,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -955,7 +956,7 @@ describe('HomeView prompt handoff', () => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('UI Mockup');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
))).toBe(false);
// The design-system picker is now a persistent control in the row below the
// composer (next to the working-directory picker), available for every
@@ -985,11 +986,11 @@ describe('HomeView prompt handoff', () => {
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
));
const protoApplyInputs = JSON.parse(String((applyCall?.[1] as RequestInit).body)).inputs;
expect(protoApplyInputs).toMatchObject({
@@ -1025,7 +1026,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply-local')) {
return new Response(JSON.stringify(DOCUMENT_NEW_GENERATION_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1059,11 +1060,11 @@ describe('HomeView prompt handoff', () => {
fireEvent.click(submit);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-new-generation/apply',
'/api/plugins/od-new-generation/apply-local',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply')
typeof url === 'string' && url.includes('/api/plugins/od-new-generation/apply-local')
));
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
inputs: {
@@ -1089,7 +1090,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1130,11 +1131,11 @@ describe('HomeView prompt handoff', () => {
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
));
const protoApplyInputs = JSON.parse(String((applyCall?.[1] as RequestInit).body)).inputs;
expect(protoApplyInputs).toMatchObject({ designSystem: 'No design system' });
@@ -1154,7 +1155,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1218,7 +1219,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1258,7 +1259,7 @@ describe('HomeView prompt handoff', () => {
);
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
))).toBe(false);
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('UI Mockup');
// The design-system picker is now the persistent control below the composer.
@@ -1281,11 +1282,11 @@ describe('HomeView prompt handoff', () => {
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
));
// The preset card seeds the prompt as plain text while preserving the
// chip's structured inputs (artifactKind / fidelity / audience /
@@ -1322,7 +1323,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(LIVE_ARTIFACT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1366,7 +1367,7 @@ describe('HomeView prompt handoff', () => {
expect(homeHeroPromptText()).toBe('Create a live Notion dashboard artifact.');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/apply')
typeof url === 'string' && url.includes('/apply-local')
))).toBe(false);
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Live artifact');
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
@@ -1378,7 +1379,7 @@ describe('HomeView prompt handoff', () => {
// reference), while the live-artifact chip's project kind + metadata are
// carried forward. Submit resolves the snapshot for the preset plugin.
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/image-template-notion-team-dashboard-live-artifact/apply',
'/api/plugins/image-template-notion-team-dashboard-live-artifact/apply-local',
expect.anything(),
));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
@@ -1402,7 +1403,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply-local')) {
return new Response(JSON.stringify(LIVE_ARTIFACT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1430,17 +1431,17 @@ describe('HomeView prompt handoff', () => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('Live artifact');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply')
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply-local')
))).toBe(false);
await setPromptAndSettle('Build a refreshable Stripe revenue dashboard.');
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-live-artifact/apply',
'/api/plugins/example-live-artifact/apply-local',
expect.anything(),
));
const applyCall = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply')
typeof url === 'string' && url.includes('/api/plugins/example-live-artifact/apply-local')
));
expect(JSON.parse(String((applyCall?.[1] as RequestInit).body))).toMatchObject({
inputs: {},
@@ -1470,7 +1471,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply-local')) {
return new Response(JSON.stringify(SIMPLE_DECK_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1525,7 +1526,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1554,7 +1555,7 @@ describe('HomeView prompt handoff', () => {
expect(screen.getByTestId('home-hero-template-trigger').textContent).toContain('UI Mockup');
});
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
))).toBe(false);
expect(homeHeroPromptText()).toBe('Keep my current brief');
expect(screen.queryByRole('dialog', { name: /replace current prompt/i })).toBeNull();
@@ -1568,13 +1569,13 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply-local')) {
return new Response(JSON.stringify(SIMPLE_DECK_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1605,7 +1606,7 @@ describe('HomeView prompt handoff', () => {
expect(screen.getByTestId('home-hero-plugin-presets').textContent).toContain('Simple Deck');
fireEvent.click(screen.getAllByTestId('home-hero-plugin-preset')[0]!);
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply')
typeof url === 'string' && url.includes('/api/plugins/example-simple-deck/apply-local')
))).toBe(false);
await waitFor(() => {
expect(homeHeroPromptText()).toBe(
@@ -1620,7 +1621,7 @@ describe('HomeView prompt handoff', () => {
});
fireEvent.click(screen.getAllByTestId('home-hero-plugin-preset')[0]!);
expect(fetchMock.mock.calls.some(([url]) => (
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')
typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')
))).toBe(false);
await waitFor(() => {
expect(homeHeroPromptText()).toBe(
@@ -1637,7 +1638,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1689,7 +1690,7 @@ describe('HomeView prompt handoff', () => {
expect(screen.getByTestId('home-hero-active-plugin')).toBeTruthy();
});
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
});
@@ -1707,7 +1708,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1746,7 +1747,7 @@ describe('HomeView prompt handoff', () => {
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.';
await waitFor(() => expect(homeHeroPromptText()).toBe(seed));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
@@ -1778,7 +1779,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1819,7 +1820,7 @@ describe('HomeView prompt handoff', () => {
const appended = `Keep my current brief\n\n${query}`;
await waitFor(() => expect(homeHeroPromptText()).toBe(appended));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
@@ -1851,7 +1852,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/example-web-prototype/apply-local')) {
return new Response(JSON.stringify(WEB_PROTOTYPE_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1890,7 +1891,7 @@ describe('HomeView prompt handoff', () => {
'Build a high-fidelity web prototype for product evaluators using the active project design system from the bundled web prototype seed.';
await waitFor(() => expect(homeHeroPromptText()).toBe(seed));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
'/api/plugins/example-web-prototype/apply-local',
expect.anything(),
));
@@ -1921,7 +1922,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/api/plugins/example-meta-landing/apply')) {
if (typeof url === 'string' && url.includes('/api/plugins/example-meta-landing/apply-local')) {
return new Response(JSON.stringify(META_INSTRUCTION_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1959,7 +1960,7 @@ describe('HomeView prompt handoff', () => {
expect(homeHeroPromptText()).not.toContain('verbatim');
expect(homeHeroPromptText()).not.toContain('example.html');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-meta-landing/apply',
'/api/plugins/example-meta-landing/apply-local',
expect.anything(),
));
});
@@ -1972,7 +1973,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -1996,7 +1997,7 @@ describe('HomeView prompt handoff', () => {
await clearActiveTypeChip();
await clickHomeShortcut('create-plugin');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-plugin-authoring/apply',
'/api/plugins/od-plugin-authoring/apply-local',
expect.anything(),
));
await waitFor(() => {
@@ -2028,7 +2029,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
if (typeof url === 'string' && url.includes('/apply-local')) {
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
@@ -2052,7 +2053,7 @@ describe('HomeView prompt handoff', () => {
await clearActiveTypeChip();
await clickHomeShortcut('create-plugin');
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-plugin-authoring/apply',
'/api/plugins/od-plugin-authoring/apply-local',
expect.anything(),
));
@@ -2087,7 +2088,7 @@ describe('HomeView prompt handoff', () => {
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
if (typeof url === 'string' && url.includes('/apply-local')) {
return applyResponse;
}
throw new Error(`unexpected fetch ${url}`);
@@ -4,7 +4,7 @@
//
// Asserts that:
// - The rail fetches GET /api/plugins on mount and renders one card per row.
// - Clicking a card POSTs to /api/plugins/:id/apply and forwards the
// - Clicking a card POSTs to /api/plugins/:id/apply-local and forwards the
// ApplyResult to onApplied.
// - The rail filters by taskKind / mode when supplied.
@@ -130,6 +130,13 @@ describe('InlinePluginsRail', () => {
const card = await waitFor(() => screen.getByTitle('A fixture'));
fireEvent.click(card);
await waitFor(() => expect(onApplied).toHaveBeenCalled());
const applyCall = fetchMock.mock.calls.find(([url]) => (
url === '/api/plugins/sample-plugin/apply-local'
));
expect(applyCall).toBeTruthy();
expect(JSON.parse(String(applyCall?.[1]?.body))).toMatchObject({
source: '/tmp/sample',
});
const [record, result] = onApplied.mock.calls[0]!;
expect(record.id).toBe('sample-plugin');
expect(result.appliedPlugin.snapshotId).toBe('snap-1');
@@ -104,7 +104,10 @@ async function renderDetailAndUse() {
const useButton = await screen.findByTestId('plugin-detail-use');
fireEvent.click(useButton);
await waitFor(() => {
expect(vi.mocked(applyPlugin)).toHaveBeenCalledWith(PLUGIN.id, expect.anything());
expect(vi.mocked(applyPlugin)).toHaveBeenCalledWith(
PLUGIN.id,
expect.objectContaining({ pluginSource: PLUGIN.source }),
);
});
}
@@ -147,6 +147,13 @@ describe('PluginsSection', () => {
// rendered: inputs fall back to schema defaults instead of being edited inline.
expect(screen.queryByTestId('plugin-inputs-form')).toBeNull();
expect(onApplied).toHaveBeenCalled();
const localApply = fetchMock.mock.calls.find(([url]) => (
url === '/api/plugins/sample-plugin/apply-local'
));
expect(localApply).toBeTruthy();
expect(JSON.parse(String(localApply?.[1]?.body))).toMatchObject({
source: '/tmp/sample',
});
const [brief, applied] = onApplied.mock.calls[0]!;
// `topic` has no schema default, so it stays un-substituted in the brief.
expect(brief).toContain('{{topic}}');
+106
View File
@@ -82,6 +82,40 @@ function teamWorkspaceContext(
};
}
describe('createProject', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('preserves the exact local plugin source in the create payload', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => Response.json({
project: {
id: 'project-local-plugin',
name: 'Local plugin project',
skillId: null,
designSystemId: null,
createdAt: 1,
updatedAt: 1,
},
conversationId: 'conversation-1',
}));
vi.stubGlobal('fetch', fetchMock);
await createProject({
name: 'Local plugin project',
skillId: null,
designSystemId: null,
pluginId: 'shared-plugin-id',
pluginSource: 'team:plugin:workspace-a:shared-plugin-id',
});
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toMatchObject({
pluginId: 'shared-plugin-id',
pluginSource: 'team:plugin:workspace-a:shared-plugin-id',
});
});
});
describe('createConversation', () => {
afterEach(() => {
vi.unstubAllGlobals();
@@ -294,6 +328,78 @@ describe('applyPlugin', () => {
});
});
it('uses the selected installed source for local apply without Workspace headers', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ ok: true }),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
await applyPlugin('shared-plugin-id', {
pluginSource: 'team:plugin:workspace-a:shared-plugin-id',
} as Parameters<typeof applyPlugin>[1]);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe('/api/plugins/shared-plugin-id/apply-local');
expect(new Headers(init?.headers).has('x-od-workspace-id')).toBe(false);
expect(JSON.parse(String(init?.body))).toMatchObject({
source: 'team:plugin:workspace-a:shared-plugin-id',
inputs: {},
grantCaps: [],
});
});
it('falls back to the legacy apply route only when an old daemon lacks local apply', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (String(url).endsWith('/apply-local')) {
return new Response('not found', { status: 404 });
}
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);
await applyPlugin('bundled-plugin', { pluginSource: 'bundled:bundled-plugin' });
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'/api/plugins/bundled-plugin/apply-local',
'/api/plugins/bundled-plugin/apply',
]);
});
it('does not fall back when a new daemon rejects the exact local source', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(
JSON.stringify({ error: 'plugin not found' }),
{
status: 404,
headers: {
'content-type': 'application/json',
'x-od-plugin-apply-local': '1',
},
},
));
vi.stubGlobal('fetch', fetchMock);
await expect(applyPlugin('shared-plugin-id', {
pluginSource: 'team:plugin:workspace-a:shared-plugin-id',
})).resolves.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('does not let an old daemon resolve a Team source headerless', async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response('not found', { status: 404 }));
vi.stubGlobal('fetch', fetchMock);
await expect(applyPlugin('shared-plugin-id', {
pluginSource: 'team:plugin:workspace-a:shared-plugin-id',
})).resolves.toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0]?.[0]).toBe('/api/plugins/shared-plugin-id/apply-local');
});
it('scopes same-id plugin apply requests to the exact A/B workspace', async () => {
const fetchMock = vi.fn<typeof fetch>(async () =>
new Response(JSON.stringify({ ok: true }), {
+2
View File
@@ -317,6 +317,8 @@ export interface CreateProjectRequest {
pendingPrompt?: string;
metadata?: ProjectMetadata;
pluginId?: string;
/** Exact source of the already-local plugin record selected by the UI. */
pluginSource?: string;
appliedPluginSnapshotId?: string;
pluginInputs?: Record<string, unknown>;
/** Session mode for the default conversation seeded with the project. */