From f0ac7d70d1cbbfd128ae5e988762c51ae91634df Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Thu, 27 Aug 2026 21:16:27 +0800 Subject: [PATCH] fix(plugin): remediate ML-07 R2 findings --- ...827-plugin-ml07-remediation-r2-6e9a3c41.md | 68 ++++++++++ electron/api/coding-composition.ts | 16 +-- .../coding-plugins/adapters/data-service.ts | 10 +- electron/coding-plugins/registry.ts | 20 ++- src/stores/coding-plugins.ts | 22 +++- tests/unit/coding-capability-registry.test.ts | 42 ++++++- tests/unit/coding-plugins-store.test.ts | 92 ++++++++++++++ .../unit/data-service-plugin-adapter.test.ts | 22 ++++ tests/unit/pi-extension-bundle.test.ts | 116 +++++++++++++++++- tests/unit/pi-resource-loader.test.ts | 2 +- 10 files changed, 381 insertions(+), 29 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-r2-6e9a3c41.md diff --git a/.project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-r2-6e9a3c41.md b/.project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-r2-6e9a3c41.md new file mode 100644 index 0000000..d54f139 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260827-plugin-ml07-remediation-r2-6e9a3c41.md @@ -0,0 +1,68 @@ +# Task: Remediate accepted MakeLore ML-07 R2 findings + +## Identity + +- Task ID: 20260827-plugin-ml07-remediation-r2-6e9a3c41 +- Mode: Feature +- Branch: codex/20260827-plugin-ml07-remediation-r2-6e9a3c41-plugin-ml07-remediation-r2 +- Worktree: D:\Datas\OthersProjects\makelore-plugin-ml07-remediation-r2-6e9a3c41 +- Base commit: 29cf322f1ac0500295c1afec076800aea3908eb3 +- Owner: ml07-remediator +- Status: Ready for Integration + +## Scope + +- Make the Data Service adapter validate the registry-supplied parsed tool instead of consulting a second static tool-definition table. +- Prevent stale project-scoped Plugin Center mutations from committing after a newer project load starts. +- Add persisted Pi run/resource identity evidence across reconnect and event replay through the existing extension bridge and capability registry. +- Refresh plugin policy only before a parent worker can materialize an assigned server-backed plugin; child and core-only resolutions must not wait. +- Change only the delegated product files, directly corresponding tests, and this record. + +## Intent And Constraints + +- Preserve the fixed first-party plugin boundary: package definitions are Main-authoritative, registry dispatch is the sole non-core tool path, and Pi remains the sole coding runtime. +- Preserve policy refresh before materializing an enabled and assigned server-backed plugin. Do not add facades, migration/checksum machinery, or a replay framework. +- Base and sole parent must remain `29cf322f1ac0500295c1afec076800aea3908eb3`; do not touch the coordinator or user root worktrees, push, or open a PR. +- Existing coordinator and reviewer tasks are read-only peers. The prior ML-07 remediation worktree is complete and must not be reused. + +## Project Context Loaded + +- Concurrent and Planning gates passed with `task_context.py`; status identifies this exact worktree, branch, base, task, and owner. +- Read the project memory entry/current-state/architecture/domain/data-flow/success criteria and relevant ADR/task records, including the coordinator, R2 reviewers, and prior remediation. +- Read the canonical plugin implementation spec (especially §§6-7.5, 10.2-12), detailed design (especially §§8.2-8.9, 15-17), and ML-07 plan ticket. +- Confirmed no ownership conflict: coordinator owns integration/its record; R2 reviewers are read-only; this task exclusively owns the bounded remediation files and focused tests. + +## Plan + +1. Add a red adapter test proving a registry-supplied schema controls validation, then remove the adapter's static definition lookup and run adapter/manifest/registry focus tests. +2. Add stale A-mutation/B-load store tests, then apply one generation/project guard consistently to `setEnabled` and adjacent Data Service mutation commits. +3. Move the refresh decision to the registry's existing role/assignment resolution seam; add never-settling child/core-only tests plus a server-backed parent refresh test, and remove the unconditional composition wrapper. +4. Extend the existing Pi extension bundle test to persist a run ID, reconnect/re-import, replay the same resource ID, and assert both calls traverse the bridge/real registry with the identical `pi::` request ID. Change production replay code only if this exposes a defect. +5. Run affected focused suites, typecheck, lint, build, and package proof proportionate to the changes; complete the task documentation gate and create one clean remediation commit. + +## Outcome + +- The Data Service adapter now validates and dispatches the immutable parsed tool supplied by the registry; its production dependency on `DATA_SERVICE_TOOL_DEFINITIONS` is removed while the typed ten-operation switch and domain bounds remain code-owned. +- Project-scoped enable/configure/reset/remove-collection/remove-project mutations capture both active project and load generation. A late project A response cannot inspect, commit, or trigger a reload after project B starts loading. +- Policy refresh now occurs inside the registry resource-resolution seam only for a parent with an assigned Skill owned by a server-backed plugin with a registered adapter. Child and parent/core-only resolution return without touching a stalled refresh; an assigned server-backed parent still refreshes before reading project enablement or policy state. +- The existing Pi extension bundle test persists `persisted-run`, reimports the generated extension to model reconnect, replays `persisted-resource`, and sends both calls through the authenticated bridge and real capability registry. Both results retain the exact request ID `pi:persisted-run:persisted-resource`; no production replay change was needed. +- The async/sync manifest loaders were intentionally not consolidated: their only duplication is the inherently different filesystem API flow, while both feed the same parser/validator. Refactoring that optional shape would not improve runtime authority within this bounded remediation. + +## Verification + +- Red evidence: adapter registry-schema test initially received success because the adapter replaced the supplied tool with the static definition; after the fix it rejects with `plugin_input_invalid` and the backend is not called. +- Red evidence: stale A enable and four adjacent Data Service mutation tests initially overwrote B or continued into inspect; after generation/project guards both pass without stale inspect/reload. +- Red evidence: assigned server-backed parent registry resolution initially never called refresh; after moving the decision into the registry it refreshes before enablement lookup. Never-settling refresh tests prove child and parent/core-only paths do not wait or call refresh. +- `pnpm exec vitest run` focused affected set: 8 files, 44 tests passed (`data-service-plugin-adapter`, plugin manifest/registry/composition/routes, store, Pi extension bundle, Pi resource loader). +- `pnpm run typecheck`: passed. +- Focused ESLint over all changed product/test files: passed with zero warnings/errors. Full `pnpm run lint` reported zero errors and five pre-existing unrelated warnings in Home/Makelore files. +- `pnpm run package:stage:win-x64`: passed (Vite renderer/Main/preload/utility builds and Pi runtime staging). +- First `pnpm run verify:artifact:pi` correctly reported a missing unpacked executable because Electron builder had not yet run. After `node scripts/run-electron-builder.mjs --win --publish never`, the verifier passed, including the package-owned Data Service manifest/Skill/tools and four core Skill roots. + +## Follow-ups + +- Coordinator will rerun the integration ledger/full suite after applying the sole remediation commit. + +## Promotion Candidates + +- None recorded. diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index f163f3a..94f6cfa 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -38,7 +38,6 @@ import { resolveLegacyProjectModel } from '../coding-projects/legacy-v1'; import { createProjectPluginService } from '../coding-plugins/project-service'; import { createCodingCapabilityRegistry, - type CodingCapabilityRegistry, } from '../coding-plugins/registry'; import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service'; import { PluginPolicyClient } from '../services/plugin-policy-client'; @@ -201,16 +200,7 @@ export function createCodingComposition( return active.projectId; }, }); - const refreshingCapabilityRegistry: CodingCapabilityRegistry = { - async resolveWorkerResources(input) { - await policyClient.refresh(); - return await capabilityRegistry.resolveWorkerResources(input); - }, - async invoke(input) { - return await capabilityRegistry.invoke(input); - }, - }; - productTools.configureCapabilityRegistry(refreshingCapabilityRegistry); + productTools.configureCapabilityRegistry(capabilityRegistry); plugins = createCodingProjectPluginService({ projects, projectPlugins, @@ -233,7 +223,7 @@ export function createCodingComposition( ? { getLocalProxyCredential: async () => getLocalProxyCredential() } : {}), extensionHost, - capabilityRegistry: refreshingCapabilityRegistry, + capabilityRegistry, }), }); const childOpener = createPiManagedSubagentChildOpener({ @@ -249,7 +239,7 @@ export function createCodingComposition( ...(getLocalProxyCredential ? { getLocalProxyCredential: async () => getLocalProxyCredential() } : {}), - capabilityRegistry: refreshingCapabilityRegistry, + capabilityRegistry, }); const subagents = new PiSubagentScheduler({ openChild: childOpener, diff --git a/electron/coding-plugins/adapters/data-service.ts b/electron/coding-plugins/adapters/data-service.ts index 21acf5f..6f22c4f 100644 --- a/electron/coding-plugins/adapters/data-service.ts +++ b/electron/coding-plugins/adapters/data-service.ts @@ -10,7 +10,6 @@ import type { } from '../../../shared/data-service'; import { DATA_SERVICE_PLUGIN_ID, - DATA_SERVICE_TOOL_DEFINITIONS, type CodingPluginToolDefinition, } from '../../../shared/coding-plugins'; import type { DataServiceOperations } from '../../services/data-service-client'; @@ -170,10 +169,6 @@ type DataServiceResult = | DataServiceInstanceRemoval | null; -function toolByName(toolName: string): CodingPluginToolDefinition | undefined { - return DATA_SERVICE_TOOL_DEFINITIONS.find(({ name }) => name === toolName); -} - async function callDataService( operations: DataServiceOperations, toolName: string, @@ -271,14 +266,13 @@ export class DataServicePluginAdapter implements CodingPluginAdapter { tool: CodingPluginToolDefinition, input: unknown, ): Promise> { - const canonicalTool = toolByName(tool.name); - if (!canonicalTool || !validateInput(canonicalTool, input)) { + if (!validateInput(tool, input)) { return failure('plugin_input_invalid', 'Data Service tool input is invalid'); } try { return projectResult(await callDataService( this.operations, - canonicalTool.name, + tool.name, input as InputRecord, _context.projectPath, )); diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts index 915ffd0..82f5c08 100644 --- a/electron/coding-plugins/registry.ts +++ b/electron/coding-plugins/registry.ts @@ -113,7 +113,10 @@ export interface CodingCapabilityRegistryPort { export type CodingCapabilityRegistry = CodingCapabilityRegistryPort; export interface CodingCapabilityRegistryOptions { - policyClient: Pick<{ getState(): PluginPolicyClientState }, 'getState'>; + policyClient: { + getState(): PluginPolicyClientState; + refresh(): Promise; + }; getEnabledPluginIds?: (projectPath: string) => Promise; projectPlugins?: { getEnabledPluginIds(projectPath: string): Promise; @@ -375,6 +378,21 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor tools: [], }; } + const hasAssignedServerPlugin = this.definitions.some((definition) => ( + definition.requiresBackend + && this.adaptersByPluginId.has(definition.id) + && definition.skills.some(({ id }) => assigned.includes(id)) + )); + if (!hasAssignedServerPlugin) { + return { + catalogRevision: this.options.policyClient.getState().revision, + pluginIds: [], + effectiveSkillIds: effectiveCoreSkills, + skillEntries: effectiveCoreSkills.map((id) => ({ id, entryPath: `${id}/SKILL.md` })), + tools: [], + }; + } + await this.options.policyClient.refresh(); const enabled = await this.enabledPluginIds(input.projectPath); const state = this.options.policyClient.getState(); const pluginIds: string[] = []; diff --git a/src/stores/coding-plugins.ts b/src/stores/coding-plugins.ts index 4474261..13cf3f8 100644 --- a/src/stores/coding-plugins.ts +++ b/src/stores/coding-plugins.ts @@ -84,14 +84,17 @@ export function createCodingPluginsStore(overrides: Partial = {}): }); }, setEnabled(projectId, pluginId, enabled) { + const generation = loadGeneration; return operation(`enabled:${projectId}:${pluginId}`, async () => { const projection = await deps.setEnabled(projectId, pluginId, enabled); - let dataService = get().projectId === projectId ? get().dataService : null; + if (generation !== loadGeneration || get().projectId !== projectId) return; + let dataService = get().dataService; const item = projection.items.find(({ id }) => id === pluginId); if (!enabled) { dataService = null; } else if (item?.settingsSurface === 'data-service' && item.backend.status === 'ready') { const inspected = await deps.inspectDataService(); + if (generation !== loadGeneration || get().projectId !== projectId) return; if (inspected.success && inspected.data) dataService = inspected.data; } set({ @@ -100,34 +103,47 @@ export function createCodingPluginsStore(overrides: Partial = {}): }); }, configure(collections) { + const projectId = get().projectId; + const generation = loadGeneration; return operation('data-service:configure', async () => { const result = await deps.configureDataService(collections); if (!result.success || !result.data) throw new Error(result.error || '开发数据空间创建失败'); + if (generation !== loadGeneration || get().projectId !== projectId) return; set({ dataService: result.data, error: null }); - if (get().projectId) await get().load(get().projectId as string); + if (projectId) await get().load(projectId); }); }, reset() { + const projectId = get().projectId; + const generation = loadGeneration; return operation('data-service:reset', async () => { const result = await deps.resetDataService(); if (!result.success || !result.data) throw new Error(result.error || '开发数据重置失败'); + if (generation !== loadGeneration || get().projectId !== projectId) return; set({ dataService: result.data }); }); }, removeCollection(collection) { + const projectId = get().projectId; + const generation = loadGeneration; return operation(`data-service:collection:${collection}`, async () => { const result = await deps.removeCollection(collection); if (!result.success) throw new Error(result.error || '移除 collection 失败'); + if (generation !== loadGeneration || get().projectId !== projectId) return; const inspected = await deps.inspectDataService(); + if (generation !== loadGeneration || get().projectId !== projectId) return; if (inspected.success && inspected.data) set({ dataService: inspected.data }); }); }, removeProject() { + const projectId = get().projectId; + const generation = loadGeneration; return operation('data-service:remove-project', async () => { const result = await deps.removeProject(); if (!result.success) throw new Error(result.error || '删除开发数据空间失败'); + if (generation !== loadGeneration || get().projectId !== projectId) return; set({ dataService: null }); - if (get().projectId) await get().load(get().projectId as string); + if (projectId) await get().load(projectId); }); }, })); diff --git a/tests/unit/coding-capability-registry.test.ts b/tests/unit/coding-capability-registry.test.ts index ec17101..151974b 100644 --- a/tests/unit/coding-capability-registry.test.ts +++ b/tests/unit/coding-capability-registry.test.ts @@ -86,7 +86,7 @@ function adapter(): CodingPluginAdapter { function registry(overrides: Partial[0]> = {}) { return new CodingCapabilityRegistryImpl({ - policyClient: { getState: () => policy }, + policyClient: { getState: () => policy, refresh: vi.fn().mockResolvedValue(undefined) }, getEnabledPluginIds: async () => ['makelore.data-service'], adapters: [adapter()], definitions: [DATA_SERVICE_PLUGIN_DEFINITION], @@ -114,13 +114,51 @@ describe('CodingCapabilityRegistry', () => { expect(child.pluginIds).toEqual([]); expect(child.tools).toEqual([]); const unavailable = await registry({ - policyClient: { getState: () => ({ ...policy, catalog: null, status: 'unavailable', revision: 0 }) }, + policyClient: { + getState: () => ({ ...policy, catalog: null, status: 'unavailable', revision: 0 }), + refresh: vi.fn().mockResolvedValue(undefined), + }, }).resolveWorkerResources({ projectPath: context.projectPath, assignedSkillIds: context.skillIds, role: 'parent', }); expect(unavailable.tools).toEqual([]); }); + it('does not wait for policy refresh when child or parent core-only resources cannot consume plugins', async () => { + const refresh = vi.fn(() => new Promise(() => {})); + const policyClient = { getState: () => policy, refresh }; + const child = await registry({ policyClient }).resolveWorkerResources({ + projectPath: context.projectPath, + assignedSkillIds: context.skillIds, + role: 'child', + }); + const coreOnly = await registry({ policyClient }).resolveWorkerResources({ + projectPath: context.projectPath, + assignedSkillIds: ['grilling'], + role: 'parent', + }); + + expect(child.tools).toEqual([]); + expect(coreOnly.effectiveSkillIds).toEqual(['grilling']); + expect(refresh).not.toHaveBeenCalled(); + }); + + it('refreshes policy before resolving an assigned server-backed parent plugin', async () => { + const refresh = vi.fn().mockResolvedValue(undefined); + const getEnabledPluginIds = vi.fn().mockResolvedValue(['makelore.data-service']); + await registry({ + policyClient: { getState: () => policy, refresh }, + getEnabledPluginIds, + }).resolveWorkerResources({ + projectPath: context.projectPath, + assignedSkillIds: context.skillIds, + role: 'parent', + }); + + expect(refresh).toHaveBeenCalledOnce(); + expect(refresh.mock.invocationCallOrder[0]).toBeLessThan(getEnabledPluginIds.mock.invocationCallOrder[0]); + }); + it('revalidates invocation, derives the stable Pi request id, and preserves domain faults', async () => { const result = await registry().invoke({ toolName: 'data_service_get_document', diff --git a/tests/unit/coding-plugins-store.test.ts b/tests/unit/coding-plugins-store.test.ts index 4133e6d..6366f60 100644 --- a/tests/unit/coding-plugins-store.test.ts +++ b/tests/unit/coding-plugins-store.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { createCodingPluginsStore } from '@/stores/coding-plugins'; +import type { + DataServiceCollectionRemoval, + DataServiceHostResult, + DataServiceInstanceRemoval, + DataServiceInstanceState, +} from '../../shared/data-service'; function projection(enabled = false, projectId = 'local-project') { return { @@ -15,6 +21,31 @@ function projection(enabled = false, projectId = 'local-project') { }; } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function dataServiceInstance(instanceId: string): DataServiceInstanceState { + return { + instance_id: instanceId, project_id: 'cloud-project', collections: [], + usage: { document_count: 0, total_bytes: 0 }, + limits: { + max_collections: 20, max_documents: 1000, max_total_bytes: 20_971_520, + max_document_bytes: 65_536, list_default_limit: 50, list_max_limit: 100, + list_max_data_bytes: 1_048_576, mutations_per_minute: 120, + }, + created_at: '2026-08-27T00:00:00Z', updated_at: '2026-08-27T00:00:00Z', + }; +} + +function success(data: T): DataServiceHostResult { + return { + success: true, status: 200, code: null, error: null, retryable: false, data, + }; +} + describe('coding plugins store', () => { it('coalesces duplicate loads and mutations while retaining the last projection on failure', async () => { let resolveLoad!: (value: ReturnType) => void; @@ -87,6 +118,66 @@ describe('coding plugins store', () => { }); }); + it('does not let a late project A enable mutation overwrite loaded project B', async () => { + const enabledA = deferred>(); + const inspectDataService = vi.fn(); + const store = createCodingPluginsStore({ + list: vi.fn().mockResolvedValue(projection(false, 'project-b')), + setEnabled: vi.fn(() => enabledA.promise), + inspectDataService, + }); + store.setState({ projectId: 'project-a', projection: projection(false, 'project-a') }); + + const mutation = store.getState().setEnabled('project-a', 'makelore.data-service', true); + await store.getState().load('project-b'); + enabledA.resolve(projection(true, 'project-a')); + await mutation; + + expect(store.getState()).toMatchObject({ + projectId: 'project-b', projection: { project: { localProjectId: 'project-b' } }, + }); + expect(inspectDataService).not.toHaveBeenCalled(); + }); + + it('does not let adjacent late Data Service mutations commit or reload over project B', async () => { + const configure = deferred>(); + const reset = deferred>(); + const removeCollection = deferred>(); + const removeProject = deferred>(); + const inspectDataService = vi.fn(); + const list = vi.fn().mockResolvedValue(projection(false, 'project-b')); + const store = createCodingPluginsStore({ + list, + inspectDataService, + configureDataService: vi.fn(() => configure.promise), + resetDataService: vi.fn(() => reset.promise), + removeCollection: vi.fn(() => removeCollection.promise), + removeProject: vi.fn(() => removeProject.promise), + }); + store.setState({ projectId: 'project-a', projection: projection(false, 'project-a') }); + + const mutations = [ + store.getState().configure(['todos']), + store.getState().reset(), + store.getState().removeCollection('todos'), + store.getState().removeProject(), + ]; + await store.getState().load('project-b'); + configure.resolve(success(dataServiceInstance('late-configure'))); + reset.resolve(success(dataServiceInstance('late-reset'))); + removeCollection.resolve(success({ removed: true, usage: { document_count: 0, total_bytes: 0 } })); + removeProject.resolve(success({ removed: true })); + await Promise.all(mutations); + + expect(store.getState()).toMatchObject({ + projectId: 'project-b', + projection: { project: { localProjectId: 'project-b' } }, + dataService: null, + }); + expect(inspectDataService).not.toHaveBeenCalled(); + expect(list).toHaveBeenCalledOnce(); + }); + it('retains ready Data Service usage when enabling an existing instance', async () => { const readyProjection = projection(true); readyProjection.items[0] = { @@ -104,6 +195,7 @@ describe('coding plugins store', () => { const store = createCodingPluginsStore({ setEnabled: vi.fn().mockResolvedValue(readyProjection), inspectDataService, }); + store.setState({ projectId: 'local-project' }); await store.getState().setEnabled('local-project', 'makelore.data-service', true); diff --git a/tests/unit/data-service-plugin-adapter.test.ts b/tests/unit/data-service-plugin-adapter.test.ts index 939ef0d..aadc151 100644 --- a/tests/unit/data-service-plugin-adapter.test.ts +++ b/tests/unit/data-service-plugin-adapter.test.ts @@ -46,6 +46,28 @@ function operations(): DataServiceOperations { } describe('Data Service plugin adapter', () => { + it('validates the parsed tool supplied by the capability registry', async () => { + const dataService = operations(); + const adapter = createDataServicePluginAdapter(dataService); + const inspect = DATA_SERVICE_TOOL_DEFINITIONS.find(({ name }) => name === 'data_service_inspect'); + if (!inspect) throw new Error('inspect definition missing'); + + const result = await adapter.invoke(context, { + ...inspect, + inputSchema: { + type: 'object', + additionalProperties: false, + required: ['registry_token'], + properties: { registry_token: { type: 'string', minLength: 1 } }, + }, + }, {}); + + expect(result).toMatchObject({ + success: false, status: 422, code: 'plugin_input_invalid', data: null, + }); + expect(dataService.inspect).not.toHaveBeenCalled(); + }); + it('maps all ten package operations through DataServiceOperations', async () => { const dataService = operations(); const adapter = createDataServicePluginAdapter(dataService); diff --git a/tests/unit/pi-extension-bundle.test.ts b/tests/unit/pi-extension-bundle.test.ts index a1293d1..2798cfd 100644 --- a/tests/unit/pi-extension-bundle.test.ts +++ b/tests/unit/pi-extension-bundle.test.ts @@ -4,13 +4,15 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host'; import { PiSubagentScheduler } from '../../electron/coding-runtime/pi/subagent'; import { PiProcessBudget } from '../../electron/coding-runtime/pi/worker-pool'; import type { AgentBrowserModule } from '../../electron/agent-browser'; import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store'; import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools'; +import { CodingCapabilityRegistryImpl } from '../../electron/coding-plugins/registry'; +import type { PluginPolicyClientState } from '../../electron/services/plugin-policy-client'; import { DATA_SERVICE_PLUGIN_DEFINITION, type CodingPluginToolDefinition, @@ -46,6 +48,118 @@ afterEach(async () => { }); describe('Makelore Pi extension bundle', () => { + it('hydrates persisted Pi identity through reconnect and event replay into the capability registry', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-persisted-identity-')); + roots.push(root); + const policy: PluginPolicyClientState = { + status: 'current', revision: 23, lastVerifiedAt: 1, + catalog: { + schema_version: 1, catalog_version: 'catalog-23', pricing_version: null, + plugins: [{ + plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id, + supported_contract_versions: [DATA_SERVICE_PLUGIN_DEFINITION.contractVersion], + status: 'active', + capabilities: [{ + capability_id: 'data-service.control', + operations: [{ + operation: 'inspect', + billing: { mode: 'included', entitlement_scope: null, notice: 'Included' }, + }], + }], + }], + }, + }; + const invoke = vi.fn(async () => ({ + success: true as const, status: 200, code: null, error: null, retryable: false as const, + payload_schema: 'data-service.v1', data: { instance_id: 'instance-a' }, + })); + const capabilityRegistry = new CodingCapabilityRegistryImpl({ + policyClient: { getState: () => policy, refresh: vi.fn().mockResolvedValue(undefined) }, + getEnabledPluginIds: async () => [DATA_SERVICE_PLUGIN_DEFINITION.id], + definitions: [DATA_SERVICE_PLUGIN_DEFINITION], + adapters: [{ + pluginId: DATA_SERVICE_PLUGIN_DEFINITION.id, + async inspect() { return { status: 'ready' }; }, + invoke, + }], + }); + const host = new PiManagedExtensionHost(); + host.configureProductTools(new PiProductTools({ + browser: {} as AgentBrowserModule, + attachments: new CodingAttachmentStore(path.join(root, 'attachments')), + bundledSkillsDir: path.resolve('resources/coding-skills'), + capabilityRegistry, + })); + hosts.push(host); + const inspectTool = DATA_SERVICE_PLUGIN_DEFINITION.tools.find( + ({ name }) => name === 'data_service_inspect', + ); + if (!inspectTool) throw new Error('inspect definition missing'); + const worker = await host.registerWorker({ + conversationId: 'persisted-conversation', generation: 1, projectId: 'project-a', + projectPath: root, extensionsDir: root, + skillEntries: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }], + catalogRevision: policy.revision, + tools: [inspectTool], + }); + await host.bindRun('persisted-conversation', 1, 'persisted-run'); + const persistedContext = JSON.parse(await readFile( + worker.env.MAKELORE_PI_CONTEXT_FILE as string, + 'utf8', + )) as { runId?: string }; + expect(persistedContext.runId).toBe('persisted-run'); + + const previous = { + bridge: process.env.MAKELORE_PI_BRIDGE_URL, + token: process.env.MAKELORE_PI_WORKER_TOKEN, + context: process.env.MAKELORE_PI_CONTEXT_FILE, + role: process.env.MAKELORE_PI_WORKER_ROLE, + }; + Object.assign(process.env, worker.env); + try { + const executeAfterHydration = async (connection: string) => { + const module = await import( + /* @vite-ignore */ `${pathToFileURL(worker.extensionPath).href}?connection=${connection}` + ) as { + default(factory: { + registerTool(tool: ExtensionTool): void; + on(event: string, handler: ExtensionHandler): void; + }): void | Promise; + }; + const tools = new Map(); + await module.default({ + registerTool: (tool) => tools.set(tool.name, tool), + on: () => undefined, + }); + return await tools.get('data_service_inspect')?.execute?.( + 'persisted-resource', {}, new AbortController().signal, + ); + }; + + const first = await executeAfterHydration('initial'); + const replay = await executeAfterHydration('reconnect'); + expect(first).toMatchObject({ + details: { + plugin_id: DATA_SERVICE_PLUGIN_DEFINITION.id, + request_id: 'pi:persisted-run:persisted-resource', + }, + }); + expect(replay).toMatchObject({ + details: { request_id: 'pi:persisted-run:persisted-resource' }, + }); + expect(invoke).toHaveBeenCalledTimes(2); + } finally { + for (const [key, value] of Object.entries(previous)) { + const environmentKey = key === 'bridge' ? 'MAKELORE_PI_BRIDGE_URL' + : key === 'token' ? 'MAKELORE_PI_WORKER_TOKEN' + : key === 'context' ? 'MAKELORE_PI_CONTEXT_FILE' + : 'MAKELORE_PI_WORKER_ROLE'; + if (value === undefined) delete process.env[environmentKey]; + else process.env[environmentKey] = value; + } + } + }); + it('materializes only the frozen plugin declarations and lease metadata', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-dynamic-bundle-')); roots.push(root); diff --git a/tests/unit/pi-resource-loader.test.ts b/tests/unit/pi-resource-loader.test.ts index 9116f49..1ce9cde 100644 --- a/tests/unit/pi-resource-loader.test.ts +++ b/tests/unit/pi-resource-loader.test.ts @@ -180,7 +180,7 @@ describe('Pi managed resource loader', () => { }, }; const registry = new CodingCapabilityRegistryImpl({ - policyClient: { getState: () => policy }, + policyClient: { getState: () => policy, refresh: vi.fn().mockResolvedValue(undefined) }, getEnabledPluginIds: async () => enabled ? [DATA_SERVICE_PLUGIN_DEFINITION.id] : [], definitions: [DATA_SERVICE_PLUGIN_DEFINITION], adapters: [{