feat: integrate marketplace plugins with coding runtime

This commit is contained in:
brother7 committed 2026-08-28 18:08:33 +08:00
1 parent 1d64b89499
commit 05917a789a
21 files changed
+1921 -29

No files matched your search

@@ -15,6 +15,10 @@ import {
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
import { productToolDetailsOfResult } from '../../shared/coding-conversation-product-tool-protocol';
import type { DataServiceErrorContext, DataServiceHostResult } from '../../shared/data-service';
import type {
EffectivePluginResolver,
EffectivePluginSnapshot,
} from '../../electron/coding-plugins/effective-resolver';
const policy: PluginPolicyClientState = {
status: 'current',
@@ -199,6 +203,49 @@ describe('CodingCapabilityRegistry', () => {
expect(JSON.stringify(invalidIdentity)).not.toMatch(/[0-9a-f]{8}-[0-9a-f]{4}/i);
});
it('refuses a new plugin action from an old worker after lifecycle invalidation', async () => {
const frozenSnapshot: EffectivePluginSnapshot = {
accountSessionId: 'account-a\u00001',
projectId: context.projectId,
pluginReleaseIds: [],
effectiveSkillIds: ['data-service'],
skillEntries: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }],
toolDefinitions: DATA_SERVICE_PLUGIN_DEFINITION.tools,
runtimePolicies: [],
unavailableReasons: [],
};
const currentSnapshot: EffectivePluginSnapshot = {
...frozenSnapshot,
effectiveSkillIds: [],
skillEntries: [],
toolDefinitions: [],
unavailableReasons: [{
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
code: 'project_disabled',
message: 'Plugin is not enabled for this project',
}],
};
const effectiveResolver = {
resolve: vi.fn(async () => currentSnapshot),
getSkillSources: vi.fn(async () => []),
getPolicyState: vi.fn(() => policy),
} as unknown as EffectivePluginResolver;
const result = await registry({ effectiveResolver }).invoke({
toolName: 'data_service_get_document',
context: { ...context, effectiveSnapshot: frozenSnapshot },
workerRole: 'parent',
effectiveSkillIds: frozenSnapshot.effectiveSkillIds,
value: { collection: 'todos', document_id: 'one' },
});
expect(result.details).toMatchObject({ success: false, code: 'plugin_not_enabled' });
expect(effectiveResolver.resolve).toHaveBeenCalledWith(expect.objectContaining({
projectId: context.projectId,
projectPath: context.projectPath,
role: 'parent',
}));
});
it('keeps the replay identity and parses bounded Data Service faults for all ten tools', async () => {
const fault = (
code: string,
@@ -0,0 +1,256 @@
// @vitest-environment node
import { describe, expect, it, vi } from 'vitest';
import {
createEffectivePluginResolver,
type EffectivePluginResolver,
} from '../../electron/coding-plugins/effective-resolver';
import type { CodingPluginDefinition } from '../../shared/coding-plugins';
const skillOnlyDefinition: CodingPluginDefinition = {
id: 'makelore.notes',
version: '1.0.0',
contractVersion: 1,
displayName: 'Notes',
description: 'Notes skill',
runtimeKind: 'skill_only',
acquisitionMode: 'user_acquired',
releaseId: 'notes-1',
provenance: { source: 'marketplace', packageRoot: 'C:/packages/notes-1' },
scope: 'project',
adapterId: '',
requiresBackend: false,
skills: [{ id: 'notes', entryPath: 'skills/notes/SKILL.md', grants: [] }],
tools: [],
operations: [],
surfaces: {},
};
const serverDefinition: CodingPluginDefinition = {
id: 'makelore.remote',
version: '1.0.0',
contractVersion: 1,
displayName: 'Remote',
description: 'Remote capability',
runtimeKind: 'platform_hosted',
acquisitionMode: 'user_acquired',
releaseId: 'remote-1',
provenance: { source: 'marketplace', packageRoot: 'C:/packages/remote-1' },
scope: 'project',
adapterId: '',
requiresBackend: true,
skills: [{ id: 'remote', entryPath: 'skills/remote/SKILL.md', grants: ['remote.read'] }],
tools: [{
name: 'remote_read', label: 'Remote read', description: 'Read remotely',
capabilityId: 'remote.read', operation: 'read', roles: ['parent'], mutation: 'read',
projectWriteLease: false, permissions: ['remote.read'],
inputSchema: { type: 'object', additionalProperties: false, properties: {} },
}],
operations: [{ capabilityId: 'remote.read', operation: 'read', toolName: 'remote_read' }],
surfaces: {},
};
const binding = { accountKey: 'account-a', epoch: 4 };
function library(runtimeStatus: 'enabled' | 'suspended' = 'enabled', catalogStatus: 'active' | 'retired' = 'active') {
return {
items: [{
pluginId: 'makelore.notes', title: 'Notes', summary: 'Notes', category: 'productivity',
acquisition: 'free' as const, acquisitionMode: 'user_acquired' as const,
catalogStatus, runtimeStatus, acquiredAt: null, removedAt: null,
stableVersion: '1.0.0', betaVersion: null,
}],
total: 1, stale: false, fetchedAt: 1,
};
}
function resolver(overrides: Partial<Parameters<typeof createEffectivePluginResolver>[0]> = {}) {
const effective = createEffectivePluginResolver({
definitions: [skillOnlyDefinition],
getAccountBinding: () => binding,
getLibrary: vi.fn(async () => library()),
getInstalled: vi.fn(async () => ({
pluginId: skillOnlyDefinition.id,
releaseId: skillOnlyDefinition.releaseId!,
version: skillOnlyDefinition.version,
packageSchemaVersion: 2,
contractVersion: skillOnlyDefinition.contractVersion,
runtimeKind: 'skill_only' as const,
sha256: 'a'.repeat(64),
sizeBytes: 1,
installedAt: '2026-08-28T00:00:00.000Z',
packageRoot: skillOnlyDefinition.provenance.packageRoot,
definition: skillOnlyDefinition,
})),
getEnabledPluginIds: vi.fn(async () => [skillOnlyDefinition.id]),
...overrides,
});
return effective;
}
function installed(definition: CodingPluginDefinition) {
return {
pluginId: definition.id,
releaseId: definition.releaseId!,
version: definition.version,
packageSchemaVersion: 2,
contractVersion: definition.contractVersion,
runtimeKind: definition.runtimeKind,
sha256: 'b'.repeat(64),
sizeBytes: 1,
installedAt: '2026-08-28T00:00:00.000Z',
packageRoot: definition.provenance.packageRoot,
definition,
};
}
function currentPolicy() {
return {
status: 'current' as const,
revision: 8,
lastVerifiedAt: 1,
catalog: {
schema_version: 1 as const,
catalog_version: 'catalog-a',
pricing_version: null,
plugins: [{
plugin_id: serverDefinition.id,
supported_contract_versions: [1],
status: 'active' as const,
capabilities: [{
capability_id: 'remote.read',
operations: [{
operation: 'read',
billing: { mode: 'included' as const, entitlement_scope: null, notice: 'Included' },
}],
}],
}],
},
};
}
async function resolve(effective: EffectivePluginResolver, assignedSkillIds = ['notes']) {
return await effective.resolve({
projectId: 'project-a',
projectPath: 'C:/project-a',
assignedSkillIds,
role: 'parent',
});
}
describe('effective plugin resolver', () => {
it('requires current Library, installed Release, project selection, and assignment', async () => {
const effective = resolver();
await expect(resolve(effective)).resolves.toMatchObject({
accountSessionId: 'account-a\u00004',
projectId: 'project-a',
pluginReleaseIds: ['notes-1'],
effectiveSkillIds: ['notes'],
skillEntries: [{ id: 'notes', entryPath: 'skills/notes/SKILL.md' }],
toolDefinitions: [],
runtimePolicies: [],
unavailableReasons: [],
});
await expect(resolve(effective, [])).resolves.toMatchObject({
effectiveSkillIds: [],
skillEntries: [],
unavailableReasons: [expect.objectContaining({ pluginId: 'makelore.notes', code: 'skill_unassigned' })],
});
await expect(resolve(effective, ['notes', 'removed-plugin-skill'])).resolves.toMatchObject({
effectiveSkillIds: ['notes'],
unavailableReasons: [],
});
await expect(resolve(resolver({ getEnabledPluginIds: vi.fn(async () => []) }))).resolves.toMatchObject({
effectiveSkillIds: [],
unavailableReasons: [expect.objectContaining({ pluginId: 'makelore.notes', code: 'project_disabled' })],
});
});
it('keeps retired Library plugins usable but excludes suspended runtime plugins', async () => {
await expect(resolve(resolver({ getLibrary: vi.fn(async () => library('enabled', 'retired')) })))
.resolves.toMatchObject({ effectiveSkillIds: ['notes'], pluginReleaseIds: ['notes-1'] });
await expect(resolve(resolver({ getLibrary: vi.fn(async () => library('suspended')) })))
.resolves.toMatchObject({
effectiveSkillIds: [],
pluginReleaseIds: [],
unavailableReasons: [expect.objectContaining({ pluginId: 'makelore.notes', code: 'runtime_suspended' })],
});
});
it('never exposes plugin resources to a child worker', async () => {
const result = await resolver().resolve({
projectId: 'project-a', projectPath: 'C:/project-a', assignedSkillIds: ['notes'], role: 'child',
});
expect(result.pluginReleaseIds).toEqual([]);
expect(result.effectiveSkillIds).toEqual([]);
expect(result.skillEntries).toEqual([]);
expect(result.toolDefinitions).toEqual([]);
});
it('keeps skill-only local while requiring current server policy for tools', async () => {
const refresh = vi.fn();
const effective = createEffectivePluginResolver({
definitions: [serverDefinition],
getAccountBinding: () => binding,
getLibrary: vi.fn(async () => ({
...library(),
items: [{ ...library().items[0], pluginId: serverDefinition.id }],
})),
getInstalled: vi.fn(async () => installed(serverDefinition)),
getEnabledPluginIds: vi.fn(async () => [serverDefinition.id]),
policyClient: { getState: currentPolicy, refresh },
});
await expect(effective.resolve({
projectId: 'project-a', projectPath: 'C:/project-a', assignedSkillIds: ['remote'], role: 'parent',
})).resolves.toMatchObject({
pluginReleaseIds: ['remote-1'],
effectiveSkillIds: ['remote'],
skillEntries: [{ id: 'remote', entryPath: 'skills/remote/SKILL.md' }],
toolDefinitions: [{ name: 'remote_read' }],
runtimePolicies: [{
pluginId: serverDefinition.id, contractVersion: 1,
capabilityId: 'remote.read', operation: 'read',
}],
unavailableReasons: [],
});
expect(refresh).not.toHaveBeenCalled();
let policy = { ...currentPolicy(), status: 'stale' as const };
const stalePolicyResolver = createEffectivePluginResolver({
definitions: [serverDefinition],
getAccountBinding: () => binding,
getLibrary: vi.fn(async () => ({
...library(),
items: [{ ...library().items[0], pluginId: serverDefinition.id }],
})),
getInstalled: vi.fn(async () => installed(serverDefinition)),
getEnabledPluginIds: vi.fn(async () => [serverDefinition.id]),
policyClient: {
getState: () => policy,
refresh: vi.fn(async () => undefined),
},
});
await expect(stalePolicyResolver.resolve({
projectId: 'project-a', projectPath: 'C:/project-a', assignedSkillIds: ['remote'], role: 'parent',
})).resolves.toMatchObject({
effectiveSkillIds: [], toolDefinitions: [], runtimePolicies: [],
unavailableReasons: [expect.objectContaining({ code: 'policy_unavailable' })],
});
const local = createEffectivePluginResolver({
definitions: [skillOnlyDefinition],
getAccountBinding: () => binding,
getLibrary: vi.fn(async () => library()),
getInstalled: vi.fn(async () => installed(skillOnlyDefinition)),
getEnabledPluginIds: vi.fn(async () => [skillOnlyDefinition.id]),
policyClient: { getState: () => ({ ...currentPolicy(), catalog: null }), refresh },
});
await expect(local.resolve({
projectId: 'project-a', projectPath: 'C:/project-a', assignedSkillIds: ['notes'], role: 'parent',
})).resolves.toMatchObject({ effectiveSkillIds: ['notes'], toolDefinitions: [], runtimePolicies: [] });
expect(refresh).not.toHaveBeenCalled();
});
});
@@ -380,6 +380,54 @@ describe('PluginPackageStore', () => {
expect(issueDownload).toHaveBeenCalledWith({ releaseId: RELEASE_ID, releaseAdmissionId: ADMISSION_ID });
});
it('removes only releases with no account or active-worker reference', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
const { grant, publicKey } = signedGrant(archive);
const accountCache = new AccountPluginCache();
const marketplace: MarketplaceClient = {
resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, {
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
})),
issueDownload: vi.fn(async () => grant),
downloadContent: async () => archive,
getCurrentAccountBinding: () => ACCOUNT_A,
} as MarketplaceClient;
const store = new PluginPackageStore({
rootDir: temporaryRoot,
marketplace,
clientVersion: '1.0.0',
keyStore: new Map([['test-key', publicKey]]),
accountCache,
getAccountBinding: () => ACCOUNT_A,
});
await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' });
accountCache.setResolve(ACCOUNT_A, 'protected', makeResolveResult({ makeloreVersion: '1.0.0' }, {
sha256: grant.sha256,
sizeBytes: grant.sizeBytes,
}));
await expect(store.removeUnused(PLUGIN_ID)).resolves.toMatchObject({
status: 'kept', pluginId: PLUGIN_ID, releaseId: RELEASE_ID,
});
accountCache.invalidateAll();
store.registerActiveWorker(RELEASE_ID);
await expect(store.removeUnused(PLUGIN_ID)).resolves.toMatchObject({
status: 'kept', pluginId: PLUGIN_ID, releaseId: RELEASE_ID,
});
store.releaseActiveWorker(RELEASE_ID);
await expect(store.removeUnused(PLUGIN_ID)).resolves.toMatchObject({
status: 'removed', pluginId: PLUGIN_ID, releaseId: RELEASE_ID,
});
await expect(store.readInstalledIndex()).resolves.toEqual([]);
await expect(store.removeUnused(PLUGIN_ID)).resolves.toEqual({
status: 'removed', pluginId: PLUGIN_ID, reason: 'none',
});
});
it('preserves the old immutable release when index replacement fails', async () => {
temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-'));
const archive = buildSkillOnlyArchive();
@@ -19,6 +19,7 @@ import {
createCodingProjectMetadata,
} from '../../electron/coding-projects/project-config';
import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools';
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
const roots: string[] = [];
const conversationId = '11111111-1111-4111-8111-111111111111';
@@ -179,6 +180,43 @@ describe('PI-105 product Host composition', () => {
);
});
it('keeps a resolver-disabled assignment visible without passing it to worker resources', async () => {
const root = await configuredProject(['data-service']);
const tools = productTools(root, true);
const effectiveResolver = {
resolve: vi.fn(async () => ({
accountSessionId: 'account-a\u00001',
projectId: 'project-a',
pluginReleaseIds: [],
effectiveSkillIds: [],
skillEntries: [],
toolDefinitions: [],
runtimePolicies: [],
unavailableReasons: [{
pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id,
code: 'project_disabled' as const,
message: 'Plugin is not enabled for this project',
}],
})),
getSkillSources: vi.fn(async () => []),
getPolicyState: vi.fn(() => ({
status: 'current' as const, catalog: null, revision: 1, lastVerifiedAt: 1,
})),
};
const host = createCodingProductHost({
projects: projectService(root),
productTools: tools,
effectiveResolver,
});
await expect(host.listSkills('builder')).resolves.toContainEqual(expect.objectContaining({
id: 'data-service', selected: true, available: false, effective: false,
}));
expect(effectiveResolver.resolve).toHaveBeenCalledWith(expect.objectContaining({
assignedSkillIds: ['data-service'], role: 'parent',
}));
});
it('reads exact-run changes from the same PiProductTools tracker instance', async () => {
const root = await configuredProject();
await writeFile(path.join(root, 'notes.txt'), 'baseline\n', 'utf8');
@@ -28,6 +28,9 @@ import type { PiWorkerProcessOptions } from '../../electron/coding-runtime/pi/wo
import type { PiRuntimeTelemetryEvent } from '../../electron/coding-runtime/pi/telemetry';
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
import { PiWorkerPool } from '../../electron/coding-runtime/pi/worker-pool';
import type { CodingCapabilityRegistry } from '../../electron/coding-plugins/registry';
import type { EffectivePluginSnapshot } from '../../electron/coding-plugins/effective-resolver';
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
const roots: string[] = [];
const NOW = '2026-08-22T16:00:00.000Z';
@@ -128,6 +131,32 @@ describe('managed Pi worker opener', () => {
const telemetry: PiRuntimeTelemetryEvent[] = [];
const registry = new PiSessionRegistry({ projectStore });
const extensionHost = new PiManagedExtensionHost();
const effectiveSnapshot: EffectivePluginSnapshot = Object.freeze({
accountSessionId: 'account-a\u00001',
projectId: 'project-a',
pluginReleaseIds: Object.freeze(['plugin-release-a']),
effectiveSkillIds: Object.freeze(['grilling']),
skillEntries: Object.freeze([{ id: 'grilling', entryPath: 'grilling/SKILL.md' }]),
toolDefinitions: Object.freeze(DATA_SERVICE_PLUGIN_DEFINITION.tools),
runtimePolicies: Object.freeze([]),
unavailableReasons: Object.freeze([]),
});
const capabilityRegistry = {
resolveWorkerResources: vi.fn(async () => ({
catalogRevision: 1,
pluginIds: [],
effectiveSkillIds: ['grilling'],
skillEntries: [{ id: 'grilling', entryPath: 'grilling/SKILL.md' }],
tools: DATA_SERVICE_PLUGIN_DEFINITION.tools,
effectiveSnapshot,
})),
} as unknown as CodingCapabilityRegistry;
const releaseCleanups: Array<ReturnType<typeof vi.fn>> = [];
const registerActivePluginReleases = vi.fn((_releaseIds: readonly string[]) => {
const cleanup = vi.fn();
releaseCleanups.push(cleanup);
return cleanup;
});
const createOpener = (
openerRegistry: PiSessionRegistry,
createSessionKey: () => string = () => 'session-key-a',
@@ -138,6 +167,8 @@ describe('managed Pi worker opener', () => {
userDataDir,
bundledSkillsDir: path.resolve('resources/coding-skills'),
extensionHost,
capabilityRegistry,
registerActivePluginReleases,
loadProviderInput: async () => ({ accounts: [account], modelSummaries: [] }),
resolveCredential: async () => 'provider-secret-value',
createSessionKey,
@@ -194,6 +225,15 @@ describe('managed Pi worker opener', () => {
expect(options.sensitiveValues).toContain('provider-secret-value');
expect(options.env?.MAKELORE_PI_BRIDGE_URL).toMatch(/^http:\/\/127\.0\.0\.1:/);
expect(options.env?.MAKELORE_PI_CONTEXT_FILE).toContain('worker-');
expect(options.tools?.filter((name) => name.startsWith('data_service_')))
.toEqual(effectiveSnapshot.toolDefinitions.map(({ name }) => name));
const workerContext = JSON.parse(await readFile(
options.env?.MAKELORE_PI_CONTEXT_FILE as string,
'utf8',
)) as Record<string, unknown>;
expect(workerContext.effectivePluginSnapshot).toEqual(effectiveSnapshot);
expect(workerContext.allowedToolNames)
.toEqual(effectiveSnapshot.toolDefinitions.map(({ name }) => name));
}
const modelsFile = path.join(userDataDir, 'coding-runtime', 'pi', 'config', 'models.json');
expect(await readFile(modelsFile, 'utf8')).not.toContain('provider-secret-value');
@@ -210,9 +250,13 @@ describe('managed Pi worker opener', () => {
expect(first.worker.delayNextResponseForProof).toBeTypeOf('function');
first.worker.delayNextResponseForProof?.('prompt', 12_000);
expect(processes[0]?.proofResponseDelays).toEqual([{ commandType: 'prompt', delayMs: 12_000 }]);
expect(registerActivePluginReleases).toHaveBeenCalledTimes(3);
expect(registerActivePluginReleases).toHaveBeenNthCalledWith(1, ['plugin-release-a']);
await first.worker.stop('test_injection');
await reopened.worker.stop('test_injection');
await restarted.worker.stop('test_injection');
expect(releaseCleanups).toHaveLength(3);
expect(releaseCleanups.every((cleanup) => cleanup.mock.calls.length === 1)).toBe(true);
await extensionHost.close();
});
+14
View File
@@ -246,6 +246,20 @@ describe('PI-090 product tools', () => {
});
});
it('preserves an uninstalled unknown assignment in project config without blocking projections', async () => {
const root = await temporaryRoot('makelore-pi-removed-skill-');
const tools = new PiProductTools({
browser: {} as AgentBrowserModule,
attachments: new CodingAttachmentStore(path.join(root, 'attachments')),
bundledSkillsDir: path.resolve('resources/coding-skills'),
getPluginSkillSources: async () => [],
});
await expect(tools.listSkills(['removed-plugin-skill'])).resolves.not.toContainEqual(
expect.objectContaining({ id: 'removed-plugin-skill' }),
);
});
it('projects effective plugin Skills in the worker runtime context', async () => {
const root = await temporaryRoot('makelore-pi-runtime-context-');
const tools = new PiProductTools({
+15
View File
@@ -14,6 +14,7 @@ import {
} from '@electron/coding-runtime/pi/resource-loader';
import type { PluginPolicyClientState } from '@electron/services/plugin-policy-client';
import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins';
import type { EffectivePluginSnapshot } from '../../electron/coding-plugins/effective-resolver';
const temporaryRoots: string[] = [];
@@ -57,6 +58,16 @@ describe('Pi managed resource loader', () => {
it('materializes only managed prompt and explicitly selected bundled skills', async () => {
const fixture = await fixtureRoot();
const prompt = 'PRIVATE PARTNER PROMPT CONTENT';
const effectiveSnapshot: EffectivePluginSnapshot = {
accountSessionId: 'account-a\u00001',
projectId: 'project-1',
pluginReleaseIds: ['release-a'],
effectiveSkillIds: ['grilling'],
skillEntries: [{ id: 'grilling', entryPath: 'grilling/SKILL.md' }],
toolDefinitions: [],
runtimePolicies: [],
unavailableReasons: [],
};
const resources = await materializePiAgentResources({
userDataDir: fixture.userDataDir,
projectId: 'project-1',
@@ -68,6 +79,7 @@ describe('Pi managed resource loader', () => {
],
catalogRevision: 11,
bundledSkillsDir: fixture.skillsDir,
effectiveSnapshot,
revision: { provider: 3, resources: 7 },
});
@@ -88,8 +100,11 @@ describe('Pi managed resource loader', () => {
skillIds: ['grilling'],
skillEntries: [{ id: 'grilling', entryPath: 'grilling/SKILL.md' }],
catalogRevision: 11,
effectivePluginSnapshot: effectiveSnapshot,
revision: { provider: 3, resources: 7 },
});
expect(resources.effectivePluginSnapshot).toEqual(effectiveSnapshot);
expect(resources.summary.effectivePluginSnapshot).toEqual(effectiveSnapshot);
expect(JSON.stringify(manifest)).not.toContain(prompt);
await expect(readFile(path.join(fixture.userDataDir, '.pi', 'agents', 'agent-1.md'), 'utf8'))
.rejects.toMatchObject({ code: 'ENOENT' });
@@ -0,0 +1,104 @@
// @vitest-environment node
import { EventEmitter } from 'node:events';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { describe, expect, it, vi } from 'vitest';
import type { HostApiContext } from '../../electron/api/context';
import { handlePluginMarketplaceRoutes } from '../../electron/api/routes/plugin-marketplace';
function request(method: string, body?: unknown): IncomingMessage {
const req = new EventEmitter();
const raw = body === undefined ? undefined : JSON.stringify(body);
Object.assign(req, {
method,
headers: raw === undefined ? {} : { 'content-length': String(Buffer.byteLength(raw)) },
[Symbol.asyncIterator]: async function* () {
if (raw !== undefined) yield Buffer.from(raw);
},
});
return req as IncomingMessage;
}
function response() {
const chunks: string[] = [];
const res = new EventEmitter();
Object.assign(res, {
statusCode: 0,
setHeader: vi.fn(),
end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }),
});
return {
res: res as unknown as ServerResponse,
get status() { return (res as { statusCode: number }).statusCode; },
json: () => JSON.parse(chunks.join('')) as Record<string, unknown>,
};
}
async function invoke(ctx: HostApiContext, method: string, target: string, body?: unknown) {
const output = response();
const handled = await handlePluginMarketplaceRoutes(
request(method, body),
output.res,
new URL(`http://localhost${target}`),
ctx,
);
return { handled, status: output.status, payload: output.json() };
}
describe('Main-owned plugin Marketplace routes', () => {
it('forwards only bounded catalog query fields and detail IDs', async () => {
const catalog = vi.fn().mockResolvedValue({ items: [], total: 0 });
const detail = vi.fn().mockResolvedValue({ pluginId: 'notes' });
const ctx = { codingProducts: { pluginMarketplace: {
readCatalog: catalog,
readDetail: detail,
} } } as unknown as HostApiContext;
await expect(invoke(ctx, 'GET', '/api/coding/plugin-marketplace/catalog?query=notes&featured=true&limit=3'))
.resolves.toMatchObject({ handled: true, status: 200 });
expect(catalog).toHaveBeenCalledWith({ query: 'notes', featured: true, limit: 3 });
await expect(invoke(ctx, 'GET', '/api/coding/plugin-marketplace/plugins/notes'))
.resolves.toMatchObject({ handled: true, status: 200 });
expect(detail).toHaveBeenCalledWith('notes');
const rejected = await invoke(ctx, 'GET', '/api/coding/plugin-marketplace/catalog?accountKey=forged');
expect(rejected).toMatchObject({ status: 400, payload: { code: 'plugin_request_invalid' } });
expect(catalog).toHaveBeenCalledOnce();
});
it('keeps acquire, install, update, and uninstall as explicit separate Main actions', async () => {
const acquire = vi.fn().mockResolvedValue({ items: [] });
const remove = vi.fn().mockResolvedValue({ items: [] });
const install = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'notes' });
const update = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'notes' });
const uninstall = vi.fn().mockResolvedValue({ status: 'removed', pluginId: 'notes' });
const ctx = { codingProducts: { pluginMarketplace: {
acquire, remove, install, update, uninstall,
readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(),
} } } as unknown as HostApiContext;
expect((await invoke(ctx, 'PUT', '/api/coding/plugin-marketplace/library/notes')).status).toBe(200);
expect((await invoke(ctx, 'DELETE', '/api/coding/plugin-marketplace/library/notes')).status).toBe(200);
expect((await invoke(ctx, 'POST', '/api/coding/plugin-marketplace/install/notes')).status).toBe(200);
expect((await invoke(ctx, 'POST', '/api/coding/plugin-marketplace/update/notes')).status).toBe(200);
expect((await invoke(ctx, 'DELETE', '/api/coding/plugin-marketplace/install/notes')).status).toBe(200);
expect(acquire).toHaveBeenCalledWith('notes');
expect(remove).toHaveBeenCalledWith('notes');
expect(install).toHaveBeenCalledWith('notes');
expect(update).toHaveBeenCalledWith('notes');
expect(uninstall).toHaveBeenCalledWith('notes');
});
it('rejects Renderer authority fields and never exposes backend errors', async () => {
const acquire = vi.fn();
const ctx = { codingProducts: { pluginMarketplace: {
acquire,
readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(),
remove: vi.fn(), install: vi.fn(), update: vi.fn(), uninstall: vi.fn(),
} } } as unknown as HostApiContext;
const forged = await invoke(ctx, 'PUT', '/api/coding/plugin-marketplace/library/notes', {
accountId: 'forged', installRoot: 'C:\\untrusted',
});
expect(forged).toMatchObject({ status: 400, payload: { code: 'plugin_request_invalid' } });
expect(acquire).not.toHaveBeenCalled();
});
});