diff --git a/.project-docs/30-worklog/tasks/20260828-plugin-marketplace-mlm03-4c8e1a7d.md b/.project-docs/30-worklog/tasks/20260828-plugin-marketplace-mlm03-4c8e1a7d.md new file mode 100644 index 0000000..2d219b0 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260828-plugin-marketplace-mlm03-4c8e1a7d.md @@ -0,0 +1,153 @@ +# Task: Implement MLM-03 effective resolver, Main routes, Pi, and lifecycle + +## Identity + +- Task ID: 20260828-plugin-marketplace-mlm03-4c8e1a7d +- Mode: Feature +- Branch: codex/20260828-plugin-marketplace-mlm03-4c8e1a7d-marketplace-mlm03 +- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm03-4c8e1a7d +- Base commit: 1d64b89499f68de721e0f1c845dad2c57f1a78ed +- Owner: marketplace-mlm03-implementer +- Status: Ready for Integration + +## Scope + +- Implement MLM-03 from the exact integrated MLM-02 frontier. Own only the + Main-owned effective resolver, project-plugin registry/service/composition + seams, Marketplace local Host routes, required Pi resource/extension/runtime/ + worker lifecycle integration, focused resolver/route/lifecycle/Pi tests, and + this task record. +- Do not modify Renderer, Package Store/MarketplaceClient/account cache, server, + Operations, project root, or unrelated Pi behavior unless a real semantic seam + blocker is found and reported first. + +## Intent And Constraints + +- Implement Marketplace Release A only: one frozen `EffectivePluginSnapshot` + supplies Skills, declarations, Extension Host, Pi CLI `--tools`, worker + context, and safe Renderer projection. Preserve the separation of acquisition, + installation, project enablement, Agent assignment, backend configuration, and + runtime authorization. +- Exact eligibility is runtime-enabled AND (system-included OR current account + Library) AND compatible installed/bundled Release AND project enabled AND + assigned Skill allowed AND (skill_only OR admitted current server policy). + Retired blocks discovery/new acquire but does not erase an existing Library + user's local eligibility; suspended blocks new resolve/download/runtime + admission. Skill-only does not wait for Runtime Policy. +- Preserve retained disabled/removed/unknown project and Agent assignments, + child-empty/current-worker-frozen semantics, Main-derived account/path/release/ + admission authority, and lifecycle invalidation on disable/remove/account or + project switch/suspend refresh/Main stop. No hosted adapter, Provider, + Plugin Charges, arbitrary code, generic invoke/config route, or billing path. +- Concurrent Task Gate: passed. `check_project_docs.py` succeeded; task_context + owns this isolated worktree and task ID; `status --json` matches owner, + feature mode, branch, worktree, and exact base commit. +- Planning Gate: passed. Required project memory, architecture/domain indexes, + accepted decisions, Marketplace implementation spec/ticket graph, MLM-01 and + MLM-02 outcomes, and coordinator scope were read. Other active local task + records were assessed; no unresolved semantic conflict or ownership overlap + blocks this plan. Coordinator/user-root worktrees remain untouched. +- The original coordinator interrupted an unresponsive prior implementer. This + serial finisher resumed the same registered logical owner and preserved the + complete dirty diff; the prior agent was confirmed interrupted and no + concurrent writer remained. +- The coordinator explicitly transferred one predecessor-owned seam for this + task only: `PluginPackageStore.removeUnused` and its focused regression. + No other MLM-02 Package Store ownership was adopted. + +## Outcome + +- Implemented one Main-owned effective resolver for bundled and immutable + installed Plugin definitions. It joins runtime status, current Account + Library, compatible installed/bundled Release, project selection, Agent Skill + assignment, and current Runtime Policy; `skill_only` does not wait for Policy, + retired Library entries remain eligible, suspended entries do not, and child + snapshots contain no Plugin resources. +- Composed the frozen `EffectivePluginSnapshot` through registry resources, + installed Skill roots, managed resource manifests, Extension Host context and + bridge declarations, Pi CLI tool allowlist, runtime context, and safe Host + projections. Known disabled and removed/unknown assignments remain in project + and Agent files but are not passed to workers; reinstall/re-enable restores + them without rewriting configuration. +- Added bounded Main Marketplace catalog/detail/Library/acquire/remove/install/ + update/uninstall routes. Renderer supplies only a Plugin ID and explicit + action; Main derives account, release, admission, client version, and package + root, while route failures exclude tokens, paths, signed URLs, and raw bodies. +- Lifecycle invalidation now covers project selection, Library/session/account + changes, successful next-worker resolution, and Main shutdown. Parent workers + ref-count their frozen Release IDs in Package Store until stop/invalidation; + old workers re-resolve authority before a Plugin action and refuse stale or + disabled resources. Child workers register no Plugin Release or tool. +- Corrected the transferred Package Store seam: `removeUnused` no longer + self-protects the latest Release. A single unreferenced Release can be removed, + while every account snapshot and active worker still protects it and a missing + install remains idempotently removed. +- No Renderer page, hosted adapter, Provider, Plugin Charges, generic invoke/ + config route, arbitrary code/MCP/hook path, server, Operations, project-root + schema, Release B, or Token Point behavior was added. + +## Implementation Plan + +1. Inspect the exact MLM-02 Main contracts, current project/Agent Skill + assignment and Pi opener/lifecycle seams, plus existing route/test conventions; + record any genuine seam mismatch before editing. +2. Add red focused tests for the exact resolver predicate and state separation, + skill-only/policy-backed behavior, retired/suspended handling, snapshot + equality across Pi/resource/declarations/CLI/context, child-empty and worker + freeze/invalidation, lifecycle cleanup, and Main local route authority. +3. Implement the smallest cohesive resolver/registry/service/composition/routes + and Pi lifecycle changes within the owned paths, preserving Data Service's + exact ten tools and preview lifecycle. +4. Run focused tests, relevant Pi/Data Service regressions, typecheck, scoped + lint/full lint as proportionate, Vite build for Main/preload/package imports, + and task-aware diff/doc gates. Fix only failures attributable to this scope. +5. Update this record with actual outcome/evidence/follow-ups, complete the task + context, and create one clean source commit whose sole parent is the exact + dispatch base. + +## Verification + +- Initial focused diagnosis: + `coding-plugin-effective-resolver`, `plugin-marketplace-routes`, and + `coding-product-services` — 3 files / 13 passed; initial typecheck passed. +- Red/green corrections: + Package Store cleanup failed because the unreferenced latest Release returned + `kept`; worker lifecycle failed because active-Release registration was called + zero times; removed/unknown assignments failed in both resolver and Product + Tools with `Unknown bundled coding skill`. Each passed after its minimal fix. +- Final focused plus adjacent resolver/routes/registry/Pi/Package Store/Data + Service matrix — 17 files / 142 passed / 2 staged-runtime skips. +- `pnpm test` — 205 files / 1,738 passed / 2 staged-runtime skips, followed by + the serialized coding-chat pressure file 1/1 passed. +- `pnpm run typecheck` — passed. +- `pnpm run lint:check` — 0 errors; the same five pre-existing Renderer warnings + in `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx` remain. +- `pnpm run build:vite` — Renderer, Main, Preload, and release utility passed; + only existing dynamic-import and chunk-size warnings were reported. +- `pnpm run package:win` — passed; staged bundled Python and uv, built Pi + win32-x64 closure (130 packages / 6 assets), `win-unpacked`, and NSIS installer. +- `pnpm run verify:artifact:pi` — passed against final `Makelore.exe`/`app.asar`; + Pi is exactly 0.84.2, packaged closure is complete, the extension contract is + present, and bundled Data Service exposes exactly ten tools. The inherited + real-Provider and macOS/native-Linux waivers remain explicit. +- `pnpm run verify:publish-runtime` — passed with npm 11.6.2. +- Windows packaged-runtime verification with the verifier's explicit + `--allow-dirty` mode passed against the exact pre-commit product bytes: + installer 208,233,099 bytes, SHA-256 + `CE9049C26939DE3281F0100936D87E39C968047BCD559ED764C5231521DC5B4B`; + bundled Python, uv 0.10.0, npm 11.6.2, native addons, and Unicode copy passed. + A default clean-tree verifier rerun is the final post-commit gate. +- `git diff --check` — passed before documentation finalization. + +## Follow-ups + +- Production Marketplace activation still depends on the official Ed25519 + signing public/private pair already recorded by MLM-02; this task adds no key + or trust override. +- Real Provider compatibility and macOS x64/arm64 plus native non-WSL Linux + product artifact execution remain explicitly unverified inherited release + gates; no broader platform Pass is claimed here. + +## Promotion Candidates + +- None recorded. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 94f6cfa..a6d57ed 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -40,6 +40,20 @@ import { createCodingCapabilityRegistry, } from '../coding-plugins/registry'; import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service'; +import { AccountPluginCache } from '../coding-plugins/account-plugin-cache'; +import { + createMarketplaceClient, + type MarketplaceClient, +} from '../coding-plugins/marketplace-client'; +import { + PluginPackageStore, + type PluginPackageStore as PluginPackageStoreType, +} from '../coding-plugins/package-store'; +import { + createEffectivePluginResolver, + type EffectivePluginResolver, +} from '../coding-plugins/effective-resolver'; +import { subscribeWorksSquareSession } from '../services/works-square-session'; import { PluginPolicyClient } from '../services/plugin-policy-client'; import { loadBundledCodingPluginDefinitionsSync, @@ -47,7 +61,9 @@ import { } from '../coding-plugins/manifest'; import { createCodingProjectPluginService, + createCodingPluginMarketplaceService, type CodingProjectPluginService, + type CodingPluginMarketplaceService, } from './coding-product-services'; export interface CodingCompositionPaths { @@ -64,6 +80,11 @@ export interface CreateCodingCompositionOptions { paths: CodingCompositionPaths; getLocalProxyCredential?(): string | undefined; acquireBackgroundLease?(lease: { id: string; kind: 'coding-run' }): () => void; + marketplaceClient?: MarketplaceClient; + packageStore?: PluginPackageStoreType; + accountCache?: AccountPluginCache; + clientVersion?: string; + policyClient?: PluginPolicyClient; } export function resolveCodingPiRuntimePaths(input: { @@ -105,6 +126,38 @@ export function createCodingComposition( entryPath: path.basename(skill.entryPath), })) )); + const accountCache = options.accountCache ?? new AccountPluginCache(); + const marketplaceClient = options.marketplaceClient ?? createMarketplaceClient({ accountCache }); + const packageStore = options.packageStore ?? new PluginPackageStore({ + rootDir: path.join(options.paths.userDataDir, 'coding-plugins'), + marketplace: marketplaceClient, + accountCache, + clientVersion: options.clientVersion ?? '2.0.0', + }); + const activePluginReleaseCounts = new Map(); + const registerActivePluginReleases = (releaseIds: readonly string[]): (() => void) => { + const uniqueReleaseIds = [...new Set(releaseIds)]; + for (const releaseId of uniqueReleaseIds) { + const count = activePluginReleaseCounts.get(releaseId) ?? 0; + if (count === 0) packageStore.registerActiveWorker(releaseId); + activePluginReleaseCounts.set(releaseId, count + 1); + } + let released = false; + return () => { + if (released) return; + released = true; + for (const releaseId of uniqueReleaseIds) { + const count = activePluginReleaseCounts.get(releaseId) ?? 0; + if (count <= 1) { + activePluginReleaseCounts.delete(releaseId); + packageStore.releaseActiveWorker(releaseId); + } else { + activePluginReleaseCounts.set(releaseId, count - 1); + } + } + }; + }; + let effectiveResolver: EffectivePluginResolver | undefined; const projectStore = options.projectStore ?? createCodingProjectStore(options.storage); const attachments = new CodingAttachmentStore( path.join(options.paths.userDataDir, 'coding-runtime', 'attachments'), @@ -114,6 +167,14 @@ export function createCodingComposition( attachments, bundledSkillsDir: options.paths.bundledSkillsDir, pluginSkillSources, + getPluginSkillSources: async () => effectiveResolver + ? (await effectiveResolver.getSkillSources()).map((source) => ({ + id: source.id, + pluginId: source.pluginId, + directory: source.directory, + entryPath: source.entryPath, + })) + : [], }); const extensionHost = new PiManagedExtensionHost(); extensionHost.configureProductTools(productTools); @@ -172,9 +233,17 @@ export function createCodingComposition( }); const dataService = createDataServiceOperations({ projects }); const dataServiceAdapter = createDataServicePluginAdapter(dataService); - const policyClient = new PluginPolicyClient(); + const policyClient = options.policyClient ?? new PluginPolicyClient(); + const knownPluginIds = new Set(pluginDefinitions.map(({ id }) => id)); + // Existing user Releases are discovered from the device index at startup; + // unlike the bundled catalog they cannot be enumerated synchronously. Keep + // the ProjectPluginService's known-ID gate accurate for a reopened app while + // preserving unknown IDs in existing project files. + void packageStore.readInstalledIndex() + .then((records) => records.forEach(({ pluginId }) => knownPluginIds.add(pluginId))) + .catch(() => undefined); const projectPlugins = createProjectPluginService({ - knownPluginIds: pluginDefinitions.map(({ id }) => id), + knownPluginIds: () => [...knownPluginIds], onManagedInputsChanged: async ({ projectPath }) => { runtime?.markResourcesStale(); const conversations = await conversationStoreForProject(projectPath).read() @@ -187,11 +256,21 @@ export function createCodingComposition( await plugins?.deactivate(projectPath, pluginId); }, }); + effectiveResolver = createEffectivePluginResolver({ + definitions: pluginDefinitions, + packageStore, + marketplace: marketplaceClient, + accountCache, + getAccountBinding: () => marketplaceClient.getCurrentAccountBinding(), + getEnabledPluginIds: (projectPath) => projectPlugins.getEnabledPluginIds(projectPath), + policyClient, + }); const capabilityRegistry = createCodingCapabilityRegistry({ policyClient, projectPlugins, adapters: [dataServiceAdapter], definitions: pluginDefinitions, + effectiveResolver, getDurableProjectId: async (projectPath, localProjectId) => { const active = await projects.requireActiveRealProjectWithIdentity(projectPath); if (active.project.id !== localProjectId) { @@ -207,6 +286,15 @@ export function createCodingComposition( policyClient, adapters: [dataServiceAdapter], definitions: pluginDefinitions, + effectiveResolver, + getDefinitions: async () => { + const installed = await packageStore.readInstalledIndex(); + const definitions = await Promise.all(installed.map(async ({ pluginId }) => { + const release = await packageStore.getInstalled(pluginId); + return release?.definition ?? null; + })); + return definitions.flatMap((definition) => definition ? [definition] : []); + }, }); const workerPool = new PiWorkerPool({ processBudget, @@ -217,6 +305,10 @@ export function createCodingComposition( cliPath: options.paths.cliPath, userDataDir: options.paths.userDataDir, bundledSkillsDir: options.paths.bundledSkillsDir, + getSkillRoots: async () => (effectiveResolver + ? (await effectiveResolver.getSkillSources()).map(({ packageRoot }) => packageRoot) + : []), + registerActivePluginReleases, loadProviderInput, resolveCredential: resolvePiProviderCredentialFromSecretStore, ...(getLocalProxyCredential @@ -240,6 +332,9 @@ export function createCodingComposition( ? { getLocalProxyCredential: async () => getLocalProxyCredential() } : {}), capabilityRegistry, + getSkillRoots: async () => (effectiveResolver + ? (await effectiveResolver.getSkillSources()).map(({ packageRoot }) => packageRoot) + : []), }); const subagents = new PiSubagentScheduler({ openChild: childOpener, @@ -278,10 +373,33 @@ export function createCodingComposition( }); }, }); + const invalidateManagedResources = async (): Promise => { + runtime?.markResourcesStale(); + const projectPaths = [...conversationStores.entries()]; + for (const [, store] of projectPaths) { + const conversationsInProject = await store.read() + .then((file) => file.conversations) + .catch(() => []); + for (const conversation of conversationsInProject) registry.forget(conversation.id); + } + }; + const pluginMarketplace: CodingPluginMarketplaceService = createCodingPluginMarketplaceService({ + marketplace: marketplaceClient, + packageStore, + clientVersion: options.clientVersion ?? '2.0.0', + onChanged: async ({ pluginId, kind }) => { + if (kind === 'install' || kind === 'update') knownPluginIds.add(pluginId); + await invalidateManagedResources(); + }, + }); + const unsubscribeMarketplaceSession = subscribeWorksSquareSession(() => { + void invalidateManagedResources(); + }); const host = createCodingProductHost({ projects, productTools, getEnabledPluginIds: (projectPath) => projectPlugins.getEnabledPluginIds(projectPath), + effectiveResolver, listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId), }); previewDataSession = createPreviewDataSessionManager({ projects }); @@ -297,6 +415,8 @@ export function createCodingComposition( attachments, dataService, plugins, + pluginMarketplace, + marketplace: pluginMarketplace, previewDataSession, productTools, projects, @@ -322,8 +442,14 @@ export function createCodingComposition( unsubscribeBrowserLifecycle(); const active = await projects.getActiveProject(); if (active) await plugins?.deactivate(active.path); - await subagents.close(); - await runtime.shutdown(); + try { + await subagents.close(); + await runtime.shutdown(); + } finally { + unsubscribeMarketplaceSession(); + packageStore.dispose(); + marketplaceClient.dispose(); + } }, }; } diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 06dd864..878b6cd 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -35,6 +35,18 @@ import type { PluginPolicyClient, PluginPolicyClientState, } from '../services/plugin-policy-client'; +import type { + CatalogPage, + CatalogQuery, + MarketplaceClient, + PluginDetail, +} from '../coding-plugins/marketplace-client'; +import type { + InstallationSnapshot, + PluginPackageStore, +} from '../coding-plugins/package-store'; +import type { MarketplaceLibrarySnapshot } from '../coding-plugins/account-plugin-cache'; +import type { EffectivePluginResolver } from '../coding-plugins/effective-resolver'; export interface ActiveCodingProject { id: string; @@ -56,6 +68,9 @@ export interface CodingProductComposition { dataService: DataServiceOperations; previewDataSession?: PreviewDataSessionManager; productTools: PiProductTools; + pluginMarketplace: CodingPluginMarketplaceService; + /** Alias retained for Main callers that refer to the Marketplace service directly. */ + marketplace: CodingPluginMarketplaceService; plugins: CodingProjectPluginService; projects: CodingProjectService; conversations: CodingConversationService; @@ -65,6 +80,83 @@ export interface CodingProductComposition { shutdown(): Promise; } +export type PublicPluginInstallation = Readonly<{ + status: InstallationSnapshot['status']; + pluginId: string; + releaseId?: string; + version?: string; + reason?: string; +}>; + +/** Main-owned Marketplace facade. It never exposes package roots or manifests. */ +export interface CodingPluginMarketplaceService { + readCatalog(input?: CatalogQuery): Promise; + readDetail(pluginId: string): Promise; + readLibrary(): Promise; + acquire(pluginId: string): Promise; + remove(pluginId: string): Promise; + install(pluginId: string): Promise; + update(pluginId: string): Promise; + uninstall(pluginId: string): Promise; +} + +export interface CreateCodingPluginMarketplaceServiceOptions { + marketplace: MarketplaceClient; + packageStore: PluginPackageStore; + clientVersion: string; + onChanged?(event: { + pluginId: string; + kind: 'acquire' | 'remove' | 'install' | 'update' | 'uninstall'; + }): Promise | void; +} + +function publicInstallation(snapshot: InstallationSnapshot): PublicPluginInstallation { + return { + status: snapshot.status, + pluginId: snapshot.pluginId, + ...(snapshot.releaseId ? { releaseId: snapshot.releaseId } : {}), + ...(snapshot.version ? { version: snapshot.version } : {}), + ...(snapshot.reason ? { reason: snapshot.reason } : {}), + }; +} + +export function createCodingPluginMarketplaceService( + options: CreateCodingPluginMarketplaceServiceOptions, +): CodingPluginMarketplaceService { + const install = async (pluginId: string, kind: 'install' | 'update'): Promise => { + const snapshot = await options.packageStore.resolveAndInstall({ + pluginId, + makeloreVersion: options.clientVersion, + channel: 'stable', + }); + await options.onChanged?.({ pluginId, kind }); + return publicInstallation(snapshot); + }; + const uninstall = async (pluginId: string): Promise => { + const snapshot = await options.packageStore.removeUnused(pluginId); + await options.onChanged?.({ pluginId, kind: 'uninstall' }); + return publicInstallation(snapshot); + }; + return { + readCatalog: (input = {}) => options.marketplace.readCatalog(input), + readDetail: (pluginId) => options.marketplace.readDetail(pluginId), + readLibrary: () => options.marketplace.readLibrary(), + acquire: async (pluginId) => { + const snapshot = await options.marketplace.acquire(pluginId); + await options.onChanged?.({ pluginId, kind: 'acquire' }); + return snapshot; + }, + remove: async (pluginId) => { + const snapshot = await options.marketplace.remove(pluginId); + await options.onChanged?.({ pluginId, kind: 'remove' }); + return snapshot; + }, + install: (pluginId) => install(pluginId, 'install'), + update: (pluginId) => install(pluginId, 'update'), + uninstall, + }; +} + export type CodingPluginEffectiveState = | 'unavailable' | 'disabled' @@ -149,6 +241,8 @@ export interface CreateCodingProjectPluginServiceOptions { policyClient: Pick; adapters: readonly CodingPluginAdapter[]; definitions: readonly CodingPluginDefinition[]; + getDefinitions?(): readonly CodingPluginDefinition[] | Promise; + effectiveResolver?: EffectivePluginResolver; } function policyOperation( @@ -256,9 +350,18 @@ function effectiveState(input: { export function createCodingProjectPluginService( options: CreateCodingProjectPluginServiceOptions, ): CodingProjectPluginService { - const definitions = options.definitions; const adapters = new Map(options.adapters.map((adapter) => [adapter.pluginId, adapter])); + async function definitions(): Promise { + const result = new Map(options.definitions.map((definition) => [definition.id, definition])); + for (const definition of options.getDefinitions ? await options.getDefinitions() : []) { + // Bundled definitions are code-owned and cannot be shadowed by an + // installed package reusing their identifier. + if (!result.has(definition.id)) result.set(definition.id, definition); + } + return [...result.values()]; + } + async function project(localProjectId: string) { const id = localProjectId.trim(); if (!id) { @@ -269,24 +372,42 @@ export function createCodingProjectPluginService( async function list(localProjectId: string): Promise { const localProject = await project(localProjectId); - await options.policyClient.refresh(); + const pluginDefinitions = await definitions(); + if (pluginDefinitions.some(({ requiresBackend }) => requiresBackend)) { + await options.policyClient.refresh(); + } const policy = options.policyClient.getState(); const [selection, config] = await Promise.all([ options.projectPlugins.getEnabledPluginIds(localProject.path), readCodingProjectConfigV2(localProject.path), ]); + const assignedSkillIds = config.status === 'valid' + ? [...new Set(config.config.agents + .filter((agent) => agent.enabled && !agent.archivedAt) + .flatMap(({ skillIds }) => skillIds))] + : []; + const effective = options.effectiveResolver + ? await options.effectiveResolver.resolve({ + projectId: localProject.id, + projectPath: localProject.path, + assignedSkillIds, + role: 'parent', + }) + : null; const durableProjectId = config.status === 'valid' ? config.config.projectId ?? null : null; - const items = await Promise.all(definitions.map(async (definition) => { + const items = await Promise.all(pluginDefinitions.map(async (definition) => { const enabled = selection.includes(definition.id); const adapter = adapters.get(definition.id); let backend: PluginBackendProjection; try { - backend = adapter ? boundedBackend(await adapter.inspect(localProject.path)) : degradedBackend(); + backend = adapter + ? boundedBackend(await adapter.inspect(localProject.path)) + : definition.requiresBackend ? degradedBackend() : { status: 'not_required' }; } catch { backend = degradedBackend(); } const pluginPolicy = policy.catalog?.plugins.find(({ plugin_id }) => plugin_id === definition.id); - const policyAvailable = Boolean( + const policyAvailable = !definition.requiresBackend || Boolean( pluginPolicy?.supported_contract_versions.includes(definition.contractVersion), ); const capabilityIds = [...new Set(definition.operations.map(({ capabilityId }) => capabilityId))]; @@ -314,6 +435,16 @@ export function createCodingProjectPluginService( return operations.length > 0 ? [{ id: capabilityId, operations }] : []; }); const agents = config.status === 'valid' ? config.config.agents : []; + const effectiveReason = effective?.unavailableReasons.find(({ pluginId }) => pluginId === definition.id); + const hasEffectiveSkill = effective?.effectiveSkillIds.some((skillId) => ( + definition.skills.some(({ id }) => id === skillId) + )) ?? false; + let state = effectiveState({ enabled, policyAvailable, backend }); + if (effective && effectiveReason?.code === 'project_disabled') state = 'disabled'; + else if (effective && !hasEffectiveSkill && effectiveReason + && ['account_required', 'library_required', 'library_unavailable', 'release_not_installed', + 'release_invalid', 'runtime_suspended', 'policy_unavailable', 'policy_unsupported'] + .includes(effectiveReason.code)) state = 'unavailable'; return { id: definition.id, version: definition.version, @@ -322,7 +453,7 @@ export function createCodingProjectPluginService( displayName: definition.displayName, description: definition.description, enabled, - state: effectiveState({ enabled, policyAvailable, backend }), + state, backend, skills: definition.skills.map((skill) => ({ id: skill.id, @@ -377,6 +508,7 @@ export interface CodingProductHostOptions { productTools: PiProductTools; files?: CodingProjectFileService; getEnabledPluginIds?(projectPath: string): Promise; + effectiveResolver?: EffectivePluginResolver; listPiCommands?(conversationId: string): Promise; } @@ -483,23 +615,53 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi }, async listSkills(agentId) { const project = await activeProject(); - const enabledPluginIds = options.getEnabledPluginIds - ? await options.getEnabledPluginIds(project.path) - : []; + const assignedSkillIds = await selectedSkillIds(project.path, agentId); + const effective = options.effectiveResolver + ? await options.effectiveResolver.resolve({ + projectId: project.id, + projectPath: project.path, + assignedSkillIds, + role: 'parent', + }) + : null; + // Keep the raw assignment for the Renderer projection so a known + // disabled Skill remains visible as selected/ineffective. The resolver + // still supplies the availability set; only the worker opener receives + // the effective subset. + const skillIds = assignedSkillIds; + const enabledPluginIds = effective + ? effective.effectiveSkillIds + : options.getEnabledPluginIds + ? await options.getEnabledPluginIds(project.path) + : []; return await options.productTools.listSkills( - await selectedSkillIds(project.path, agentId), + skillIds, enabledPluginIds, ); }, async listCommands(conversationId) { const context = await conversationContext(conversationId); - const enabledPluginIds = options.getEnabledPluginIds - ? await options.getEnabledPluginIds(context.project.path) - : []; + const effective = options.effectiveResolver + ? await options.effectiveResolver.resolve({ + projectId: context.project.id, + projectPath: context.project.path, + assignedSkillIds: context.skillIds, + role: 'parent', + }) + : null; + // Commands are projected from the assignment, while ProductTools marks + // only resolver-approved Skills effective. This preserves disabled + // assignment state without loading it into a worker. + const skillIds = context.skillIds; + const enabledPluginIds = effective + ? effective.effectiveSkillIds + : options.getEnabledPluginIds + ? await options.getEnabledPluginIds(context.project.path) + : []; const piCommands = options.listPiCommands ? normalizePiCommands(await options.listPiCommands(conversationId)) : []; - return await options.productTools.listCommands(context.skillIds, piCommands, enabledPluginIds); + return await options.productTools.listCommands(skillIds, piCommands, enabledPluginIds); }, async getChanges(conversationId) { await conversationContext(conversationId); diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index 7a99e61..27cee5a 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -22,6 +22,7 @@ import { handleCodingAttachmentRoutes } from './routes/coding-attachments'; import { handleCodingProjectRoutes } from './routes/coding-projects'; import { handleCodingConversationRoutes } from './routes/coding-conversations'; import { handleCodingPluginRoutes } from './routes/coding-plugins'; +import { handlePluginMarketplaceRoutes } from './routes/plugin-marketplace'; export type HostApiRouteHandler = ( req: IncomingMessage, @@ -50,6 +51,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleUserSyncRoutes, handleCodingAttachmentRoutes, handleCodingProjectRoutes, + handlePluginMarketplaceRoutes, handleCodingPluginRoutes, handleCodingConversationRoutes, handleCodingFileRoutes, diff --git a/electron/api/routes/plugin-marketplace.ts b/electron/api/routes/plugin-marketplace.ts new file mode 100644 index 0000000..f9b6a25 --- /dev/null +++ b/electron/api/routes/plugin-marketplace.ts @@ -0,0 +1,238 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + MarketplaceClientError, + type CatalogQuery, +} from '../../coding-plugins/marketplace-client'; +import { PluginPackageStoreError } from '../../coding-plugins/package-store'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; +import type { CodingPluginMarketplaceService } from '../coding-product-services'; + +const ROOT = '/api/coding/plugin-marketplace'; +const DETAIL = /^\/api\/coding\/plugin-marketplace\/plugins\/([^/]+)$/u; +const LIBRARY_MUTATION = /^\/api\/coding\/plugin-marketplace\/library\/([^/]+)$/u; +const INSTALLATION = /^\/api\/coding\/plugin-marketplace\/install\/([^/]+)$/u; +const UPDATE = /^\/api\/coding\/plugin-marketplace\/update\/([^/]+)$/u; +const PLUGIN_ID = /^[a-z][a-z0-9.-]{0,127}$/u; + +class PluginMarketplaceRouteError extends Error { + constructor( + readonly status: 400 | 401 | 404 | 409 | 422 | 503, + readonly code: string, + message: string, + ) { + super(message); + this.name = 'PluginMarketplaceRouteError'; + } +} + +function invalid(message: string): never { + throw new PluginMarketplaceRouteError(400, 'plugin_request_invalid', message); +} + +function routePluginId(value: string): string { + let decoded: string; + try { + decoded = decodeURIComponent(value).trim(); + } catch { + return invalid('plugin id is invalid'); + } + if (!PLUGIN_ID.test(decoded)) return invalid('plugin id is invalid'); + return decoded; +} + +function exactNoQuery(url: URL): void { + if (url.search) invalid('This Marketplace route does not accept query parameters'); +} + +function catalogQuery(url: URL): CatalogQuery { + const allowed = new Set(['query', 'category', 'featured', 'limit', 'cursor']); + for (const key of url.searchParams.keys()) { + if (!allowed.has(key) || url.searchParams.getAll(key).length !== 1) { + invalid('Catalog query is invalid'); + } + } + const rawFeatured = url.searchParams.get('featured'); + if (rawFeatured !== null && rawFeatured !== 'true' && rawFeatured !== 'false') { + invalid('featured is invalid'); + } + const rawLimit = url.searchParams.get('limit'); + const limit = rawLimit === null ? undefined : Number(rawLimit); + if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)) { + invalid('limit is invalid'); + } + const text = (key: string, maximum: number): string | undefined => { + const value = url.searchParams.get(key); + if (value === null) return undefined; + const normalized = value.trim(); + if (!normalized || normalized.length > maximum) invalid(`${key} is invalid`); + return normalized; + }; + return { + ...(text('query', 120) === undefined ? {} : { query: text('query', 120) }), + ...(text('category', 128) === undefined ? {} : { category: text('category', 128) }), + ...(rawFeatured === null ? {} : { featured: rawFeatured === 'true' }), + ...(limit === undefined ? {} : { limit }), + ...(text('cursor', 1024) === undefined ? {} : { cursor: text('cursor', 1024) }), + }; +} + +async function exactEmptyBody(req: IncomingMessage): Promise { + const body = await parseJsonBody(req); + if (!body || typeof body !== 'object' || Array.isArray(body) + || Object.keys(body as Record).length !== 0) { + invalid('This Marketplace action does not accept a request body'); + } +} + +function sendError(res: ServerResponse, error: unknown): void { + if (error instanceof PluginMarketplaceRouteError) { + sendJson(res, error.status, { success: false, code: error.code, error: error.message }); + return; + } + if (error instanceof MarketplaceClientError) { + const status = error.status === 401 || error.code === 'marketplace_auth_required' ? 401 + : error.status === 404 ? 404 + : error.code === 'marketplace_account_changed' ? 409 + : error.code === 'marketplace_request_invalid' ? 400 + : error.status === 403 ? 403 + : error.status === 409 ? 409 + : error.status === 422 ? 422 + : 503; + const code = status === 401 ? 'plugin_auth_required' + : status === 403 ? 'plugin_library_required' + : status === 404 ? 'plugin_not_found' + : status === 409 ? (error.code === 'marketplace_account_changed' + ? 'plugin_account_changed' : 'plugin_release_not_ready') + : status === 422 ? 'plugin_validation_failed' + : status === 400 ? 'plugin_request_invalid' + : 'plugin_backend_unavailable'; + sendJson(res, status, { + success: false, + code, + error: status === 401 ? 'Marketplace authentication is required' + : status === 403 ? 'Marketplace Library access is required' + : status === 404 ? 'Plugin was not found' + : status === 409 ? (error.code === 'marketplace_account_changed' + ? 'Marketplace account changed' : 'Plugin Release is not ready') + : status === 422 ? 'Plugin validation failed' + : status === 400 ? 'Marketplace request is invalid' + : 'Marketplace service is temporarily unavailable', + }); + return; + } + if (error instanceof PluginPackageStoreError) { + const status = error.code === 'plugin_release_unavailable' || error.code === 'plugin_beta_selection_required' + || error.code === 'plugin_release_conflict' || error.code === 'plugin_incompatible_client' + ? 409 + : error.code === 'plugin_artifact_invalid' || error.code === 'plugin_signature_invalid' + || error.code === 'plugin_manifest_invalid' ? 422 + : error.code === 'plugin_account_changed' ? 409 : 503; + const code = error.code === 'plugin_account_changed' ? 'plugin_account_changed' + : status === 409 ? 'plugin_release_not_ready' + : status === 422 ? 'plugin_artifact_invalid' + : 'plugin_backend_unavailable'; + sendJson(res, status, { + success: false, + code, + error: status === 409 ? 'Plugin Release is not ready' + : status === 422 ? 'Plugin artifact is invalid' + : 'Plugin installation is temporarily unavailable', + }); + return; + } + if (error instanceof SyntaxError) { + sendJson(res, 400, { success: false, code: 'plugin_request_invalid', error: 'Request body is invalid' }); + return; + } + sendJson(res, 503, { + success: false, + code: 'plugin_backend_unavailable', + error: 'Marketplace service is temporarily unavailable', + }); +} + +function service(ctx: HostApiContext): CodingPluginMarketplaceService | null { + const products = ctx.codingProducts as (HostApiContext['codingProducts'] & { + marketplace?: CodingPluginMarketplaceService; + }) | undefined; + return products?.pluginMarketplace ?? products?.marketplace ?? null; +} + +function isKnownPath(pathname: string): boolean { + return pathname === `${ROOT}/catalog` + || pathname === `${ROOT}/library` + || DETAIL.test(pathname) + || LIBRARY_MUTATION.test(pathname) + || INSTALLATION.test(pathname) + || UPDATE.test(pathname); +} + +export async function handlePluginMarketplaceRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + if (!isKnownPath(url.pathname)) return false; + const marketplace = service(ctx); + if (!marketplace) { + sendJson(res, 503, { + success: false, + code: 'plugin_backend_unavailable', + error: 'Marketplace service is temporarily unavailable', + }); + return true; + } + try { + if (url.pathname === `${ROOT}/catalog` && req.method === 'GET') { + sendJson(res, 200, await marketplace.readCatalog(catalogQuery(url))); + return true; + } + const detailMatch = DETAIL.exec(url.pathname); + if (detailMatch && req.method === 'GET') { + exactNoQuery(url); + sendJson(res, 200, await marketplace.readDetail(routePluginId(detailMatch[1]))); + return true; + } + if (url.pathname === `${ROOT}/library` && req.method === 'GET') { + exactNoQuery(url); + sendJson(res, 200, await marketplace.readLibrary()); + return true; + } + const libraryMatch = LIBRARY_MUTATION.exec(url.pathname); + if (libraryMatch && (req.method === 'PUT' || req.method === 'DELETE')) { + exactNoQuery(url); + await exactEmptyBody(req); + const pluginId = routePluginId(libraryMatch[1]); + sendJson(res, 200, req.method === 'PUT' + ? await marketplace.acquire(pluginId) + : await marketplace.remove(pluginId)); + return true; + } + const installationMatch = INSTALLATION.exec(url.pathname); + if (installationMatch && (req.method === 'POST' || req.method === 'DELETE')) { + exactNoQuery(url); + await exactEmptyBody(req); + const pluginId = routePluginId(installationMatch[1]); + const result = req.method === 'DELETE' + ? await marketplace.uninstall(pluginId) + : await marketplace.install(pluginId); + sendJson(res, 200, result); + return true; + } + const updateMatch = UPDATE.exec(url.pathname); + if (updateMatch && req.method === 'POST') { + exactNoQuery(url); + await exactEmptyBody(req); + sendJson(res, 200, await marketplace.update(routePluginId(updateMatch[1]))); + return true; + } + return false; + } catch (error) { + sendError(res, error); + return true; + } +} + +export const handleCodingPluginMarketplaceRoutes = handlePluginMarketplaceRoutes; diff --git a/electron/coding-plugins/effective-resolver.ts b/electron/coding-plugins/effective-resolver.ts new file mode 100644 index 0000000..afb3005 --- /dev/null +++ b/electron/coding-plugins/effective-resolver.ts @@ -0,0 +1,478 @@ +import path from 'node:path'; +import type { + CodingPluginDefinition, + CodingPluginToolDefinition, +} from '../../shared/coding-plugins'; +import { + CORE_CODING_SKILL_IDS, + type CodingSkillId, +} from '../../shared/coding-skills'; +import { + accountBindingKey, + type AccountBinding, + type MarketplaceLibrarySnapshot, +} from './account-plugin-cache'; +import type { InstalledRelease } from './package-store'; +import type { + PluginBillingPolicy, + PluginCatalog, + PluginCatalogOperation, + PluginPolicyClientState, +} from '../services/plugin-policy-client'; + +/** The serialisable Skill resource consumed by every next Pi worker. */ +export interface SkillEntry { + readonly id: CodingSkillId; + readonly entryPath: string; +} + +/** A policy row copied from the last verified server catalog. */ +export interface RuntimePolicy { + readonly pluginId: string; + readonly contractVersion: number; + readonly capabilityId: string; + readonly operation: string; + readonly billing: PluginBillingPolicy; +} + +export type PluginUnavailableReasonCode = + | 'account_required' + | 'library_required' + | 'library_unavailable' + | 'release_not_installed' + | 'release_invalid' + | 'project_disabled' + | 'skill_unassigned' + | 'runtime_suspended' + | 'policy_unavailable' + | 'policy_unsupported' + | 'billing_unavailable'; + +export interface PluginUnavailableReason { + readonly pluginId: string; + readonly code: PluginUnavailableReasonCode; + readonly message: string; +} + +/** + * Main-owned, frozen worker contract. No renderer or worker state is an + * authority: all consumers receive this exact projection for one worker. + */ +export interface EffectivePluginSnapshot { + readonly accountSessionId: string; + readonly projectId: string; + readonly pluginReleaseIds: readonly string[]; + readonly effectiveSkillIds: readonly CodingSkillId[]; + readonly skillEntries: readonly SkillEntry[]; + readonly toolDefinitions: readonly CodingPluginToolDefinition[]; + readonly runtimePolicies: readonly RuntimePolicy[]; + readonly unavailableReasons: readonly PluginUnavailableReason[]; +} + +export interface EffectivePluginResolverInput { + readonly projectId: string; + readonly projectPath: string; + readonly assignedSkillIds: readonly string[]; + readonly role: 'parent' | 'child'; + /** Test and composition seam for an already fetched Account Library. */ + readonly library?: MarketplaceLibrarySnapshot | null; +} + +export interface EffectivePluginResolverOptions { + readonly definitions?: readonly CodingPluginDefinition[]; + readonly getDefinitions?: () => readonly CodingPluginDefinition[] | Promise; + readonly installedDefinitions?: readonly CodingPluginDefinition[]; + readonly getInstalled?: (pluginId: string) => Promise | InstalledRelease | null; + readonly packageStore?: { + readInstalledIndex(): Promise; + getInstalled(pluginId: string): Promise; + }; + readonly getLibrary?: (binding: AccountBinding) => Promise | MarketplaceLibrarySnapshot | null; + readonly marketplace?: { + readLibrary(): Promise; + }; + readonly accountCache?: { + getLibrary(binding: AccountBinding): MarketplaceLibrarySnapshot | null; + }; + readonly getAccountBinding?: () => AccountBinding | null; + readonly getAccountSessionId?: () => string; + readonly getEnabledPluginIds?: (projectPath: string) => Promise; + readonly projectPlugins?: { + getEnabledPluginIds(projectPath: string): Promise; + }; + readonly policyClient?: { + getState(): PluginPolicyClientState; + refresh(): Promise; + }; +} + +export interface EffectivePluginSkillSource { + readonly id: string; + readonly pluginId: string; + readonly packageRoot: string; + readonly directory: string; + readonly entryPath: string; +} + +interface DefinitionRecord { + readonly definition: CodingPluginDefinition; + readonly installed: boolean; +} + +const EMPTY_POLICY_STATE: PluginPolicyClientState = { + status: 'unavailable', + catalog: null, + revision: 0, + lastVerifiedAt: null, +}; + +function clone(value: T): T { + return structuredClone(value); +} + +function freezeArray(value: readonly T[]): readonly T[] { + return Object.freeze([...value]); +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value as Record)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function freezeSnapshot(value: EffectivePluginSnapshot): EffectivePluginSnapshot { + const effectiveSkillIds = [...new Set(value.effectiveSkillIds)]; + const skillEntries = [...new Map(value.skillEntries.map((entry) => [entry.id, entry])).values()]; + return Object.freeze({ + accountSessionId: value.accountSessionId, + projectId: value.projectId, + pluginReleaseIds: freezeArray(value.pluginReleaseIds), + effectiveSkillIds: freezeArray(effectiveSkillIds), + skillEntries: freezeArray(skillEntries.map((entry) => Object.freeze({ ...entry }))), + toolDefinitions: freezeArray(value.toolDefinitions.map((tool) => deepFreeze(clone(tool)))), + runtimePolicies: freezeArray(value.runtimePolicies.map((policy) => deepFreeze({ + ...policy, + billing: clone(policy.billing), + }))), + unavailableReasons: freezeArray(value.unavailableReasons.map((reason) => Object.freeze({ ...reason }))), + }); +} + +function accountSessionId( + options: EffectivePluginResolverOptions, + binding: AccountBinding | null, +): string { + const provided = options.getAccountSessionId?.(); + if (provided && provided.trim()) return provided; + return binding ? accountBindingKey(binding) : 'anonymous'; +} + +function normalizeIds(value: readonly string[]): string[] { + return [...new Set(value.map((id) => id.trim()).filter(Boolean))]; +} + +function policyOperation( + catalog: PluginCatalog | null, + definition: CodingPluginDefinition, + capabilityId: string, + operation: string, +): PluginCatalogOperation | null { + const plugin = catalog?.plugins.find(({ plugin_id }) => plugin_id === definition.id); + if (!plugin || plugin.status !== 'active' + || !plugin.supported_contract_versions.includes(definition.contractVersion)) return null; + return plugin.capabilities + .find(({ capability_id }) => capability_id === capabilityId) + ?.operations.find((candidate) => candidate.operation === operation) ?? null; +} + +function policyIsBillingAvailable(policy: PluginCatalogOperation): boolean { + return policy.billing.mode !== 'platform_metered' + || !('status' in policy.billing) || policy.billing.status !== 'billing_unavailable'; +} + +function unavailable( + pluginId: string, + code: PluginUnavailableReasonCode, + message: string, +): PluginUnavailableReason { + return { pluginId, code, message }; +} + +function validateBinding(value: AccountBinding | null): AccountBinding | null { + if (!value || typeof value.accountKey !== 'string' || value.accountKey.length === 0 + || value.accountKey.length > 512 || !Number.isSafeInteger(value.epoch) || value.epoch < 0) { + return null; + } + return { accountKey: value.accountKey, epoch: value.epoch }; +} + +/** + * Resolve a worker snapshot from separated Marketplace, Package Store, + * project, assignment, and policy state. This module deliberately performs + * no package execution and never trusts paths supplied by a package. + */ +export class EffectivePluginResolver { + private readonly cachedLibraries = new Map(); + + constructor(private readonly options: EffectivePluginResolverOptions) {} + + async resolve(input: EffectivePluginResolverInput): Promise { + const definitions = await this.definitionRecords(); + const assigned = normalizeIds(input.assignedSkillIds); + + const coreIds = new Set(CORE_CODING_SKILL_IDS); + const effectiveSkillIds: CodingSkillId[] = assigned.filter((id) => coreIds.has(id)); + const skillEntries: SkillEntry[] = effectiveSkillIds.map((id) => ({ + id, + entryPath: `${id}/SKILL.md`, + })); + const pluginReleaseIds: string[] = []; + const toolDefinitions: CodingPluginToolDefinition[] = []; + const runtimePolicies: RuntimePolicy[] = []; + const unavailableReasons: PluginUnavailableReason[] = []; + + // Child workers intentionally receive only core resources. Still validate + // assignment IDs above so malformed project configuration remains visible. + if (input.role === 'child') { + return freezeSnapshot({ + accountSessionId: accountSessionId(this.options, validateBinding(this.options.getAccountBinding?.() ?? null)), + projectId: input.projectId, + pluginReleaseIds, + effectiveSkillIds, + skillEntries, + toolDefinitions, + runtimePolicies, + unavailableReasons, + }); + } + + const binding = validateBinding(this.options.getAccountBinding?.() ?? null); + const userDefinitions = definitions.filter(({ definition }) => definition.acquisitionMode === 'user_acquired'); + const enabled = new Set(await this.enabledPluginIds(input.projectPath)); + const requiresLibrary = userDefinitions.some(({ definition, installed }) => ( + installed && enabled.has(definition.id) + && definition.skills.some(({ id }) => assigned.includes(id)) + )); + const library = input.library !== undefined + ? input.library + : await this.libraryFor(binding, requiresLibrary); + const libraryById = new Map((library?.items ?? []).map((entry) => [entry.pluginId, entry])); + let policyState: PluginPolicyClientState = this.options.policyClient?.getState() ?? EMPTY_POLICY_STATE; + + const serverDefinitions = definitions.filter(({ definition }) => definition.requiresBackend); + const needsPolicyRefresh = policyState.status !== 'current' + && serverDefinitions.some(({ definition, installed }) => ( + installed && enabled.has(definition.id) && definition.skills.some(({ id }) => assigned.includes(id)) + )); + if (needsPolicyRefresh && this.options.policyClient) { + await this.options.policyClient.refresh(); + policyState = this.options.policyClient.getState(); + } + + for (const { definition, installed } of definitions) { + const selectedSkills = definition.skills.filter(({ id }) => assigned.includes(id)); + if (selectedSkills.length === 0) { + unavailableReasons.push(unavailable(definition.id, 'skill_unassigned', 'Plugin Skill is not assigned')); + continue; + } + + if (!installed) { + unavailableReasons.push(unavailable(definition.id, 'release_not_installed', 'Plugin Release is not installed')); + continue; + } + if (!enabled.has(definition.id)) { + unavailableReasons.push(unavailable(definition.id, 'project_disabled', 'Plugin is not enabled for this project')); + continue; + } + if (definition.acquisitionMode === 'user_acquired') { + if (!binding) { + unavailableReasons.push(unavailable(definition.id, 'account_required', 'Marketplace account is required')); + continue; + } + if (!library || library.stale) { + unavailableReasons.push(unavailable(definition.id, 'library_unavailable', 'Marketplace Library is unavailable')); + continue; + } + const entry = libraryById.get(definition.id); + if (!entry || entry.acquisitionMode !== 'user_acquired' || entry.removedAt !== null) { + unavailableReasons.push(unavailable(definition.id, 'library_required', 'Plugin is not acquired in this Account Library')); + continue; + } + // Retired remains usable for an existing Library user. Suspension is + // a runtime gate and is intentionally independent from catalogStatus. + if (entry.runtimeStatus === 'suspended') { + unavailableReasons.push(unavailable(definition.id, 'runtime_suspended', 'Plugin runtime is suspended')); + continue; + } + if (!definition.releaseId) { + unavailableReasons.push(unavailable(definition.id, 'release_invalid', 'Installed Plugin Release is invalid')); + continue; + } + } + if (definition.requiresBackend) { + const pluginPolicy = policyState.catalog?.plugins.find(({ plugin_id }) => plugin_id === definition.id); + if (policyState.status !== 'current' || !policyState.catalog) { + unavailableReasons.push(unavailable(definition.id, 'policy_unavailable', 'Plugin runtime policy is unavailable')); + continue; + } + if (!pluginPolicy || !pluginPolicy.supported_contract_versions.includes(definition.contractVersion)) { + unavailableReasons.push(unavailable(definition.id, 'policy_unsupported', 'Plugin contract is not supported by runtime policy')); + continue; + } + } + + if (definition.releaseId) pluginReleaseIds.push(definition.releaseId); + for (const skill of selectedSkills) { + effectiveSkillIds.push(skill.id); + skillEntries.push({ id: skill.id, entryPath: skill.entryPath }); + } + if (!definition.requiresBackend) continue; + for (const operation of definition.operations) { + const policy = policyOperation( + policyState.catalog, + definition, + operation.capabilityId, + operation.operation, + ); + if (!policy) continue; + runtimePolicies.push({ + pluginId: definition.id, + contractVersion: definition.contractVersion, + capabilityId: operation.capabilityId, + operation: operation.operation, + billing: clone(policy.billing), + }); + const tool = operation.toolName + ? definition.tools.find(({ name }) => name === operation.toolName) + : undefined; + if (!tool || !selectedSkills.some(({ grants }) => grants.includes(tool.capabilityId))) continue; + if (!policyIsBillingAvailable(policy)) { + unavailableReasons.push(unavailable(definition.id, 'billing_unavailable', 'Plugin billing is unavailable')); + continue; + } + toolDefinitions.push(clone(tool)); + } + } + + return freezeSnapshot({ + accountSessionId: accountSessionId(this.options, binding), + projectId: input.projectId, + pluginReleaseIds: [...new Set(pluginReleaseIds)], + effectiveSkillIds: [...new Set(effectiveSkillIds)], + skillEntries, + toolDefinitions: [...new Map(toolDefinitions.map((tool) => [tool.name, tool])).values()], + runtimePolicies: [...new Map(runtimePolicies.map((policy) => [ + `${policy.pluginId}\u0000${policy.capabilityId}\u0000${policy.operation}`, + policy, + ])).values()], + unavailableReasons, + }); + } + + /** Alias kept explicit for callers that name the contract rather than the class. */ + resolveEffectivePluginSnapshot(input: EffectivePluginResolverInput): Promise { + return this.resolve(input); + } + + async getSkillSources(): Promise { + const definitions = await this.definitionRecords(); + // Bundled plugin Skills already have a trusted resource source supplied by + // composition. Returning their manifest-relative `packageRoot` here would + // replace that source with a cwd-relative path in the product projection; + // this seam is exclusively for immutable user-installed package roots. + return Object.freeze(definitions.flatMap(({ definition, installed }) => ( + !installed || definition.acquisitionMode !== 'user_acquired' + ? [] + : definition.skills.map((skill) => ({ + id: skill.id, + pluginId: definition.id, + packageRoot: definition.provenance.packageRoot, + directory: path.join(definition.provenance.packageRoot, path.dirname(skill.entryPath)), + entryPath: path.basename(skill.entryPath), + })) + ))); + } + + getPolicyState(): PluginPolicyClientState { + return this.options.policyClient?.getState() ?? EMPTY_POLICY_STATE; + } + + private async definitionRecords(): Promise { + const base = [ + ...(this.options.definitions ?? []), + ...(this.options.getDefinitions ? await this.options.getDefinitions() : []), + ]; + const records = new Map(); + for (const definition of base) { + if (!records.has(definition.id)) records.set(definition.id, { definition, installed: true }); + } + for (const definition of this.options.installedDefinitions ?? []) { + records.set(definition.id, { definition, installed: true }); + } + const installedIds = new Set(); + if (this.options.packageStore) { + const index = await this.options.packageStore.readInstalledIndex(); + for (const record of index) installedIds.add(record.pluginId); + } + for (const definition of [...records.values()].map(({ definition }) => definition)) { + if (definition.acquisitionMode !== 'user_acquired') continue; + let installed = (this.options.installedDefinitions ?? []).some(({ id }) => id === definition.id); + if (installedIds.has(definition.id)) installed = Boolean(await this.options.packageStore?.getInstalled(definition.id)); + if (this.options.getInstalled) installed = Boolean(await this.options.getInstalled(definition.id)); + if (!this.options.packageStore && !this.options.getInstalled + && !(this.options.installedDefinitions ?? []).some(({ id }) => id === definition.id)) { + installed = false; + } + records.set(definition.id, { definition, installed }); + } + if (this.options.packageStore) { + for (const pluginId of installedIds) { + if (records.has(pluginId)) continue; + const installed = await this.options.packageStore.getInstalled(pluginId); + if (installed) records.set(pluginId, { definition: installed.definition, installed: true }); + } + } + return [...records.values()]; + } + + private async libraryFor( + binding: AccountBinding | null, + required: boolean, + ): Promise { + if (!required || !binding) return null; + const key = accountBindingKey(binding); + const cached = this.options.accountCache?.getLibrary(binding) ?? this.cachedLibraries.get(key); + if (cached && !cached.stale) this.cachedLibraries.set(key, clone(cached)); + try { + const library = this.options.getLibrary + ? await this.options.getLibrary(binding) + : this.options.marketplace + ? await this.options.marketplace.readLibrary() + : cached ?? null; + if (library) this.cachedLibraries.set(key, clone(library)); + return library ?? cached ?? null; + } catch { + return cached ?? null; + } + } + + private async enabledPluginIds(projectPath: string): Promise { + if (this.options.getEnabledPluginIds) return await this.options.getEnabledPluginIds(projectPath); + if (this.options.projectPlugins) return await this.options.projectPlugins.getEnabledPluginIds(projectPath); + return []; + } +} + +export function createEffectivePluginResolver( + options: EffectivePluginResolverOptions, +): EffectivePluginResolver { + return new EffectivePluginResolver(options); +} + +export const createEffectiveResolver = createEffectivePluginResolver; +export const resolveEffectivePluginSnapshot = async ( + options: EffectivePluginResolverOptions, + input: EffectivePluginResolverInput, +): Promise => await createEffectivePluginResolver(options).resolve(input); diff --git a/electron/coding-plugins/package-store.ts b/electron/coding-plugins/package-store.ts index 6a5ab79..5d10626 100644 --- a/electron/coding-plugins/package-store.ts +++ b/electron/coding-plugins/package-store.ts @@ -572,7 +572,6 @@ export class PluginPackageStore { ...this.accountCache.referencedReleaseIds(), ...(this.activeWorkerReleaseIds() ?? []), ...this.activeWorkers, - latest.releaseId, ]); const removable = records.filter((record) => !protectedIds.has(record.releaseId)); if (removable.length === 0) return { status: 'kept', pluginId: validated, releaseId: latest.releaseId, version: latest.version }; diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts index 82f5c08..3b9b58b 100644 --- a/electron/coding-plugins/registry.ts +++ b/electron/coding-plugins/registry.ts @@ -14,6 +14,10 @@ import type { PluginCatalogOperation, PluginPolicyClientState, } from '../services/plugin-policy-client'; +import type { + EffectivePluginResolver, + EffectivePluginSnapshot, +} from './effective-resolver'; const MAX_REQUEST_ID = 128; const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,47}$/u; @@ -93,6 +97,10 @@ export interface ResolvedWorkerResources { effectiveSkillIds: readonly string[]; skillEntries: readonly { id: string; entryPath: string }[]; tools: readonly CodingPluginToolDefinition[]; + /** The exact Main-owned snapshot used to produce these legacy fields. */ + effectiveSnapshot?: EffectivePluginSnapshot; + /** Trusted package roots paired with the same effective snapshot. */ + skillRoots?: readonly string[]; } export interface CodingCapabilityRegistryPort { @@ -100,7 +108,14 @@ export interface CodingCapabilityRegistryPort { projectPath: string; assignedSkillIds: readonly string[]; role: 'parent' | 'child'; + projectId?: string; }): Promise; + resolveEffectivePluginSnapshot?(input: { + projectId: string; + projectPath: string; + assignedSkillIds: readonly string[]; + role: 'parent' | 'child'; + }): Promise; invoke(input: { toolName: string; context: PiProductToolContext; @@ -124,6 +139,7 @@ export interface CodingCapabilityRegistryOptions { adapters: readonly CodingPluginAdapter[]; definitions: readonly CodingPluginDefinition[]; getDurableProjectId?: (projectPath: string, localProjectId: string) => Promise | string; + effectiveResolver?: EffectivePluginResolver; } function isRecord(value: unknown): value is Record { @@ -257,10 +273,14 @@ function definitionValid(definition: CodingPluginDefinition): boolean { if (!PLUGIN_ID_PATTERN.test(definition.id) || !VERSION_PATTERN.test(definition.version) || !Number.isSafeInteger(definition.contractVersion) || definition.contractVersion < 1 || definition.scope !== 'project' || typeof definition.requiresBackend !== 'boolean') return false; - if (!definition.skills.length || !definition.tools.length) return false; + if (!definition.skills.length + || (definition.runtimeKind !== 'skill_only' && !definition.tools.length) + || (definition.runtimeKind === 'skill_only' && (definition.requiresBackend || definition.tools.length > 0))) return false; const skillIds = new Set(); for (const skill of definition.skills) { - if (!skill.id || skillIds.has(skill.id) || !skill.entryPath || skill.grants.length === 0) return false; + if (!skill.id || skillIds.has(skill.id) || !skill.entryPath + || (definition.runtimeKind !== 'skill_only' && skill.grants.length === 0) + || (definition.runtimeKind === 'skill_only' && skill.grants.length > 0)) return false; skillIds.add(skill.id); } const toolNames = new Set(); @@ -358,7 +378,35 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor projectPath: string; assignedSkillIds: readonly string[]; role: 'parent' | 'child'; + projectId?: string; }): Promise { + if (this.options.effectiveResolver) { + const snapshot = await this.options.effectiveResolver.resolve({ + projectId: input.projectId ?? input.projectPath, + projectPath: input.projectPath, + assignedSkillIds: input.assignedSkillIds, + role: input.role, + }); + const sources = await this.options.effectiveResolver.getSkillSources(); + const effectiveSkills = new Set(snapshot.effectiveSkillIds); + const pluginIds = new Set(snapshot.runtimePolicies.map(({ pluginId }) => pluginId)); + const skillRoots = new Set(); + for (const source of sources) { + if (effectiveSkills.has(source.id)) { + pluginIds.add(source.pluginId); + skillRoots.add(source.packageRoot); + } + } + return { + catalogRevision: this.options.effectiveResolver.getPolicyState().revision, + pluginIds: [...pluginIds], + effectiveSkillIds: [...snapshot.effectiveSkillIds], + skillEntries: snapshot.skillEntries.map(({ id, entryPath }) => ({ id, entryPath })), + tools: snapshot.toolDefinitions.map((tool) => structuredClone(tool)), + effectiveSnapshot: snapshot, + skillRoots: [...skillRoots], + }; + } const assigned = [...new Set(input.assignedSkillIds)]; const coreIds = new Set(CORE_CODING_SKILL_IDS); const pluginSkillOwners = new Map(); @@ -432,6 +480,26 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor }; } + async resolveEffectivePluginSnapshot(input: { + projectId: string; + projectPath: string; + assignedSkillIds: readonly string[]; + role: 'parent' | 'child'; + }): Promise { + if (this.options.effectiveResolver) return await this.options.effectiveResolver.resolve(input); + const resources = await this.resolveWorkerResources(input); + return Object.freeze({ + accountSessionId: 'anonymous', + projectId: input.projectId, + pluginReleaseIds: Object.freeze([]), + effectiveSkillIds: Object.freeze([...resources.effectiveSkillIds]), + skillEntries: Object.freeze(resources.skillEntries.map((entry) => Object.freeze({ ...entry }))), + toolDefinitions: Object.freeze(resources.tools.map((tool) => structuredClone(tool))), + runtimePolicies: Object.freeze([]), + unavailableReasons: Object.freeze([]), + }); + } + async invoke(input: { toolName: string; context: PiProductToolContext; @@ -440,10 +508,36 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor value: unknown; }): Promise { const indexed = this.toolsByName.get(input.toolName); - const state = this.options.policyClient.getState(); const validId = requestId(input.context) !== 'invalid-request-id'; if (!indexed) return this.unknownResult(input.context, 'plugin_backend_unavailable', 'Plugin capability is unavailable'); const { definition, tool } = indexed; + if (this.options.effectiveResolver && input.context.effectiveSnapshot) { + const currentSnapshot = await this.options.effectiveResolver.resolve({ + projectId: input.context.projectId, + projectPath: input.context.projectPath, + assignedSkillIds: input.context.effectiveSnapshot.effectiveSkillIds, + role: input.workerRole, + }); + if (currentSnapshot.accountSessionId !== input.context.effectiveSnapshot.accountSessionId + || !currentSnapshot.toolDefinitions.some(({ name }) => name === input.toolName)) { + const disabled = currentSnapshot.unavailableReasons.some((reason) => ( + reason.pluginId === definition.id && reason.code === 'project_disabled' + )); + return this.resultFailure( + definition, + tool, + input.context, + disabled ? 'plugin_not_enabled' : 'plugin_runtime_stale', + disabled ? 'Plugin is not enabled for this project' : 'Plugin worker resources are stale', + disabled ? 403 : 409, + false, + policyNotStarted('unknown'), + ); + } + } + // The effective resolver may refresh a stale policy while checking the + // worker snapshot. Read the post-resolution state for invocation checks. + const state = this.options.policyClient.getState(); const currentEnabled = await this.enabledPluginIds(input.context.projectPath); const catalogPolicy = state.catalog ? policyOperation(state.catalog, definition, tool) : null; const baseBilling = catalogPolicy ? policyNotStarted(catalogPolicy.billing.mode) : policyNotStarted('unknown'); diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index eeb402f..810b403 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -16,6 +16,7 @@ import { import type { PiProductTools } from './product-tools'; import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins'; import type { PiSkillEntry } from './resource-loader'; +import type { EffectivePluginSnapshot } from '../../coding-plugins/effective-resolver'; const MAX_REQUEST_BYTES = 64 * 1024; const PRODUCT_TOOL_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9._:-]{0,63}$/u; @@ -40,6 +41,7 @@ interface WorkerRegistrationRecord { tools: CodingPluginToolDefinition[]; projectWriteLeaseToolNames: string[]; role: 'parent' | 'child'; + effectiveSnapshot?: EffectivePluginSnapshot; contextFile: string; runId: string | null; leases: Map; @@ -62,6 +64,8 @@ export interface RegisterPiExtensionWorkerInput { skillEntries?: readonly PiSkillEntry[]; catalogRevision?: number; tools?: readonly CodingPluginToolDefinition[]; + /** Exact Main-owned resolver output used for this worker generation. */ + effectiveSnapshot?: EffectivePluginSnapshot; extensionsDir: string; role?: 'parent' | 'child'; runId?: string; @@ -248,6 +252,7 @@ export class PiManagedExtensionHost { tools, projectWriteLeaseToolNames, role, + ...(input.effectiveSnapshot ? { effectiveSnapshot: input.effectiveSnapshot } : {}), contextFile, runId: role === 'child' ? input.runId as string @@ -424,6 +429,7 @@ export class PiManagedExtensionHost { projectId: record.projectId, projectPath: record.projectPath, skillIds: record.skillIds, + ...(record.effectiveSnapshot ? { effectiveSnapshot: record.effectiveSnapshot } : {}), }, value.input); this.respond(response, 200, { result: productResult }); return; @@ -589,6 +595,7 @@ export class PiManagedExtensionHost { role: record.role, skillIds: record.skillIds, ...(record.catalogRevision === undefined ? {} : { catalogRevision: record.catalogRevision }), + ...(record.effectiveSnapshot ? { effectivePluginSnapshot: record.effectiveSnapshot } : {}), allowedToolNames: record.allowedToolNames, tools: record.tools, projectWriteLeaseToolNames: record.projectWriteLeaseToolNames, diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index dd14779..ecbff90 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -19,7 +19,9 @@ import { import { type CodingCapabilityRegistry, } from '../../coding-plugins/registry'; +import type { EffectivePluginSnapshot } from '../../coding-plugins/effective-resolver'; import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts'; +import { BUNDLED_CODING_SKILL_IDS } from '../../../shared/coding-skills'; import { PiAgentBrowserTool } from './extensions/agent-browser'; import { reportChangedFiles } from './extensions/changed-file'; import { PiGameAssetTools } from './extensions/game-assets'; @@ -53,6 +55,7 @@ export interface PiProductToolContext { projectId: string; projectPath: string; skillIds: readonly string[]; + effectiveSnapshot?: EffectivePluginSnapshot; } export interface PiProductToolResult { @@ -66,6 +69,8 @@ export interface PiProductToolsOptions { bundledSkillsDir: string; changeTracker?: ConversationChangeTracker; pluginSkillSources?: readonly ProductCodingPluginSkillSource[]; + getPluginSkillSources?(): readonly ProductCodingPluginSkillSource[] + | Promise; capabilityRegistry?: CodingCapabilityRegistry; } @@ -97,16 +102,27 @@ export class PiProductTools { return this.changeTracker.getSnapshot(conversationId); } - listSkills( + async listSkills( skillIds: readonly string[], availablePluginSkillIds: readonly string[] = [], ): Promise { const available = new Set(availablePluginSkillIds); - const sources = (this.options.pluginSkillSources ?? []).map((source) => ({ + const dynamicSources = this.options.getPluginSkillSources + ? await this.options.getPluginSkillSources() + : []; + const uniqueSources = [...new Map( + [...(this.options.pluginSkillSources ?? []), ...dynamicSources] + .map((source) => [source.id, source] as const), + ).values()]; + const sources = uniqueSources.map((source) => ({ ...source, available: available.has(source.pluginId ?? source.id) || available.has(source.id), })); - return listProductCodingSkills(this.options.bundledSkillsDir, skillIds, sources); + const selectedSkillIds = this.options.getPluginSkillSources + ? skillIds.filter((id) => BUNDLED_CODING_SKILL_IDS.includes(id) + || uniqueSources.some((source) => source.id === id)) + : skillIds; + return listProductCodingSkills(this.options.bundledSkillsDir, selectedSkillIds, sources); } async listCommands( diff --git a/electron/coding-runtime/pi/resource-loader.ts b/electron/coding-runtime/pi/resource-loader.ts index c094420..3e0cc75 100644 --- a/electron/coding-runtime/pi/resource-loader.ts +++ b/electron/coding-runtime/pi/resource-loader.ts @@ -5,6 +5,7 @@ import { validateSessionKey } from '../../coding-projects/conversation-store'; import { resolveBundledCodingPluginRootPaths } from '../../coding-plugins/manifest'; import type { PiProviderSelection } from './provider-config'; import type { PiManagedInputRevision } from './managed-input-revision'; +import type { EffectivePluginSnapshot } from '../../coding-plugins/effective-resolver'; const MANAGED_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; @@ -38,6 +39,10 @@ export interface MaterializePiAgentResourcesOptions { skillEntries: readonly PiSkillEntry[]; catalogRevision: number; bundledSkillsDir: string; + /** Main-owned installed package roots for effective Marketplace Skills. */ + skillRoots?: readonly string[]; + /** The exact resolver output for this worker generation. */ + effectiveSnapshot?: EffectivePluginSnapshot; revision: PiManagedInputRevision; } @@ -50,6 +55,7 @@ export interface PiAgentResourceManifest { skillEntries: PiSkillEntry[]; catalogRevision: number; revision: PiManagedInputRevision; + effectivePluginSnapshot?: EffectivePluginSnapshot; } export interface PiAgentResourceSnapshot { @@ -62,6 +68,7 @@ export interface PiAgentResourceSnapshot { skillPaths: string[]; catalogRevision: number; revision: PiManagedInputRevision; + effectivePluginSnapshot?: EffectivePluginSnapshot; summary: { projectId: string; agentId: string; @@ -69,6 +76,7 @@ export interface PiAgentResourceSnapshot { skillEntries: PiSkillEntry[]; catalogRevision: number; revision: PiManagedInputRevision; + effectivePluginSnapshot?: EffectivePluginSnapshot; }; } @@ -184,6 +192,7 @@ function pathWithin(root: string, entryPath: string): string | null { async function resolveSkillEntryPath( bundledSkillsDir: string, entry: PiSkillEntry, + skillRoots: readonly string[] = [], ): Promise { const roots = [ path.resolve(bundledSkillsDir), @@ -191,6 +200,7 @@ async function resolveSkillEntryPath( path.dirname(path.resolve(bundledSkillsDir)), 'coding-plugins', )), + ...skillRoots.map((root) => path.resolve(root)), ]; for (const root of roots) { const candidate = pathWithin(root, entry.entryPath); @@ -208,10 +218,11 @@ async function resolveSkillEntryPath( export async function resolveExplicitCodingSkillPaths( bundledSkillsDir: string, skillEntries: readonly PiSkillEntry[], + skillRoots: readonly string[] = [], ): Promise<{ skillIds: string[]; skillEntries: PiSkillEntry[]; skillPaths: string[] }> { const normalizedEntries = normalizeSkillEntries(skillEntries); const skillPaths = await Promise.all(normalizedEntries.map((entry) => ( - resolveSkillEntryPath(bundledSkillsDir, entry) + resolveSkillEntryPath(bundledSkillsDir, entry, skillRoots) ))); return { skillIds: normalizedEntries.map(({ id }) => id), @@ -235,6 +246,7 @@ export async function materializePiAgentResources( const { skillIds, skillEntries, skillPaths } = await resolveExplicitCodingSkillPaths( options.bundledSkillsDir, options.skillEntries, + options.skillRoots, ); const resolvedCatalogRevision = catalogRevision(options.catalogRevision); const promptPath = path.join(projectPromptsDir, `${agentId}.md`); @@ -248,6 +260,7 @@ export async function materializePiAgentResources( skillEntries: structuredClone(skillEntries), catalogRevision: resolvedCatalogRevision, revision: { ...options.revision }, + ...(options.effectiveSnapshot ? { effectivePluginSnapshot: structuredClone(options.effectiveSnapshot) } : {}), }; await atomicWriteText(promptPath, options.prompt); await atomicWriteJson(manifestPath, manifest); @@ -261,6 +274,7 @@ export async function materializePiAgentResources( skillPaths, catalogRevision: resolvedCatalogRevision, revision: { ...options.revision }, + ...(options.effectiveSnapshot ? { effectivePluginSnapshot: structuredClone(options.effectiveSnapshot) } : {}), summary: { projectId, agentId, @@ -268,6 +282,7 @@ export async function materializePiAgentResources( skillEntries: structuredClone(skillEntries), catalogRevision: resolvedCatalogRevision, revision: { ...options.revision }, + ...(options.effectiveSnapshot ? { effectivePluginSnapshot: structuredClone(options.effectiveSnapshot) } : {}), }, }; } diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index d1524ba..4fa417d 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -141,6 +141,8 @@ export interface PiManagedWorkerOpenerOptions { cliPath: string; userDataDir: string; bundledSkillsDir: string; + getSkillRoots?(): readonly string[] | Promise; + registerActivePluginReleases?(releaseIds: readonly string[]): () => void; loadProviderInput(): Promise; resolveCredential(account: ProviderAccount): Promise; getLocalProxyCredential?(): Promise; @@ -246,6 +248,7 @@ export function createPiManagedWorkerOpener( const workerResources = options.capabilityRegistry ? await options.capabilityRegistry.resolveWorkerResources({ projectPath: registered.projectPath, + projectId: input.conversation.projectId, assignedSkillIds: registered.agent.skillIds, role: 'parent', }) @@ -258,6 +261,12 @@ export function createPiManagedWorkerOpener( skillEntries: workerResources.skillEntries, catalogRevision: workerResources.catalogRevision, bundledSkillsDir: options.bundledSkillsDir, + ...(workerResources.skillRoots + ? { skillRoots: workerResources.skillRoots } + : options.getSkillRoots ? { skillRoots: await options.getSkillRoots() } : {}), + ...(workerResources.effectiveSnapshot + ? { effectiveSnapshot: workerResources.effectiveSnapshot } + : {}), revision: input.revision, }); const credential = await buildPiWorkerCredentialProjection({ @@ -276,6 +285,9 @@ export function createPiManagedWorkerOpener( skillEntries: workerResources.skillEntries, catalogRevision: workerResources.catalogRevision, tools: workerResources.tools, + ...(workerResources.effectiveSnapshot + ? { effectiveSnapshot: workerResources.effectiveSnapshot } + : {}), extensionsDir: managedPaths.extensionsDir, }); recordManagedMilestone( @@ -313,8 +325,21 @@ export function createPiManagedWorkerOpener( conversationId: input.conversation.conversationId, workerGeneration: input.generation, }); + const releaseActivePluginReleases = workerResources.effectiveSnapshot + ? options.registerActivePluginReleases?.(workerResources.effectiveSnapshot.pluginReleaseIds) + : undefined; + let managedResourcesDisposed = false; + const disposeManagedResources = async (): Promise => { + if (managedResourcesDisposed) return; + managedResourcesDisposed = true; + try { + await extension.dispose(); + } finally { + releaseActivePluginReleases?.(); + } + }; let unsubscribeExtensionInvalidation = process.subscribeInvalidation(() => { - void extension.dispose(); + void disposeManagedResources(); }); try { const spawnStartedAt = now(); @@ -389,7 +414,7 @@ export function createPiManagedWorkerOpener( `${input.conversation.conversationId}:${input.generation}`, input.generation, process, - extension.dispose, + disposeManagedResources, () => { unsubscribeExtensionInvalidation(); unsubscribeExtensionInvalidation = () => undefined; @@ -400,7 +425,7 @@ export function createPiManagedWorkerOpener( } catch (error) { unsubscribeExtensionInvalidation(); await process.stop('open_failure').catch(() => undefined); - await extension.dispose(); + await disposeManagedResources(); throw error; } }; diff --git a/electron/coding-runtime/pi/subagent-child.ts b/electron/coding-runtime/pi/subagent-child.ts index f6777c7..37d0da5 100644 --- a/electron/coding-runtime/pi/subagent-child.ts +++ b/electron/coding-runtime/pi/subagent-child.ts @@ -56,6 +56,7 @@ export interface PiManagedSubagentChildOpenerOptions { cliPath: string; userDataDir: string; bundledSkillsDir: string; + getSkillRoots?(): readonly string[] | Promise; extensionHost: PiManagedExtensionHost; loadProviderInput(): Promise<{ accounts: ProviderAccount[]; modelSummaries: ModelSummary[] }>; resolveCredential(account: ProviderAccount): Promise; @@ -194,6 +195,7 @@ export function createPiManagedSubagentChildOpener( const workerResources = options.capabilityRegistry ? await options.capabilityRegistry.resolveWorkerResources({ projectPath: project.path, + projectId: input.projectId, assignedSkillIds: agent.skillIds, role: 'child', }) @@ -206,6 +208,12 @@ export function createPiManagedSubagentChildOpener( skillEntries: workerResources.skillEntries, catalogRevision: workerResources.catalogRevision, bundledSkillsDir: options.bundledSkillsDir, + ...(workerResources.skillRoots + ? { skillRoots: workerResources.skillRoots } + : options.getSkillRoots ? { skillRoots: await options.getSkillRoots() } : {}), + ...(workerResources.effectiveSnapshot + ? { effectiveSnapshot: workerResources.effectiveSnapshot } + : {}), revision: options.getRevision(), }); const credential = await buildPiWorkerCredentialProjection({ @@ -224,6 +232,9 @@ export function createPiManagedSubagentChildOpener( skillEntries: workerResources.skillEntries, catalogRevision: workerResources.catalogRevision, tools: [], + ...(workerResources.effectiveSnapshot + ? { effectiveSnapshot: workerResources.effectiveSnapshot } + : {}), extensionsDir: managedPaths.extensionsDir, role: 'child', runId: input.runId, diff --git a/tests/unit/coding-capability-registry.test.ts b/tests/unit/coding-capability-registry.test.ts index 151974b..7f5b552 100644 --- a/tests/unit/coding-capability-registry.test.ts +++ b/tests/unit/coding-capability-registry.test.ts @@ -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, diff --git a/tests/unit/coding-plugin-effective-resolver.test.ts b/tests/unit/coding-plugin-effective-resolver.test.ts new file mode 100644 index 0000000..70ea244 --- /dev/null +++ b/tests/unit/coding-plugin-effective-resolver.test.ts @@ -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[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(); + }); +}); diff --git a/tests/unit/coding-plugin-marketplace-client.test.ts b/tests/unit/coding-plugin-marketplace-client.test.ts index 74049e0..f3052d0 100644 --- a/tests/unit/coding-plugin-marketplace-client.test.ts +++ b/tests/unit/coding-plugin-marketplace-client.test.ts @@ -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(); diff --git a/tests/unit/coding-product-services.test.ts b/tests/unit/coding-product-services.test.ts index 3741d99..03ae275 100644 --- a/tests/unit/coding-product-services.test.ts +++ b/tests/unit/coding-product-services.test.ts @@ -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'); diff --git a/tests/unit/pi-managed-worker-opener.test.ts b/tests/unit/pi-managed-worker-opener.test.ts index ff12cfe..242c48b 100644 --- a/tests/unit/pi-managed-worker-opener.test.ts +++ b/tests/unit/pi-managed-worker-opener.test.ts @@ -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> = []; + 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; + 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(); }); diff --git a/tests/unit/pi-product-tools.test.ts b/tests/unit/pi-product-tools.test.ts index 3764a8a..3556cf3 100644 --- a/tests/unit/pi-product-tools.test.ts +++ b/tests/unit/pi-product-tools.test.ts @@ -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({ diff --git a/tests/unit/pi-resource-loader.test.ts b/tests/unit/pi-resource-loader.test.ts index 1ce9cde..0d5eafa 100644 --- a/tests/unit/pi-resource-loader.test.ts +++ b/tests/unit/pi-resource-loader.test.ts @@ -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' }); diff --git a/tests/unit/plugin-marketplace-routes.test.ts b/tests/unit/plugin-marketplace-routes.test.ts new file mode 100644 index 0000000..5168395 --- /dev/null +++ b/tests/unit/plugin-marketplace-routes.test.ts @@ -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, + }; +} + +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(); + }); +});