diff --git a/.project-docs/30-worklog/tasks/20260829-marketplace-mlm06-r5-remediation-c8f4a2d1.md b/.project-docs/30-worklog/tasks/20260829-marketplace-mlm06-r5-remediation-c8f4a2d1.md new file mode 100644 index 0000000..2502779 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260829-marketplace-mlm06-r5-remediation-c8f4a2d1.md @@ -0,0 +1,91 @@ +# Task: Marketplace MLM-06 R5 remediation + +## Identity + +- Task ID: 20260829-marketplace-mlm06-r5-remediation-c8f4a2d1 +- Mode: Feature +- Branch: codex/20260829-marketplace-mlm06-r5-remediation-c8f4a2d1-marketplace-mlm06-r5-remediation +- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r5-remediation-c8f4a2d1 +- Base commit: e4676977b91be62a87ba8c12d3415f1e8334e3c4 +- Owner: marketplace-client-coordinator-r5-remediator +- Status: Ready for Integration + +## Scope + +- Remediate the fixed-range MLM-06 R5 Release A findings from exact coordinator + frontier `e4676977b91be62a87ba8c12d3415f1e8334e3c4` as the sole product writer. +- Own only the schema-v2 execution-mode vocabulary/parser, explicit Package Store + uninstall cleanup semantics, Marketplace device-failure projection, their focused + tests, and this task record. +- Accept any additional supported R5 Standards finding into this same task before + commit; do not create a second remediation owner. + +## Intent And Constraints + +- Make the client schema-v2 contract exactly match canonical/server + `synchronous | job`; reject the non-contract `accepted` value. +- Make explicit uninstall prevent a new worker from seeing the Plugin immediately, + while preserving already-running workers until disposal and allowing their + formerly protected Release records to be removed after the last reference ends. +- Preserve the installed Beta channel across failed install/update projection so the + Renderer cannot silently fall back to Stable labeling or routing. +- Keep Account Library, Device Installation, project selection, Agent assignment, + active-worker freezing, runtime authorization, and billing separate. Do not add a + compatibility layer, background daemon, hidden mutation, Release B path, hosted + adapter, Token Point path, or production key material. +- User root worktrees, server, Operations, publication, deployment, XMA-01, push/PR, + and production activation remain outside this task. +- Concurrent and Planning Gates passed: task-context identity matches exact base; + coordinator is clean; R5 Spec/Standards peers are read-only reviews of the same + range and have no ownership conflict. `AGENTS.md`, all required skills, startup + memory, coordinator/peer records, and the full canonical spec/plan/design were read. + +## Outcome + +- Red/green implementation is in progress. The exact focused red boundary was + `4 failed / 60 passed`: schema-v2 rejected canonical `job`, explicit uninstall + could not retire active/current records across worker release, and a failed Beta + update dropped its channel. +- The minimal product correction now uses `synchronous | job`, removes explicit + current selection before deferred active-worker cleanup, and preserves the prior + installation channel in bounded failure projection. No compatibility layer, + daemon, root-worktree change, server change, or Release B path was added. +- Explicit uninstall now separates logical availability from physical retention: + it clears the current selection immediately, keeps immutable bytes referenced by + a running worker, and allows a later explicit cleanup to remove those bytes after + the worker releases its frozen Release. Background cleanup still refuses to guess + when current selection is missing. +- R5 Standards finalized with the same three Medium findings as R5 Spec and no + independent finding. The complete remediation set is therefore closed in this + single writer task. + +## Verification + +- Focused green: manifest/Package Store/store `3 files / 64 passed`. +- Adjacent Marketplace/Main/effective/Pi green: `11 files / 126 passed`. +- Renderer Beta failure/action proof: `2 files / 20 passed`. +- TypeScript typecheck passed. Dependency install used the frozen lockfile, reused + all 997 packages from the local store, downloaded nothing, and changed no lockfile. +- Scoped ESLint passed. Full `lint:check` passed with zero errors and the exact five + unchanged out-of-scope warnings in Home/Makelore. +- Vite production build passed for Renderer, Main, Preload, and utility worker. The + first sandboxed invocation failed only because pnpm could not `lstat` the Windows + user directory; the identical escalated command passed, so this is recorded as an + environment gate rather than a product failure. +- Full unit suite passed: 208 files / 1,807 passed / 2 skipped, followed by the + repository's single-worker pressure file 1/1 passed. +- Packaged artifact verification was not repeated in this remediation: no trust + key, package layout, artifact verifier, or packaged resource changed; the Vite + build and exact manifest contract tests exercise this change. XMA-01 remains the + later real packaged acceptance gate. + +## Follow-ups + +- Fresh fixed-range Standards and Spec review must run from the integrated client + frontier before XMA-01 can open. +- Official Ed25519 production public key activation and Release B Provider work + remain external holds, not this Release A remediation's completion criteria. + +## Promotion Candidates + +- None recorded. diff --git a/electron/coding-plugins/manifest.ts b/electron/coding-plugins/manifest.ts index 66629be..d0b4d5f 100644 --- a/electron/coding-plugins/manifest.ts +++ b/electron/coding-plugins/manifest.ts @@ -782,8 +782,8 @@ function parseV2Tool( if (permissions.some((permission) => !hostedPermissionMatchesPlugin(permission, pluginId))) { fail(filePath, `tools[${index}].permissions`, 'permission is outside the plugin hosted namespace'); } - if (tool.executionMode !== 'synchronous' && tool.executionMode !== 'accepted') { - fail(filePath, `tools[${index}].executionMode`, 'must be synchronous or accepted'); + if (tool.executionMode !== 'synchronous' && tool.executionMode !== 'job') { + fail(filePath, `tools[${index}].executionMode`, 'must be synchronous or job'); } validateV2SchemaNode(tool.inputSchema, filePath, `tools[${index}].inputSchema`); validateV2SchemaNode(tool.outputSchema, filePath, `tools[${index}].outputSchema`); diff --git a/electron/coding-plugins/package-store.ts b/electron/coding-plugins/package-store.ts index d09b68b..c365573 100644 --- a/electron/coding-plugins/package-store.ts +++ b/electron/coding-plugins/package-store.ts @@ -1040,43 +1040,50 @@ export class PluginPackageStore { const selected = currentSelection.current[validated] ? records.find((record) => record.releaseId === currentSelection.current[validated]) : undefined; - if (!selected) { - return { - status: 'kept', - pluginId: validated, - reason: 'current_selection_missing', - }; - } const protectedIds = new Set([ ...this.accountCache.referencedReleaseIds(), ...(this.activeWorkerReleaseIds() ?? []), ...this.activeWorkers, ]); - const removable = mode === 'explicit' - ? records.filter((record) => !protectedIds.has(record.releaseId)) - : records.length === 1 && !protectedIds.has(selected.releaseId) + let removable: InstalledReleaseRecord[]; + if (mode === 'explicit') { + removable = records.filter((record) => !protectedIds.has(record.releaseId)); + } else { + if (!selected) { + return { + status: 'kept', + pluginId: validated, + reason: 'current_selection_missing', + }; + } + removable = records.length === 1 && !protectedIds.has(selected.releaseId) ? records : records.filter((record) => ( record.releaseId !== selected.releaseId && !protectedIds.has(record.releaseId) )); - if (removable.length === 0) { - return { - status: 'kept', - pluginId: validated, - releaseId: selected.releaseId, - version: selected.version, - ...(selected.channel === undefined ? {} : { channel: selected.channel }), - }; + if (removable.length === 0) { + return { + status: 'kept', + pluginId: validated, + releaseId: selected.releaseId, + version: selected.version, + ...(selected.channel === undefined ? {} : { channel: selected.channel }), + }; + } } const remaining = index.releases.filter((record) => !removable.includes(record)); if (binding) this.assertBinding(binding); - try { - await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining })); - } catch { - throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed'); + if (removable.length > 0) { + try { + await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining })); + } catch { + throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed'); + } } const nextCurrent = { ...currentSelection.current }; - if (nextCurrent[validated] && !remaining.some((record) => ( + if (mode === 'explicit') { + delete nextCurrent[validated]; + } else if (nextCurrent[validated] && !remaining.some((record) => ( record.pluginId === validated && record.releaseId === nextCurrent[validated] ))) delete nextCurrent[validated]; await this.writeCurrentSelection({ diff --git a/shared/coding-plugins.ts b/shared/coding-plugins.ts index b7d63ac..73b5a1b 100644 --- a/shared/coding-plugins.ts +++ b/shared/coding-plugins.ts @@ -21,7 +21,7 @@ export type CodingPluginAcquisitionMode = | 'system_included' | 'user_acquired'; -export type CodingPluginExecutionMode = 'synchronous' | 'accepted'; +export type CodingPluginExecutionMode = 'synchronous' | 'job'; export interface CodingPluginPackageProvenance { readonly source: 'bundled' | 'marketplace'; diff --git a/src/stores/plugin-marketplace.ts b/src/stores/plugin-marketplace.ts index 59a4d5e..2301f61 100644 --- a/src/stores/plugin-marketplace.ts +++ b/src/stores/plugin-marketplace.ts @@ -229,6 +229,9 @@ export function createPluginMarketplaceStore( ...(state.installations[pluginId]?.version ? { version: state.installations[pluginId].version } : {}), + ...(state.installations[pluginId]?.channel + ? { channel: state.installations[pluginId].channel } + : {}), reason, }, }, diff --git a/tests/unit/coding-plugin-marketplace-client.test.ts b/tests/unit/coding-plugin-marketplace-client.test.ts index 736f8cf..4050956 100644 --- a/tests/unit/coding-plugin-marketplace-client.test.ts +++ b/tests/unit/coding-plugin-marketplace-client.test.ts @@ -740,11 +740,11 @@ describe('PluginPackageStore', () => { expect(index.releases.map(({ release_id }) => release_id)).toEqual(['release-2', 'release-1']); store.registerActiveWorker('release-1'); - await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({ - status: 'kept', pluginId: PLUGIN_ID, releaseId: 'release-1', version: '1.0.0', + await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({ + status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference', }); await expect(store.readInstalledIndex()).resolves.toHaveLength(1); - await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-1' }); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull(); }); it('persists the installed channel and client range, and fails closed after a client upgrade', async () => { @@ -899,6 +899,49 @@ describe('PluginPackageStore', () => { expect.objectContaining({ releaseId: 'release-worker' }), ]); await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull(); + + store.releaseActiveWorker('release-worker'); + await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({ + status: 'removed', pluginId: PLUGIN_ID, reason: 'none', + }); + await expect(store.readInstalledIndex()).resolves.toEqual([]); + }); + + it('makes an active current Release unavailable to new workers before deferred cleanup', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const active = signedGrant(archive, { releaseId: 'release-active' }); + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + releaseId: active.grant.releaseId, + sha256: active.grant.sha256, + sizeBytes: active.grant.sizeBytes, + })), + issueDownload: vi.fn(async () => active.grant), + downloadContent: async () => archive, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + getAccountBinding: () => ACCOUNT_A, + keyStore: new Map([['test-key', active.publicKey]]), + clientVersion: '1.0.0', + }); + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + store.registerActiveWorker('release-active'); + + await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({ + status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference', + }); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull(); + await expect(store.readInstalledIndex()).resolves.toHaveLength(1); + + store.releaseActiveWorker('release-active'); + await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({ + status: 'removed', pluginId: PLUGIN_ID, reason: 'none', + }); + await expect(store.readInstalledIndex()).resolves.toEqual([]); }); it('preserves the old release across download, signature, and extraction failures', async () => { diff --git a/tests/unit/coding-plugin-marketplace-contract.test.ts b/tests/unit/coding-plugin-marketplace-contract.test.ts index afbb4de..c3b6be2 100644 --- a/tests/unit/coding-plugin-marketplace-contract.test.ts +++ b/tests/unit/coding-plugin-marketplace-contract.test.ts @@ -121,6 +121,16 @@ describe('Marketplace Release A package contract', () => { expect(Object.isFrozen(definition.tools[0]?.outputSchema)).toBe(true); }); + it('accepts only the canonical asynchronous job execution mode', () => { + const job = structuredClone(HOSTED_CAPABILITY) as Record; + (job.tools as Array>)[0]!.executionMode = 'job'; + expect(parse(job).tools[0]?.executionMode).toBe('job'); + + const accepted = structuredClone(HOSTED_CAPABILITY) as Record; + (accepted.tools as Array>)[0]!.executionMode = 'accepted'; + expect(() => parse(accepted)).toThrow(CodingPluginManifestError); + }); + it('requires the exact capability manifest path even for direct parsing', () => { const root = structuredClone(ROOT) as Record; const extensions = root.extensions as Record; diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx index fec96b3..ed50dbb 100644 --- a/tests/unit/plugin-marketplace-pages.test.tsx +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -130,6 +130,31 @@ describe('My Plugins', () => { expect(screen.getByText('当前频道:Beta')).toBeVisible(); }); + it('keeps a failed Beta update on the explicit Beta route', () => { + const onUpdate = vi.fn(); + const onInstallBeta = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: '更新 Beta灵感笔记' })); + expect(onInstallBeta).toHaveBeenCalledWith('makelore.notes'); + expect(onUpdate).not.toHaveBeenCalled(); + expect(screen.getByText('当前频道:Beta')).toBeVisible(); + }); + it('does not silently fall back to stable when the installed Beta channel has no release', () => { render( { reason: 'plugin_signature_invalid: Release signature is invalid', }); }); + + it('preserves the installed Beta channel when a device update fails', async () => { + const error = Object.assign(new Error('Release is temporarily unavailable'), { + code: 'plugin_release_unavailable', + }); + const store = createPluginMarketplaceStore({ + installBeta: vi.fn().mockResolvedValue({ + status: 'installed', + pluginId: 'makelore.notes', + releaseId: 'release-beta-1', + version: '2.0.0-beta.1', + channel: 'beta', + }), + update: vi.fn().mockRejectedValue(error), + }); + store.getState().activateAccount('account-a'); + await store.getState().installBeta('makelore.notes'); + + await expect(store.getState().update('makelore.notes')).rejects.toBe(error); + expect(store.getState().installations['makelore.notes']).toMatchObject({ + status: 'unavailable', + pluginId: 'makelore.notes', + releaseId: 'release-beta-1', + version: '2.0.0-beta.1', + channel: 'beta', + reason: 'plugin_release_unavailable: Release is temporarily unavailable', + }); + }); });