feat: integrate marketplace plugins with coding runtime
This commit is contained in:
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user