diff --git a/.project-docs/30-worklog/tasks/20260828-marketplace-mlm06-r2-remediation-6f4a2d91.md b/.project-docs/30-worklog/tasks/20260828-marketplace-mlm06-r2-remediation-6f4a2d91.md new file mode 100644 index 0000000..a6acb06 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260828-marketplace-mlm06-r2-remediation-6f4a2d91.md @@ -0,0 +1,186 @@ +# Task: MLM-06 R2 fixed-range remediation + +## Identity + +- Task ID: 20260828-marketplace-mlm06-r2-remediation-6f4a2d91 +- Mode: Feature +- Branch: codex/20260828-marketplace-mlm06-r2-remediation-6f4a2d91-marketplace-mlm06-r2-remediation-6f4a2d91 +- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r2-remediation-6f4a2d91 +- Base commit: 2c3baf6dff975ce229d80a35450c5e79e271651b +- Owner: marketplace-mlm06-r2-remediator +- Status: Ready for Integration + +## Scope + +- Sole fixed-range MLM-06 R2 remediation over the eight accepted actionable roots from + the exact Marketplace Release A client frontier. Product changes stay in the isolated + MakeLore worktree and are limited to the existing Main/Renderer Package Store, + resolver/resource, account-cache, artifact-proof, and shared Marketplace E2E seams. +- Preserve Release A only: bundled Data Service remains system-included with the exact + typed 14-operation/10-tool contract and zero Token Point transactions; no hosted + Provider, Plugin Charges/Credits, arbitrary local code/MCP/hooks, or XMA work. +- Deliver one source commit with this task record, with sole parent + `2c3baf6dff975ce229d80a35450c5e79e271651b` and a clean READY_FOR_INTEGRATION handoff. + +## Intent And Constraints + +- Concurrent Task Gate passed: `check_project_docs.py` succeeded; task_context owns this + isolated worktree/task ID and `status --json` matches the exact owner, feature mode, + absolute worktree, branch, and base. The coordinator worktree and user root `main` + remain read-only; the server repository is outside this task. +- Planning Gate passed after reading the required project-memory startup set, this task + record, relevant coordinator/MLM-03 peer records, MakeLore AGENTS, the implementation + spec, ticket graph, accepted detailed design, implement-spec, TDD, and UI references. + The R2 standards peer record was not present in its registered worktree and is recorded + as unknown coordination state; the parent handoff supplies the accepted eight roots and + no unresolved semantic conflict or ownership overlap blocks implementation. +- Test-first vertical slices are required at the public seams. No broad compatibility + layer, prefixing, hidden chaining, account/library conflation, yanked claim, or runtime + trust override may be added. Explicit device uninstall remains separate from Library + removal; rollback selects a persisted channel current explicitly; orphan recovery only + handles the exact plugin/release pair whose package verifies completely. +- Production official Ed25519 public-key activation remains HOLD. Tests may inject + ephemeral keys; no production private key or arbitrary runtime key source may be read, + generated, or committed. + +## Fixed-Range Ownership / Red Plan + +1. Registry/resource-loader: reject a Marketplace Skill ID that collides with a core + Skill or another accepted package source while retaining package configuration; keep + each effective Skill paired with its verified package root and cover core plus two- + package collisions. +2. Renderer Marketplace store: make cross-plugin and cross-domain mutations intent-safe + under the existing state model, preserving legitimate duplicate/coalesced requests; + cover both completion orders and avoid hidden chaining. +3. Package Store: make uninstall/removeUnused remove only stale releases; return `kept` + for current releases protected by worker/cache/account, and `removed` only when no + current installed projection remains; cover mixed protection and Renderer projection. +4. Packaged trust verifier: bind proof from real `app.asar` `package.json.main` to the + reachable code-owned trust implementation, reject stale/unreachable markers, and + retain no private key/runtime override or checkout-authority read. +5. Shared Marketplace E2E: add explicit beta, disabled/unavailable, and device-delete + journeys using the existing fixture; do not run XMA. +6. Package Store compatibility: persist verified MakeLore min/max release ranges and + re-check them on reload, installed/effective projection, and next-worker resolution; + retain package/config while fail-closing incompatible projection with bounded reason. +7. Channel/risk projection: persist installed channel, distinguish explicit beta from + stable, project same-channel mismatch/unavailable risk through existing bounded server + authority, and expose explicit same-channel update without claiming unsupported yanked + fields or silently switching channel. +8. Main AccountPluginCache: protect same-account reads and mutations with + generation/intent ordering so old GET/fallback results cannot overwrite newer intent; + retain account A/B isolation and reasonable coalescing. + +Each item follows red test → minimal implementation → focused regression, then adjacent +regression before the next seam. Ownership is limited to the listed modules/tests and +the shared E2E fixture/spec where required by root 5. + +## Project Context Loaded + +- Exact source base is `2c3baf6dff975ce229d80a35450c5e79e271651b`; it is clean and is the + only permitted parent. Existing Marketplace Release A work is inherited from the + coordinator frontier; this task does not reuse the prior R1 task/worktree. +- Canonical client contract requires distinct Operations publication, Account Library, + Device Installation, Project Selection, Agent assignment, runtime authorization, and + billing states. Stable updates may be automatic; beta requires explicit user action; + current workers remain frozen. The effective resolver is the single consumer snapshot. +- Relevant peer task records confirm MLM-03 owns the Main resolver/Pi/lifecycle seam and + the coordinator owns integration/package evidence; this task adopts only the explicitly + handed-off fixed-range remediation roots above, without reverting peer changes. + +## Outcome + +- Completed the sole MLM-06 R2 fixed-range remediation over all eight accepted roots: + Marketplace Skill IDs now reject core/accepted-source collisions while retaining the + configured package; effective snapshots keep Skills paired with their verified package + roots; Package Store uninstall removes only stale releases and returns `kept` for a + remaining current/protected projection; verified MakeLore min/max ranges and the + installed channel persist across reload/effective/next-worker projections; incompatible + installations fail closed with a bounded reason; account cache and Renderer Marketplace + mutations are intent-aware; and the packaged trust proof follows the real app.asar + `package.json.main` reachability graph without checkout authority, private-key material, + or runtime key overrides. +- My Plugins exposes explicit Beta-channel actions, same-channel update/risk copy, + bounded unavailable/suspended states, and device-package deletion separately from + Library removal. The shared Electron E2E uses the existing fixture and verifies the + full account-to-device journey. Existing README Marketplace/Package Store/effective + runtime documentation already states the official-key activation HOLD, so no unrelated + documentation path was expanded. + +### Before → After + +- Marketplace package definitions could shadow core or another accepted raw Skill ID; + the first accepted owner now wins and the later conflicting package contributes no + effective resources, without inventing a prefix. +- `removeUnused` could retain/guess a current release by installation time or report a + removed projection prematurely; explicit current selection and worker/cache/account + protection now determine `kept`, while only a release-less projection returns `removed`. +- Installed records lacked channel/client-range facts and could materialize on an + incompatible client; reload and next-worker resolution now preserve the record while + projecting `client_incompatible` fail-closed. +- Artifact verification could read checkout trust code or accept unreachable markers; + it now follows the packaged Main entry and decodes Windows/Uint8Array asar entries + through the native archive path. +- Older async Library/read and mutation completions could overwrite newer same-account + intent; request-start epochs now prevent stale snapshots while preserving valid + coalescing and account isolation. + +## Verification + +- RED → GREEN ledger: + - Initial Package Store/resolver slice: 26 focused tests with 3 expected failures; + the corresponding collision, uninstall, compatibility, and channel tests are green + in the final focused/adjacent runs. + - First six-file focused run: 68 passed / 1 failed because the packaged `package.json` + extraction path was unreadable. The leading-slash attempt exposed the actual + `Uint8Array.toString()` decode issue: 66 passed / 3 failed. `Buffer.from` decoding + with normalized entries finished at 69 passed / 0 failed. + - Real x64 app.asar initially rejected the nested trust source because Windows + `extractFile` required native separators; the native-separator helper finished the + real verifier at pass. + - Initial Marketplace E2E was 1 passed / 1 failed: the new test's old-page + localStorage setup was overwritten during auth initialization and only emitted two + session-sync calls. The auth snapshot is now registered with `addInitScript` before + the new document, the auth→Library request poll is retained, and the target file is + 2 passed / 0 failed. + - Typecheck first reported one literal-union inference error at + `src/lib/plugin-marketplace.ts:197`; the `as const` value tuple fixed it without a + cast around the parser. Final typecheck passed. +- Focused: `corepack pnpm exec vitest run tests/unit/coding-plugin-effective-resolver.test.ts + tests/unit/coding-plugin-marketplace-client.test.ts tests/unit/coding-product-services.test.ts + tests/unit/pi-product-artifact.test.ts tests/unit/plugin-marketplace-pages.test.tsx + tests/unit/plugin-marketplace-store.test.ts --maxWorkers=1` — 6 files / 69 tests + passed. +- Adjacent Marketplace/Pi suite — 6 files / 39 tests passed. Full unit single-worker — + 208 files / 1,789 tests passed / 2 skipped. Pressure — 1 / 1 passed. +- `corepack pnpm run typecheck` passed. `corepack pnpm run lint:check` passed with + 0 errors and the five pre-existing warnings. `corepack pnpm run build:vite` passed + (Renderer 2,261 modules; Main 193; Preload 1; utility 9; existing chunk warnings). +- Windows Electron verification passed 6 / 6. `corepack pnpm run package:stage:win-x64` + passed; `node scripts/run-electron-builder.mjs --win --dir --publish never` passed. + The builder log only reports the known optional Darwin/Linux packages and the + stage's absent resources bin/python as non-x64-stage inputs; no full cross-platform + package claim is made. +- Artifact unit suite passed 12 / 12. On the fresh + `release/win-unpacked/Makelore.exe`, + `node scripts/verify-pi-product-artifact.mjs --app-exe release/win-unpacked/Makelore.exe + --samples 1 --timeout-ms 10000` exited 0. Marketplace trust is + `official-key-absent-fail-closed`; `privateKeyMaterialInTrustSource` is false, and + the trust source was read from reachable packaged app.asar code. +- `git diff --check` passed. No XMA-01/live acceptance, push, PR, deployment, or + publication was run. The official Ed25519 production public-key activation remains + an explicit HOLD; tests use only injected/fixture trust evidence. Inherited Pi + runtime partial-pass waivers remain accurately unclaimed for real-provider and + deferred platform evidence. + +## Follow-ups + +- Provide the official production Ed25519 public key through the deployment-controlled + activation process before production trust can be enabled; do not add it to this + source commit. Coordinator still owns XMA-01 real PostgreSQL + Operations + packaged + signed-in acceptance and the deferred Release B/provider tickets remain closed. + +## Promotion Candidates + +- One source commit from this worktree only, with sole parent + `2c3baf6dff975ce229d80a35450c5e79e271651b` and no push/PR publication. diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index f67bc84..ea0a418 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -85,6 +85,7 @@ export type PublicPluginInstallation = Readonly<{ pluginId: string; releaseId?: string; version?: string; + channel?: 'stable' | 'beta'; reason?: string; }>; @@ -122,6 +123,7 @@ function publicInstallation(snapshot: InstallationSnapshot): PublicPluginInstall pluginId: snapshot.pluginId, ...(snapshot.releaseId ? { releaseId: snapshot.releaseId } : {}), ...(snapshot.version ? { version: snapshot.version } : {}), + ...(snapshot.channel ? { channel: snapshot.channel } : {}), ...(snapshot.reason ? { reason: snapshot.reason } : {}), }; } @@ -134,10 +136,12 @@ async function publicLibrary( try { const record = await packageStore.getInstalled(pluginId); return record ? { - status: 'installed' as const, + status: record.unavailableReason ? 'unavailable' as const : 'installed' as const, pluginId: record.pluginId, releaseId: record.releaseId, version: record.version, + ...(record.channel === undefined ? {} : { channel: record.channel }), + ...(record.unavailableReason ? { reason: record.unavailableReason } : {}), } : null; } catch (error) { const candidate = error && typeof error === 'object' && 'code' in error diff --git a/electron/coding-plugins/account-plugin-cache.ts b/electron/coding-plugins/account-plugin-cache.ts index b2cfbe1..a68636f 100644 --- a/electron/coding-plugins/account-plugin-cache.ts +++ b/electron/coding-plugins/account-plugin-cache.ts @@ -92,14 +92,15 @@ export class AccountPluginCache { library: MarketplaceLibrarySnapshot | null; resolves: Map; }>(); + private readonly libraryIntents = new Map(); - getLibrary(binding: AccountBinding): MarketplaceLibrarySnapshot | null { - const record = this.records.get(bindingId(binding)); - return record?.library ? clone(record.library) : null; + private nextLibraryIntent(key: string): number { + const next = (this.libraryIntents.get(key) ?? 0) + 1; + this.libraryIntents.set(key, next); + return next; } - setLibrary(binding: AccountBinding, snapshot: MarketplaceLibrarySnapshot): void { - const key = bindingId(binding); + private setLibrarySnapshot(key: string, binding: AccountBinding, snapshot: MarketplaceLibrarySnapshot): void { const record = this.records.get(key) ?? { binding: { accountKey: binding.accountKey, epoch: binding.epoch }, library: null, @@ -109,8 +110,37 @@ export class AccountPluginCache { this.records.set(key, record); } - markLibraryStale(binding: AccountBinding): MarketplaceLibrarySnapshot | null { + getLibrary(binding: AccountBinding): MarketplaceLibrarySnapshot | null { + const record = this.records.get(bindingId(binding)); + return record?.library ? clone(record.library) : null; + } + + setLibrary(binding: AccountBinding, snapshot: MarketplaceLibrarySnapshot): void { const key = bindingId(binding); + this.nextLibraryIntent(key); + this.setLibrarySnapshot(key, binding, snapshot); + } + + /** Reserve the commit slot before an async Library read or mutation starts. */ + beginLibraryIntent(binding: AccountBinding): number { + return this.nextLibraryIntent(bindingId(binding)); + } + + /** Commit only the latest read/mutation intent for this account binding. */ + commitLibrary( + binding: AccountBinding, + intent: number, + snapshot: MarketplaceLibrarySnapshot, + ): boolean { + const key = bindingId(binding); + if (this.libraryIntents.get(key) !== intent) return false; + this.setLibrarySnapshot(key, binding, snapshot); + return true; + } + + markLibraryStale(binding: AccountBinding, intent?: number): MarketplaceLibrarySnapshot | null { + const key = bindingId(binding); + if (intent !== undefined && this.libraryIntents.get(key) !== intent) return null; const record = this.records.get(key); if (!record?.library) return null; record.library = { ...record.library, stale: true }; @@ -193,12 +223,16 @@ export class AccountPluginCache { clearAccount(binding: AccountBinding): void { assertBinding(binding); for (const [key, record] of this.records.entries()) { - if (record.binding.accountKey === binding.accountKey) this.records.delete(key); + if (record.binding.accountKey === binding.accountKey) { + this.records.delete(key); + this.libraryIntents.delete(key); + } } } invalidateAll(): void { this.records.clear(); + this.libraryIntents.clear(); } clear(): void { diff --git a/electron/coding-plugins/effective-resolver.ts b/electron/coding-plugins/effective-resolver.ts index f1cd94f..37c96cd 100644 --- a/electron/coding-plugins/effective-resolver.ts +++ b/electron/coding-plugins/effective-resolver.ts @@ -43,6 +43,7 @@ export type PluginUnavailableReasonCode = | 'library_unavailable' | 'release_not_installed' | 'release_invalid' + | 'client_incompatible' | 'project_disabled' | 'skill_unassigned' | 'runtime_suspended' @@ -119,6 +120,7 @@ export interface EffectivePluginSkillSource { interface DefinitionRecord { readonly definition: CodingPluginDefinition; readonly installed: boolean; + readonly unavailableReason?: PluginUnavailableReasonCode; } const EMPTY_POLICY_STATE: PluginPolicyClientState = { @@ -210,6 +212,26 @@ function validateBinding(value: AccountBinding | null): AccountBinding | null { return { accountKey: value.accountKey, epoch: value.epoch }; } +/** + * A raw Skill ID is the public assignment key. Marketplace packages cannot + * shadow a core Skill or an already accepted package owner; the project + * assignment is retained, but the later package contributes no resources. + */ +function marketplaceSkillConflicts(records: readonly DefinitionRecord[]): ReadonlySet { + const blocked = new Set(); + const owned = new Set(CORE_CODING_SKILL_IDS); + for (const { definition } of records) { + if (definition.provenance.source !== 'marketplace') continue; + const conflicts = definition.skills.some(({ id }) => owned.has(id)); + if (conflicts) { + blocked.add(definition.id); + continue; + } + for (const { id } of definition.skills) owned.add(id); + } + return blocked; +} + /** * Resolve a worker snapshot from separated Marketplace, Package Store, * project, assignment, and policy state. This module deliberately performs @@ -222,6 +244,7 @@ export class EffectivePluginResolver { async resolve(input: EffectivePluginResolverInput): Promise { const definitions = await this.definitionRecords(); + const blockedMarketplacePlugins = marketplaceSkillConflicts(definitions); const assigned = normalizeIds(input.assignedSkillIds); const coreIds = new Set(CORE_CODING_SKILL_IDS); @@ -273,7 +296,8 @@ export class EffectivePluginResolver { policyState = this.options.policyClient.getState(); } - for (const { definition, installed } of definitions) { + for (const { definition, installed, unavailableReason } of definitions) { + if (blockedMarketplacePlugins.has(definition.id)) continue; 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')); @@ -284,6 +308,16 @@ export class EffectivePluginResolver { unavailableReasons.push(unavailable(definition.id, 'release_not_installed', 'Plugin Release is not installed')); continue; } + if (unavailableReason) { + unavailableReasons.push(unavailable( + definition.id, + unavailableReason, + unavailableReason === 'client_incompatible' + ? 'Plugin Release is incompatible with this MakeLore client' + : 'Plugin Release is unavailable', + )); + continue; + } if (!enabled.has(definition.id)) { unavailableReasons.push(unavailable(definition.id, 'project_disabled', 'Plugin is not enabled for this project')); continue; @@ -386,12 +420,14 @@ export class EffectivePluginResolver { async getSkillSources(): Promise { const definitions = await this.definitionRecords(); + const blockedMarketplacePlugins = marketplaceSkillConflicts(definitions); // 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' + return Object.freeze(definitions.flatMap(({ definition, installed, unavailableReason }) => ( + !installed || unavailableReason || blockedMarketplacePlugins.has(definition.id) + || definition.acquisitionMode !== 'user_acquired' ? [] : definition.skills.map((skill) => ({ id: skill.id, @@ -427,19 +463,38 @@ export class EffectivePluginResolver { 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)); + let unavailableReason: PluginUnavailableReasonCode | undefined; + if (installedIds.has(definition.id)) { + const installedRelease = await this.options.packageStore?.getInstalled(definition.id); + installed = Boolean(installedRelease); + if (installedRelease?.unavailableReason === 'plugin_incompatible_client') { + unavailableReason = 'client_incompatible'; + } + } + if (this.options.getInstalled) { + const installedRelease = await this.options.getInstalled(definition.id); + installed = Boolean(installedRelease); + if (installedRelease?.unavailableReason === 'plugin_incompatible_client') { + unavailableReason = 'client_incompatible'; + } + } if (!this.options.packageStore && !this.options.getInstalled && !(this.options.installedDefinitions ?? []).some(({ id }) => id === definition.id)) { installed = false; } - records.set(definition.id, { definition, installed }); + records.set(definition.id, { definition, installed, unavailableReason }); } 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 }); + if (installed) records.set(pluginId, { + definition: installed.definition, + installed: true, + ...(installed.unavailableReason === 'plugin_incompatible_client' + ? { unavailableReason: 'client_incompatible' as const } + : {}), + }); } } return [...records.values()]; diff --git a/electron/coding-plugins/marketplace-client.ts b/electron/coding-plugins/marketplace-client.ts index f9a74c7..825cb98 100644 --- a/electron/coding-plugins/marketplace-client.ts +++ b/electron/coding-plugins/marketplace-client.ts @@ -924,6 +924,14 @@ class MarketplaceClientImpl implements MarketplaceClient { async readLibrary(): Promise { const binding = this.requireBinding(); + const intent = this.accountCache.beginLibraryIntent(binding); + return this.readLibraryForIntent(binding, intent); + } + + private async readLibraryForIntent( + binding: AccountBinding, + intent: number, + ): Promise { const previous = this.accountCache.getLibrary(binding); try { const result = await this.requestJson('/api/plugin-marketplace/v1/library', { auth: 'required' }, parseLibrary); @@ -934,14 +942,16 @@ class MarketplaceClientImpl implements MarketplaceClient { fetchedAt: this.now(), }; this.assertBinding(binding); - this.accountCache.setLibrary(binding, snapshot); + if (!this.accountCache.commitLibrary(binding, intent, snapshot)) { + return clone(this.accountCache.getLibrary(binding) ?? snapshot); + } return clone(snapshot); } catch (error) { if (!this.bindingMatches(binding)) { fail('marketplace_account_changed', 'Marketplace account changed while the request was active'); } if (previous && canServeStale(error)) { - const stale = this.accountCache.markLibraryStale(binding); + const stale = this.accountCache.markLibraryStale(binding, intent); if (stale) return clone(stale); } throw normalizeError(error); @@ -1073,6 +1083,8 @@ class MarketplaceClientImpl implements MarketplaceClient { private async mutateLibrary(method: 'PUT' | 'DELETE', pluginId: string): Promise { const validated = idValue(pluginId, 'pluginId'); const binding = this.requireBinding(); + const intent = this.accountCache.beginLibraryIntent(binding); + const previous = this.accountCache.getLibrary(binding); const result = await this.requestJson( `/api/plugin-marketplace/v1/library/${encodeURIComponent(validated)}`, { auth: 'required', method }, @@ -1081,13 +1093,12 @@ class MarketplaceClientImpl implements MarketplaceClient { const entry = result.value!; this.assertBinding(binding); try { - return await this.readLibrary(); + return await this.readLibraryForIntent(binding, intent); } catch (error) { if (!this.bindingMatches(binding)) { fail('marketplace_account_changed', 'Marketplace account changed while the request was active'); } if (!canServeStale(error)) throw normalizeError(error); - const previous = this.accountCache.getLibrary(binding); if (previous) { const items = [entry, ...previous.items.filter((item) => item.pluginId !== entry.pluginId)]; const snapshot: MarketplaceLibrarySnapshot = { @@ -1096,8 +1107,8 @@ class MarketplaceClientImpl implements MarketplaceClient { stale: true, fetchedAt: previous.fetchedAt, }; - this.accountCache.setLibrary(binding, snapshot); - return clone(snapshot); + if (this.accountCache.commitLibrary(binding, intent, snapshot)) return clone(snapshot); + return clone(this.accountCache.getLibrary(binding) ?? snapshot); } throw normalizeError(error); } diff --git a/electron/coding-plugins/package-store.ts b/electron/coding-plugins/package-store.ts index ba79cbe..8e34afd 100644 --- a/electron/coding-plugins/package-store.ts +++ b/electron/coding-plugins/package-store.ts @@ -20,6 +20,7 @@ import { import type { CodingPluginDefinition } from '../../shared/coding-plugins'; import { buildPluginReleaseDescriptor, + compareSemVer, isMakeLoreVersionCompatible, isValidSemVer, type PluginReleaseDescriptor, @@ -70,6 +71,9 @@ const INDEX_RELEASE_KEYS = new Set([ 'sha256', 'size_bytes', 'installed_at', + 'channel', + 'min_makelore_version', + 'max_makelore_version', ]); const CURRENT_SELECTION_ROOT_KEYS = new Set(['schema_version', 'current']); @@ -88,11 +92,16 @@ export interface InstalledReleaseRecord { readonly sha256: string; readonly sizeBytes: number; readonly installedAt: string; + /** Channel and verified client range are immutable facts of this install. */ + readonly channel?: 'stable' | 'beta'; + readonly minMakeloreVersion?: string; + readonly maxMakeloreVersion?: string | null; } export interface InstalledRelease extends InstalledReleaseRecord { readonly packageRoot: string; readonly definition: CodingPluginDefinition; + readonly unavailableReason?: 'plugin_incompatible_client'; } export type InstallationStatus = 'installed' | 'kept' | 'removed' | 'unavailable'; @@ -102,6 +111,8 @@ export interface InstallationSnapshot { readonly pluginId: string; readonly releaseId?: string; readonly version?: string; + /** The immutable channel selected for this device package. */ + readonly channel?: 'stable' | 'beta'; readonly packageRoot?: string; readonly definition?: CodingPluginDefinition; readonly reason?: string; @@ -306,6 +317,20 @@ function parseIndexDocument(value: unknown): IndexDocument { } const installedAt = boundedText(record.installed_at, `release ${index}.installed_at`, 80); if (!Number.isFinite(Date.parse(installedAt))) fail('plugin_store_index_invalid', `invalid release ${index}.installed_at`); + const minMakeloreVersion = record.min_makelore_version === undefined + ? undefined + : validVersion(record.min_makelore_version, `release ${index}.min_makelore_version`); + const maxMakeloreVersion = record.max_makelore_version === undefined || record.max_makelore_version === null + ? record.max_makelore_version as string | null | undefined + : validVersion(record.max_makelore_version, `release ${index}.max_makelore_version`); + if (minMakeloreVersion && maxMakeloreVersion + && compareSemVer(maxMakeloreVersion, minMakeloreVersion) < 0) { + fail('plugin_store_index_invalid', `invalid release ${index}.max_makelore_version`); + } + const channel = record.channel === undefined ? undefined : record.channel; + if (channel !== undefined && channel !== 'stable' && channel !== 'beta') { + fail('plugin_store_index_invalid', `invalid release ${index}.channel`); + } return Object.freeze({ pluginId, releaseId, @@ -316,6 +341,9 @@ function parseIndexDocument(value: unknown): IndexDocument { sha256: validSha(record.sha256, `release ${index}.sha256`, 'plugin_store_index_invalid'), sizeBytes: positiveInteger(record.size_bytes, `release ${index}.size_bytes`, DEFAULT_MAX_ARCHIVE_BYTES), installedAt, + ...(channel === undefined ? {} : { channel }), + ...(minMakeloreVersion === undefined ? {} : { minMakeloreVersion }), + ...(maxMakeloreVersion === undefined ? {} : { maxMakeloreVersion }), }); }); return Object.freeze({ schema_version: INDEX_SCHEMA_VERSION, releases: Object.freeze(releases) }); @@ -334,6 +362,9 @@ function serializeIndex(document: IndexDocument): Uint8Array { sha256: record.sha256, size_bytes: record.sizeBytes, installed_at: record.installedAt, + ...(record.channel === undefined ? {} : { channel: record.channel }), + ...(record.minMakeloreVersion === undefined ? {} : { min_makelore_version: record.minMakeloreVersion }), + ...(record.maxMakeloreVersion === undefined ? {} : { max_makelore_version: record.maxMakeloreVersion }), })), })}\n`, 'utf8'); } @@ -687,6 +718,7 @@ export class PluginPackageStore { pluginId, releaseId: current.releaseId, version: current.version, + ...(current.channel === undefined ? {} : { channel: current.channel }), packageRoot: current.packageRoot, definition: current.definition, }; @@ -705,7 +737,7 @@ export class PluginPackageStore { const existingRecord = index.releases.find((record) => record.pluginId === pluginId && record.releaseId === item.releaseId); if (existingRecord && existingRecord.sha256 === item.sha256 && await isDirectoryPath(this.releaseDirectory(existingRecord))) { const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId); - if (existing) { + if (existing && !existing.unavailableReason) { await this.setCurrentSelection( await this.readCurrentSelection(), pluginId, @@ -716,6 +748,7 @@ export class PluginPackageStore { pluginId, releaseId: existing.releaseId, version: existing.version, + ...(existing.channel === undefined ? {} : { channel: existing.channel }), packageRoot: existing.packageRoot, definition: existing.definition, }; @@ -738,6 +771,7 @@ export class PluginPackageStore { packageRoot, pluginId, grant, + channel, binding, }); } @@ -781,7 +815,7 @@ export class PluginPackageStore { await writeFile(path.join(extractedPath, ORPHAN_ARCHIVE_FILE), toBuffer(artifact), { flag: 'wx' }); await rename(extractedPath, packageRoot); moved = true; - const record = this.installedRecord(grant); + const record = this.installedRecord(grant, channel); const records = index.releases.filter((candidate) => !(candidate.pluginId === pluginId && candidate.releaseId === grant.releaseId)); try { await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: [...records, record] })); @@ -794,6 +828,7 @@ export class PluginPackageStore { pluginId, releaseId: record.releaseId, version: record.version, + channel: record.channel, packageRoot, definition, }; @@ -817,6 +852,7 @@ export class PluginPackageStore { readonly packageRoot: string; readonly pluginId: string; readonly grant: DownloadGrant; + readonly channel: 'stable' | 'beta'; readonly binding: AccountBinding; }): Promise { const existingRecord = input.index.releases.find((record) => ( @@ -832,7 +868,7 @@ export class PluginPackageStore { const descriptor = this.verifyArtifact(artifact, input.grant); const definition = await this.loadDefinition(input.packageRoot, input.grant, descriptor); this.assertBinding(input.binding); - const record = this.installedRecord(input.grant); + const record = this.installedRecord(input.grant, input.channel); try { await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, @@ -847,6 +883,7 @@ export class PluginPackageStore { pluginId: input.pluginId, releaseId: record.releaseId, version: record.version, + channel: record.channel, packageRoot: input.packageRoot, definition, }; @@ -879,7 +916,7 @@ export class PluginPackageStore { return descriptor; } - private installedRecord(grant: DownloadGrant): InstalledReleaseRecord { + private installedRecord(grant: DownloadGrant, channel: 'stable' | 'beta'): InstalledReleaseRecord { return Object.freeze({ pluginId: grant.pluginId, releaseId: grant.releaseId, @@ -890,6 +927,9 @@ export class PluginPackageStore { sha256: grant.sha256, sizeBytes: grant.sizeBytes, installedAt: new Date(this.now()).toISOString(), + channel, + minMakeloreVersion: grant.minMakeloreVersion, + maxMakeloreVersion: grant.maxMakeloreVersion, }); } @@ -908,22 +948,23 @@ export class PluginPackageStore { } return { status: 'removed', pluginId: validated, reason: 'none' }; } - const latest = records - .map((record, index) => ({ record, index })) - .sort((left, right) => right.record.installedAt.localeCompare(left.record.installedAt) || right.index - left.index)[0] - ?.record; - if (!latest) return { status: 'removed', pluginId: validated, reason: 'none' }; const selected = currentSelection.current[validated] ? records.find((record) => record.releaseId === currentSelection.current[validated]) : undefined; - const reported = selected ?? latest; const protectedIds = new Set([ ...this.accountCache.referencedReleaseIds(), ...(this.activeWorkerReleaseIds() ?? []), ...this.activeWorkers, ]); - const removable = records.filter((record) => !protectedIds.has(record.releaseId)); - if (removable.length === 0) return { status: 'kept', pluginId: validated, releaseId: reported.releaseId, version: reported.version }; + const removable = selected && records.length > 1 + ? records.filter((record) => record.releaseId !== selected.releaseId && !protectedIds.has(record.releaseId)) + : records.filter((record) => !protectedIds.has(record.releaseId)); + if (removable.length === 0) { + const reported = selected ?? records.find((record) => protectedIds.has(record.releaseId)); + return reported + ? { status: 'kept', pluginId: validated, releaseId: reported.releaseId, version: reported.version } + : { status: 'kept', pluginId: validated, reason: 'current_selection_missing' }; + } const remaining = index.releases.filter((record) => !removable.includes(record)); try { await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining })); @@ -933,15 +974,7 @@ export class PluginPackageStore { const nextCurrent = { ...currentSelection.current }; if (nextCurrent[validated] && !remaining.some((record) => ( record.pluginId === validated && record.releaseId === nextCurrent[validated] - ))) { - const fallback = remaining - .filter((record) => record.pluginId === validated) - .map((record, index) => ({ record, index })) - .sort((left, right) => right.record.installedAt.localeCompare(left.record.installedAt) || right.index - left.index)[0] - ?.record; - if (fallback) nextCurrent[validated] = fallback.releaseId; - else delete nextCurrent[validated]; - } + ))) delete nextCurrent[validated]; await this.writeCurrentSelection({ schema_version: CURRENT_SELECTION_SCHEMA_VERSION, current: Object.freeze(nextCurrent), @@ -952,8 +985,24 @@ export class PluginPackageStore { const currentAfter = nextCurrent[validated] ? remaining.find((record) => record.pluginId === validated && record.releaseId === nextCurrent[validated]) : undefined; - const reportedAfter = currentAfter ?? latest; - return { status: 'removed', pluginId: validated, releaseId: reportedAfter.releaseId, version: reportedAfter.version }; + const selectedAfter = selected && remaining.some((record) => ( + record.pluginId === validated && record.releaseId === selected.releaseId + )) ? selected : undefined; + const reportedAfter = currentAfter ?? selectedAfter ?? remaining.find((record) => record.pluginId === validated); + if (reportedAfter) { + return { + status: 'kept', + pluginId: validated, + releaseId: reportedAfter.releaseId, + version: reportedAfter.version, + ...(reportedAfter.channel === undefined ? {} : { channel: reportedAfter.channel }), + }; + } + return { + status: 'removed', + pluginId: validated, + ...(selected ? { releaseId: selected.releaseId, version: selected.version } : { reason: 'none' }), + }; } private buildDescriptor(grant: DownloadGrant): PluginReleaseDescriptor { @@ -1073,9 +1122,11 @@ export class PluginPackageStore { .filter(({ record }) => record.pluginId === pluginId && (releaseId === undefined || record.releaseId === releaseId)) .sort((left, right) => right.record.installedAt.localeCompare(left.record.installedAt) || right.index - left.index) .map(({ record }) => record); - const selected = pointedReleaseId === undefined + const selected = releaseId !== undefined ? records - : records.filter((record) => record.releaseId === pointedReleaseId); + : pointedReleaseId === undefined + ? [] + : records.filter((record) => record.releaseId === pointedReleaseId); for (const record of selected) { const packageRoot = this.releaseDirectory(record); if (!await isDirectoryPath(packageRoot)) continue; @@ -1090,7 +1141,18 @@ export class PluginPackageStore { || definition.contractVersion !== record.contractVersion || definition.runtimeKind !== record.runtimeKind) { throw new PluginPackageStoreError('plugin_manifest_invalid', 'installed package does not match its immutable index record'); } - return { ...record, packageRoot, definition }; + const compatible = record.minMakeloreVersion !== undefined + && isMakeLoreVersionCompatible( + this.clientVersion, + record.minMakeloreVersion, + record.maxMakeloreVersion ?? null, + ); + return { + ...record, + packageRoot, + definition, + ...(compatible ? {} : { unavailableReason: 'plugin_incompatible_client' as const }), + }; } catch (error) { if (error instanceof PluginPackageStoreError) throw error; throw new PluginPackageStoreError('plugin_manifest_invalid', 'installed package manifest is invalid'); diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts index c7e0fe4..7e482ea 100644 --- a/electron/coding-plugins/registry.ts +++ b/electron/coding-plugins/registry.ts @@ -17,6 +17,7 @@ import type { import type { EffectivePluginResolver, EffectivePluginSnapshot, + EffectivePluginSkillSource, } from './effective-resolver'; const MAX_REQUEST_ID = 128; @@ -391,21 +392,30 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor const effectiveSkills = new Set(snapshot.effectiveSkillIds); const pluginIds = new Set(snapshot.runtimePolicies.map(({ pluginId }) => pluginId)); const skillRoots = new Set(); + const sourcesBySkillId = new Map(); for (const source of sources) { - if (effectiveSkills.has(source.id)) { - pluginIds.add(source.pluginId); - skillRoots.add(source.packageRoot); + if (effectiveSkills.has(source.id) && !sourcesBySkillId.has(source.id)) { + sourcesBySkillId.set(source.id, source); } } + const skillEntries = snapshot.skillEntries.map(({ id, entryPath, packageRoot }) => { + const source = sourcesBySkillId.get(id); + const pairedRoot = packageRoot ?? source?.packageRoot; + if (source && pairedRoot) { + pluginIds.add(source.pluginId); + skillRoots.add(pairedRoot); + } + return { + id, + entryPath, + ...(pairedRoot ? { packageRoot: pairedRoot } : {}), + }; + }); return { catalogRevision: this.options.effectiveResolver.getPolicyState().revision, pluginIds: [...pluginIds], effectiveSkillIds: [...snapshot.effectiveSkillIds], - skillEntries: snapshot.skillEntries.map(({ id, entryPath, packageRoot }) => ({ - id, - entryPath, - ...(packageRoot ? { packageRoot } : {}), - })), + skillEntries, tools: snapshot.toolDefinitions.map((tool) => structuredClone(tool)), effectiveSnapshot: snapshot, skillRoots: [...skillRoots], diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs index 0037903..7f11ac1 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -322,16 +322,67 @@ export function verifyMarketplaceClientArtifact(appAsarContents, productionTrust * Checkout sources are not evidence of what an installed app will trust. */ export async function readPackagedMarketplaceTrustSource(appAsar) { - const candidates = listPackage(appAsar, { isPack: false }) - .filter((entry) => /\.(?:js|mjs|cjs)$/u.test(entry)); - for (const entry of candidates) { - const filename = entry.replace(/^[/\\]+/u, ''); + const entries = new Set(listPackage(appAsar, { isPack: false }) + .map((entry) => entry.replace(/^[/\\]+/u, '').replaceAll('\\', '/'))); + const readArchiveText = (entry) => Buffer.from( + extractFile(appAsar, entry.replaceAll('/', sep)), + ).toString('utf8'); + let packageJson; + try { + packageJson = JSON.parse(readArchiveText('package.json')); + } catch { + throw new Error('Packaged app.asar package.json is unreadable'); + } + if (typeof packageJson.main !== 'string' || packageJson.main.trim().length === 0) { + throw new Error('Packaged app.asar package.json.main is missing'); + } + + const normalizeAsarPath = (value) => { + const result = []; + for (const segment of value.replaceAll('\\', '/').split('/')) { + if (!segment || segment === '.') continue; + if (segment === '..') { + if (result.length === 0) return null; + result.pop(); + } else result.push(segment); + } + return result.join('/'); + }; + const resolveModule = (from, specifier) => { + if (!specifier.startsWith('.')) return null; + const base = normalizeAsarPath(`${dirname(from).replaceAll('\\', '/')}/${specifier}`); + if (!base) return null; + const candidates = [base, `${base}.js`, `${base}.mjs`, `${base}.cjs`, `${base}/index.js`]; + return candidates.find((candidate) => entries.has(candidate)) ?? null; + }; + const main = normalizeAsarPath(packageJson.main); + if (!main || !entries.has(main)) throw new Error('Packaged app.asar package.json.main is not present'); + const pending = [main]; + const reachable = []; + const seen = new Set(); + while (pending.length > 0) { + const entry = pending.shift(); + if (!entry || seen.has(entry)) continue; + seen.add(entry); let source; try { - source = extractFile(appAsar, filename).toString('utf8'); + source = readArchiveText(entry); } catch { continue; } + reachable.push({ entry, source }); + const specifiers = []; + const importPattern = /(?:import|export)\s+(?:[\s\S]*?\sfrom\s*)?['"]([^'"]+)['"]/gu; + const requirePattern = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/gu; + for (const match of source.matchAll(importPattern)) specifiers.push(match[1]); + for (const match of source.matchAll(requirePattern)) specifiers.push(match[1]); + for (const specifier of specifiers) { + const resolved = resolveModule(entry, specifier); + if (resolved) pending.push(resolved); + } + } + + for (const { source } of reachable) { const trustAssignment = source.match( /CODE_OWNED_PLUGIN_SIGNING_KEYS\s*=\s*Object\.freeze\(\s*\{\}\s*(?:as\s+[^)]*)?\)/u, ); diff --git a/src/lib/plugin-marketplace.ts b/src/lib/plugin-marketplace.ts index 2311d86..6bf7145 100644 --- a/src/lib/plugin-marketplace.ts +++ b/src/lib/plugin-marketplace.ts @@ -89,6 +89,7 @@ export type MarketplaceInstallation = { pluginId: string; releaseId?: string; version?: string; + channel?: 'stable' | 'beta'; reason?: string; }; @@ -198,6 +199,7 @@ function installation(value: unknown): MarketplaceInstallation { pluginId: pluginId(text(item.pluginId, 'installation.pluginId', 128)), ...(item.releaseId === undefined ? {} : { releaseId: text(item.releaseId, 'installation.releaseId', 128) }), ...(item.version === undefined ? {} : { version: text(item.version, 'installation.version', 64) }), + ...(item.channel === undefined ? {} : { channel: oneOf(item.channel, ['stable', 'beta'] as const, 'installation.channel') }), ...(item.reason === undefined ? {} : { reason: text(item.reason, 'installation.reason', 256) }), }; } diff --git a/src/pages/MyPlugins/index.tsx b/src/pages/MyPlugins/index.tsx index 5beffdf..c1e8269 100644 --- a/src/pages/MyPlugins/index.tsx +++ b/src/pages/MyPlugins/index.tsx @@ -67,18 +67,21 @@ export function MyPluginsView(props: MyPluginsViewProps) { const removed = plugin.removedAt !== null; const suspended = plugin.runtimeStatus === 'suspended'; const retired = plugin.catalogStatus === 'retired'; - const updateAvailable = Boolean(installed?.version && plugin.stableVersion && installed.version !== plugin.stableVersion); + const installedChannel = installed?.channel ?? 'stable'; + const channelVersion = installedChannel === 'beta' ? plugin.betaVersion : plugin.stableVersion; + const updateAvailable = Boolean(installed?.version && channelVersion && installed.version !== channelVersion); + const betaChannelUnavailable = Boolean(installed && installedChannel === 'beta' && !plugin.betaVersion); const busy = Object.keys(props.pending).some((key) => key.endsWith(`:${plugin.pluginId}`)); const failure = failureText(installed?.reason); return
-

{plugin.title}

{plugin.acquisition === 'system_included' ? 系统内置 : removed ? 已移除 : 已获取}

{plugin.summary}

稳定版 {plugin.stableVersion ?? '不可用'}

{installed?.version ?

设备版本 {installed.version}

:

尚未下载到设备

}
-
{suspended ?

运行已暂停

: null}{retired ?

{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}

: null}{failure ?

{failure}

: null}
+

{plugin.title}

{plugin.acquisition === 'system_included' ? 系统内置 : removed ? 已移除 : 已获取}

{plugin.summary}

稳定版 {plugin.stableVersion ?? '不可用'}

{plugin.betaVersion ?

Beta 版 {plugin.betaVersion}

: null}{installed?.version ? <>

设备版本 {installed.version}

当前频道:{installedChannel === 'beta' ? 'Beta' : '稳定'}

:

尚未下载到设备

}
+
{suspended ?

运行已暂停

: null}{retired ?

{removed ? '已退役,移除后不可重新获取' : '已退役;现有账号插件库仍可继续使用受支持版本'}

: null}{betaChannelUnavailable ?

当前 Beta 频道暂无可用版本;不会静默切回稳定版。

: null}{failure ?

{failure}

: null}
{removed ? : !installed ? - : updateAvailable ? + : updateAvailable ? : 设备版本已是最新} - {!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion ? : null} + {!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? : null} {!removed && plugin.acquisition !== 'system_included' ? : null} {installed ? : null} diff --git a/src/stores/plugin-marketplace.ts b/src/stores/plugin-marketplace.ts index 089f784..0026bcb 100644 --- a/src/stores/plugin-marketplace.ts +++ b/src/stores/plugin-marketplace.ts @@ -91,10 +91,64 @@ export function createPluginMarketplaceStore( const deps = { ...defaults, ...overrides }; let scopeGeneration = 0; let catalogGeneration = 0; - let libraryMutationEpoch = 0; - let libraryReadGeneration = 0; + let libraryIntent = 0; + let latestLibraryReadIntent = 0; + const latestPluginMutationIntent = new Map(); return createStore((set, get) => { + const beginMutation = (pluginId: string): number => { + const intent = ++libraryIntent; + latestPluginMutationIntent.set(pluginId, intent); + return intent; + }; + + const canCommitMutation = ( + pluginId: string, + intent: number, + generation: number, + accountKey: string, + ): boolean => generation === scopeGeneration + && get().accountKey === accountKey + && latestPluginMutationIntent.get(pluginId) === intent + // A later explicit read is the newest account projection for every + // plugin, so an older mutation must not replace it. + && latestLibraryReadIntent <= intent; + + const mergeLibraryForPlugin = ( + current: MarketplaceLibrarySnapshot | null, + incoming: MarketplaceLibrarySnapshot, + pluginId: string, + ): MarketplaceLibrarySnapshot => { + if (!current) return incoming; + const byPlugin = new Map(current.items.map((item) => [item.pluginId, item])); + const incomingItem = incoming.items.find((item) => item.pluginId === pluginId); + if (incomingItem) byPlugin.set(pluginId, incomingItem); + else byPlugin.delete(pluginId); + const items = [...byPlugin.values()].sort((left, right) => { + const leftIntent = latestPluginMutationIntent.get(left.pluginId) ?? 0; + const rightIntent = latestPluginMutationIntent.get(right.pluginId) ?? 0; + return rightIntent - leftIntent; + }); + return { + ...incoming, + items, + total: Math.max(current.total, incoming.total, items.length), + fetchedAt: Math.max(current.fetchedAt, incoming.fetchedAt), + }; + }; + + const mergeLibraryProjection = ( + pluginId: string, + projection: MarketplaceLibraryProjection, + ): Pick => { + const state = get(); + const library = mergeLibraryForPlugin(state.library, projection.library, pluginId); + const incomingInstallation = projection.installations.find((item) => item.pluginId === pluginId); + const installations = { ...state.installations }; + if (incomingInstallation) installations[pluginId] = incomingInstallation; + return { library, installations }; + }; + const pending = async (key: string, operation: () => Promise): Promise => { set((state) => ({ pending: { ...state.pending, [key]: true } })); try { @@ -116,21 +170,19 @@ export function createPluginMarketplaceStore( const generation = scopeGeneration; const accountKey = get().accountKey; if (!accountKey) throw new Error('请先登录后管理我的插件'); - const mutationEpoch = ++libraryMutationEpoch; + const intent = beginMutation(pluginId); try { const projection = await action(pluginId); - if (generation !== scopeGeneration || get().accountKey !== accountKey - || mutationEpoch !== libraryMutationEpoch) return; - libraryMutationEpoch += 1; + if (!canCommitMutation(pluginId, intent, generation, accountKey)) return; + const merged = mergeLibraryProjection(pluginId, projection); set({ - library: projection.library, - installations: installationMap(projection.installations), + library: merged.library, + installations: merged.installations, libraryState: 'ready', libraryError: null, }); } catch (error) { - if (generation === scopeGeneration && get().accountKey === accountKey - && mutationEpoch === libraryMutationEpoch) { + if (canCommitMutation(pluginId, intent, generation, accountKey)) { set({ libraryError: message(error) }); } throw error; @@ -145,12 +197,10 @@ export function createPluginMarketplaceStore( const generation = scopeGeneration; const accountKey = get().accountKey; if (!accountKey) throw new Error('请先登录后管理设备插件'); - const mutationEpoch = ++libraryMutationEpoch; + const intent = beginMutation(pluginId); try { const result = await action(pluginId); - if (generation !== scopeGeneration || get().accountKey !== accountKey - || mutationEpoch !== libraryMutationEpoch) return; - libraryMutationEpoch += 1; + if (!canCommitMutation(pluginId, intent, generation, accountKey)) return; set((state) => { const installations = { ...state.installations }; if (result.status === 'removed') delete installations[pluginId]; @@ -158,8 +208,7 @@ export function createPluginMarketplaceStore( return { installations, libraryError: null }; }); } catch (error) { - if (generation === scopeGeneration && get().accountKey === accountKey - && mutationEpoch === libraryMutationEpoch) { + if (canCommitMutation(pluginId, intent, generation, accountKey)) { const reason = message(error); set((state) => ({ installations: { @@ -199,8 +248,9 @@ export function createPluginMarketplaceStore( activateAccount(accountKey) { if (get().accountKey === accountKey) return; scopeGeneration += 1; - libraryReadGeneration += 1; - libraryMutationEpoch += 1; + libraryIntent += 1; + latestLibraryReadIntent = libraryIntent; + latestPluginMutationIntent.clear(); set({ accountKey, library: null, @@ -242,10 +292,10 @@ export function createPluginMarketplaceStore( } }, async loadLibrary() { - const readGeneration = ++libraryReadGeneration; + const readIntent = ++libraryIntent; + latestLibraryReadIntent = readIntent; const generation = scopeGeneration; const accountKey = get().accountKey; - const mutationEpoch = libraryMutationEpoch; if (!accountKey) { set({ library: null, installations: {}, libraryState: 'idle', libraryError: null }); return; @@ -254,8 +304,8 @@ export function createPluginMarketplaceStore( try { const projection = await deps.readLibrary(); if (generation === scopeGeneration && get().accountKey === accountKey - && readGeneration === libraryReadGeneration - && mutationEpoch === libraryMutationEpoch) { + && readIntent === latestLibraryReadIntent + && readIntent === libraryIntent) { set({ library: projection.library, installations: installationMap(projection.installations), @@ -265,8 +315,8 @@ export function createPluginMarketplaceStore( } } catch (error) { if (generation === scopeGeneration && get().accountKey === accountKey - && readGeneration === libraryReadGeneration - && mutationEpoch === libraryMutationEpoch) { + && readIntent === latestLibraryReadIntent + && readIntent === libraryIntent) { set({ libraryState: 'error', libraryError: message(error) }); } throw error; diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts index 27b13eb..23d6249 100644 --- a/tests/e2e/plugin-marketplace.spec.ts +++ b/tests/e2e/plugin-marketplace.spec.ts @@ -56,4 +56,126 @@ test.describe('Plugin Marketplace', () => { await closeElectronApp(app); } }); + + test('keeps Beta explicit and exposes bounded unavailable, disabled, and device-delete states', async ({ launchElectronApp }) => { + const app = await launchElectronApp({ skipSetup: true }); + try { + const page = await getStableWindow(app); + await app.evaluate(({ ipcMain }) => { + let installedChannel: 'stable' | 'beta' | null = 'stable'; + const installedVersion = '1.0.0'; + const requests: string[] = []; + const library = { + items: [ + { + pluginId: 'makelore.notes', title: '灵感笔记', summary: '整理项目灵感。', category: '效率', + acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', + acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: '2.0.0-beta.1', + }, + { + pluginId: 'makelore.paused', title: '暂停插件', summary: '暂时暂停。', category: '效率', + acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'suspended', + acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null, + }, + { + pluginId: 'makelore.unavailable', title: '不可用插件', summary: '服务不可用。', category: '效率', + acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', + acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null, + }, + ], + total: 3, + stale: false, + fetchedAt: 1, + }; + const projection = () => ({ + library, + installations: [ + ...(installedChannel ? [{ + status: 'installed', pluginId: 'makelore.notes', releaseId: installedChannel === 'beta' ? 'beta-2' : 'stable-1', + version: installedChannel === 'beta' ? '2.0.0-beta.1' : installedVersion, channel: installedChannel, + }] : []), + { status: 'unavailable', pluginId: 'makelore.unavailable', reason: 'plugin_backend_unavailable' }, + ], + }); + const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } }); + (globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E = { requests }; + ipcMain.removeHandler('hostapi:fetch'); + ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string }) => { + const requestPath = request.path ?? ''; + const method = (request.method ?? 'GET').toUpperCase(); + requests.push(`${method} ${requestPath}`); + if (requestPath === '/api/auth/session/sync') { + const response = result({ + success: true, + session: { + accessToken: 'marketplace-e2e-token', tokenType: 'Bearer', + expiresAt: Date.now() + 3_600_000, lastActiveAt: Date.now(), canRefresh: false, + }, + }); + return response; + } + if (requestPath === '/api/auth/me') return result({ success: true, moduleAccess: { programming: true } }); + if (requestPath === '/api/coding/plugin-marketplace/library') return result(projection()); + if (requestPath === '/api/coding/plugin-marketplace/install/makelore.notes/beta' && method === 'POST') { + installedChannel = 'beta'; + return result({ status: 'installed', pluginId: 'makelore.notes', releaseId: 'beta-2', version: '2.0.0-beta.1', channel: 'beta' }); + } + if (requestPath === '/api/coding/plugin-marketplace/install/makelore.notes' && method === 'DELETE') { + installedChannel = null; + return result({ status: 'removed', pluginId: 'makelore.notes', releaseId: 'beta-2', version: '2.0.0-beta.1', channel: 'beta' }); + } + return { ok: false, error: { message: `Unexpected Host API request: ${method} ${requestPath}` } }; + }); + }); + await page.addInitScript(({ key, value }) => { + localStorage.setItem(key, value); + }, { + key: 'niancode-auth', + value: JSON.stringify({ + state: { + authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'marketplace-e2e-token', + tokenType: 'Bearer', expiresAt: Date.now() + 3_600_000, lastActiveAt: Date.now(), canRefresh: false, + legacyRefreshToken: null, + user: { username: 'marketplace-e2e', userId: 'marketplace-e2e-user', tenantId: null, deptId: null, authorities: [] }, + moduleAccess: { programming: true, design: true, learning: true, robot: true }, + }, + version: 2, + }), + }); + await page.reload({ waitUntil: 'domcontentloaded' }); + await expect(page.getByTestId('ai-module-option-programming')).toBeVisible(); + const programmingOption = page.getByTestId('ai-module-option-programming'); + await programmingOption.click(); + await expect(page.getByTestId('main-layout')).toBeVisible(); + await page.keyboard.press('Escape'); + await page.getByTestId('sidebar-nav-my-plugins').click(); + await expect(page.getByTestId('my-plugins-page')).toBeVisible(); + await expect.poll(async () => app.evaluate(() => ( + ((globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? []) + .filter((requestPath) => requestPath.includes('/api/auth') || requestPath.includes('/plugin-marketplace')) + ))).toEqual(expect.arrayContaining([ + 'GET /api/auth/me', + 'GET /api/coding/plugin-marketplace/library', + ])); + await expect(page.getByRole('heading', { name: '暂停插件' })).toBeVisible(); + await expect(page.getByText('运行已暂停')).toBeVisible(); + await expect(page.getByRole('button', { name: '下载暂停插件' })).toBeDisabled(); + await expect(page.getByText('Marketplace 服务暂不可用,设备版本未替换。')).toBeVisible(); + + await page.getByRole('button', { name: '安装 Beta灵感笔记' }).click(); + await expect(page.getByText('当前频道:Beta')).toBeVisible(); + await expect(page.getByRole('button', { name: '安装 Beta灵感笔记' })).toHaveCount(0); + await page.getByRole('button', { name: '删除设备上的灵感笔记' }).click(); + const notesCard = page.locator('article').filter({ has: page.getByRole('heading', { name: '灵感笔记' }) }); + await expect(notesCard.getByText('尚未下载到设备')).toBeVisible(); + await expect(notesCard.getByRole('button', { name: '下载灵感笔记' })).toBeVisible(); + const requests = await app.evaluate(() => ( + (globalThis as typeof globalThis & { __marketplaceE2E?: { requests: string[] } }).__marketplaceE2E?.requests ?? [] + )); + expect(requests).toContain('POST /api/coding/plugin-marketplace/install/makelore.notes/beta'); + expect(requests).toContain('DELETE /api/coding/plugin-marketplace/install/makelore.notes'); + } finally { + await closeElectronApp(app); + } + }); }); diff --git a/tests/unit/coding-plugin-effective-resolver.test.ts b/tests/unit/coding-plugin-effective-resolver.test.ts index 70ea244..7687b20 100644 --- a/tests/unit/coding-plugin-effective-resolver.test.ts +++ b/tests/unit/coding-plugin-effective-resolver.test.ts @@ -139,6 +139,72 @@ async function resolve(effective: EffectivePluginResolver, assignedSkillIds = [' } describe('effective plugin resolver', () => { + it('rejects Marketplace Skill ID collisions with core and other packages without changing project assignment', async () => { + const coreCollision: CodingPluginDefinition = { + ...skillOnlyDefinition, + id: 'makelore.core-collision', + releaseId: 'core-collision-1', + provenance: { source: 'marketplace', packageRoot: 'C:/packages/core-collision-1' }, + skills: [{ id: 'agent-browser', entryPath: 'skills/agent-browser/SKILL.md', grants: [] }], + }; + const packageA: CodingPluginDefinition = { + ...skillOnlyDefinition, + id: 'makelore.collision-a', + releaseId: 'collision-a-1', + provenance: { source: 'marketplace', packageRoot: 'C:/packages/collision-a-1' }, + skills: [{ id: 'shared-skill', entryPath: 'skills/shared/SKILL.md', grants: [] }], + }; + const packageB: CodingPluginDefinition = { + ...skillOnlyDefinition, + id: 'makelore.collision-b', + releaseId: 'collision-b-1', + provenance: { source: 'marketplace', packageRoot: 'C:/packages/collision-b-1' }, + skills: [{ id: 'shared-skill', entryPath: 'skills/shared/SKILL.md', grants: [] }], + }; + const definitions = [coreCollision, packageA, packageB]; + const result = await createEffectivePluginResolver({ + definitions, + getAccountBinding: () => binding, + getLibrary: vi.fn(async () => ({ + items: definitions.map((definition) => ({ + pluginId: definition.id, + title: definition.displayName, + summary: definition.description, + category: 'tools', + acquisition: 'free' as const, + acquisitionMode: 'user_acquired' as const, + catalogStatus: 'active' as const, + runtimeStatus: 'enabled' as const, + acquiredAt: null, + removedAt: null, + stableVersion: definition.version, + betaVersion: null, + })), + total: definitions.length, + stale: false, + fetchedAt: 1, + })), + getInstalled: vi.fn(async (pluginId: string) => { + const definition = definitions.find((candidate) => candidate.id === pluginId); + return definition ? installed(definition) : null; + }), + getEnabledPluginIds: vi.fn(async () => definitions.map(({ id }) => id)), + }).resolve({ + projectId: 'project-a', + projectPath: 'C:/project-a', + assignedSkillIds: ['agent-browser', 'shared-skill'], + role: 'parent', + }); + + expect(result.effectiveSkillIds).toEqual(['agent-browser', 'shared-skill']); + expect(result.skillEntries).toEqual([ + { id: 'agent-browser', entryPath: 'agent-browser/SKILL.md' }, + { id: 'shared-skill', entryPath: 'skills/shared/SKILL.md', packageRoot: 'C:/packages/collision-a-1' }, + ]); + expect(result.pluginReleaseIds).toEqual(['collision-a-1']); + expect(result.unavailableReasons).toEqual([]); + }); + it('requires current Library, installed Release, project selection, and assignment', async () => { const effective = resolver(); await expect(resolve(effective)).resolves.toMatchObject({ diff --git a/tests/unit/coding-plugin-marketplace-client.test.ts b/tests/unit/coding-plugin-marketplace-client.test.ts index 6070305..99a25c9 100644 --- a/tests/unit/coding-plugin-marketplace-client.test.ts +++ b/tests/unit/coding-plugin-marketplace-client.test.ts @@ -33,6 +33,13 @@ const RELEASE_ID = 'release-1'; const ADMISSION_ID = 'admission-1'; const SHA256 = 'a'.repeat(64); +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); + return { promise, resolve, reject }; +} + const catalogPage = { items: [{ plugin_id: PLUGIN_ID, @@ -160,6 +167,27 @@ function signedGrant( describe('Marketplace client and account cache', () => { afterEach(() => vi.restoreAllMocks()); + function rawLibraryEntry(pluginId: string, title: string) { + return { + plugin_id: pluginId, + title, + summary: title, + category: 'tools', + acquisition: 'free', + acquisition_mode: 'user_acquired', + catalog_status: 'active', + runtime_status: 'enabled', + acquired_at: '2026-08-28T00:00:00Z', + removed_at: null, + stable_version: '1.0.0', + beta_version: null, + }; + } + + function rawLibrary(entries: readonly Record[]) { + return { items: entries, total: entries.length }; + } + it('keeps Library and admission snapshots isolated by account and invalidates on logout', () => { const cache = new AccountPluginCache(); const aLibrary: MarketplaceLibrarySnapshot = { @@ -199,6 +227,64 @@ describe('Marketplace client and account cache', () => { expect(cache.referencedReleaseIds()).toEqual(new Set()); }); + it('does not let an older Library read overwrite a newer mutation/read intent', async () => { + const oldRead = deferred(); + const mutation = deferred(); + const newestRead = deferred(); + const fetcher = vi.fn() + .mockImplementationOnce(() => oldRead.promise) + .mockImplementationOnce(() => mutation.promise) + .mockImplementationOnce(() => newestRead.promise); + const cache = new AccountPluginCache(); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + getAccessToken: async () => 'token', + getAccountBinding: () => ACCOUNT_A, + subscribeSession: () => () => undefined, + accountCache: cache, + }); + const old = client.readLibrary(); + const acquired = client.acquire(PLUGIN_ID); + mutation.resolve(response(rawLibraryEntry(PLUGIN_ID, 'acquired'))); + await Promise.resolve(); + newestRead.resolve(response(rawLibrary([rawLibraryEntry(PLUGIN_ID, 'newest')]))); + oldRead.resolve(response(rawLibrary([rawLibraryEntry(PLUGIN_ID, 'old')]))); + await Promise.all([old, acquired]); + expect(cache.getLibrary(ACCOUNT_A)?.items[0]?.title).toBe('newest'); + }); + + it('keeps the latest same-account mutation intent when mutation responses complete out of order', async () => { + const firstMutation = deferred(); + const secondMutation = deferred(); + const firstRead = deferred(); + const secondRead = deferred(); + const fetcher = vi.fn() + .mockImplementationOnce(() => firstMutation.promise) + .mockImplementationOnce(() => secondMutation.promise) + .mockImplementationOnce(() => secondRead.promise) + .mockImplementationOnce(() => firstRead.promise); + const cache = new AccountPluginCache(); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + getAccessToken: async () => 'token', + getAccountBinding: () => ACCOUNT_A, + subscribeSession: () => () => undefined, + accountCache: cache, + }); + const first = client.acquire('makelore.first'); + const second = client.acquire('makelore.second'); + secondMutation.resolve(response(rawLibraryEntry('makelore.second', 'second mutation'))); + await Promise.resolve(); + secondRead.resolve(response(rawLibrary([rawLibraryEntry('makelore.second', 'second newest')]))); + firstMutation.resolve(response(rawLibraryEntry('makelore.first', 'first mutation'))); + await Promise.resolve(); + firstRead.resolve(response(rawLibrary([rawLibraryEntry('makelore.first', 'first stale')]))); + await Promise.all([first, second]); + expect(cache.getLibrary(ACCOUNT_A)?.items[0]?.title).toBe('second newest'); + }); + it('parses bounded catalog metadata, refreshes exactly once after a 401, and marks stale data', async () => { const fetcher = vi.fn(); fetcher @@ -584,12 +670,91 @@ describe('PluginPackageStore', () => { store.registerActiveWorker('release-1'); await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({ - status: 'removed', pluginId: PLUGIN_ID, releaseId: 'release-1', version: '1.0.0', + status: 'kept', pluginId: PLUGIN_ID, releaseId: 'release-1', version: '1.0.0', }); await expect(store.readInstalledIndex()).resolves.toHaveLength(1); await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-1' }); }); + it('persists the installed channel and client range, and fails closed after a client upgrade', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const stable = signedGrant(archive, { releaseId: 'release-stable', minMakeloreVersion: '1.0.0', maxMakeloreVersion: '1.5.0' }); + let current = stable; + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + releaseId: current.grant.releaseId, + sha256: current.grant.sha256, + sizeBytes: current.grant.sizeBytes, + })), + issueDownload: vi.fn(async () => current.grant), + downloadContent: async () => archive, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + getAccountBinding: () => ACCOUNT_A, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', stable.publicKey]]), + }); + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0', channel: 'stable' }); + + const index = JSON.parse(await readFile(path.join(temporaryRoot, 'index.json'), 'utf8')) as { + releases: Array>; + }; + expect(index.releases[0]).toMatchObject({ + channel: 'stable', + min_makelore_version: '1.0.0', + max_makelore_version: '1.5.0', + }); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ + channel: 'stable', minMakeloreVersion: '1.0.0', maxMakeloreVersion: '1.5.0', + }); + + const upgradedStore = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + getAccountBinding: () => ACCOUNT_A, + clientVersion: '2.0.0', + keyStore: new Map([['test-key', stable.publicKey]]), + }); + await expect(upgradedStore.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ + unavailableReason: 'plugin_incompatible_client', + }); + await expect(upgradedStore.readInstalledIndex()).resolves.toHaveLength(1); + }); + + it('removes only old releases and never guesses a new current selection from installedAt', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const first = signedGrant(archive, { releaseId: 'release-old' }); + const second = signedGrant(archive, { releaseId: 'release-current', signingKeyId: 'test-key-2' }); + let current = first; + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + releaseId: current.grant.releaseId, + sha256: current.grant.sha256, + sizeBytes: current.grant.sizeBytes, + })), + issueDownload: vi.fn(async () => current.grant), + downloadContent: async () => archive, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, marketplace, getAccountBinding: () => ACCOUNT_A, + keyStore: new Map([['test-key', first.publicKey], ['test-key-2', second.publicKey]]), clientVersion: '1.0.0', + }); + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + current = second; + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + + await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({ status: 'kept', releaseId: 'release-current' }); + await expect(store.readInstalledIndex()).resolves.toHaveLength(1); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-current' }); + await expect(readFile(path.join(temporaryRoot, 'current.json'), 'utf8')).resolves.toContain('release-current'); + }); + it('preserves the old release across download, signature, and extraction failures', async () => { temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); const oldArchive = buildSkillOnlyArchive(); diff --git a/tests/unit/coding-product-services.test.ts b/tests/unit/coding-product-services.test.ts index d9fb72e..9a5aa28 100644 --- a/tests/unit/coding-product-services.test.ts +++ b/tests/unit/coding-product-services.test.ts @@ -350,6 +350,31 @@ describe('Marketplace public Library projection', () => { expect(JSON.stringify(result)).not.toMatch(/sha256|sizeBytes|installedAt|packageRoot|definition|account|admission|token/i); }); + it('projects an installed but client-incompatible package without discarding its cached release', async () => { + const marketplace = { + readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn().mockResolvedValue(snapshot), + acquire: vi.fn(), remove: vi.fn(), + }; + const packageStore = { + readInstalledIndex: vi.fn().mockResolvedValue(records), + getInstalled: vi.fn().mockResolvedValue({ + ...records[0], channel: 'beta', packageRoot: 'ignored', definition: {}, + unavailableReason: 'plugin_incompatible_client' as const, + }), + resolveAndInstall: vi.fn(), removeUnused: vi.fn(), + }; + const service = createCodingPluginMarketplaceService({ + marketplace: marketplace as never, packageStore: packageStore as never, clientVersion: '3.0.0', + }); + + await expect(service.readLibrary()).resolves.toMatchObject({ + installations: [{ + status: 'unavailable', pluginId: 'makelore.notes', releaseId: 'release-old', version: '1.0.0', + channel: 'beta', reason: 'plugin_incompatible_client', + }], + }); + }); + it.each(['acquire', 'remove'] as const)('returns an authoritative joined snapshot after %s', async (action) => { const marketplace = { readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(), diff --git a/tests/unit/pi-product-artifact.test.ts b/tests/unit/pi-product-artifact.test.ts index a3f30a6..1212aa1 100644 --- a/tests/unit/pi-product-artifact.test.ts +++ b/tests/unit/pi-product-artifact.test.ts @@ -236,6 +236,7 @@ describe('final Pi product artifact verification', () => { roots.push(root); const source = path.join(root, 'source'); await mkdir(path.join(source, 'dist-electron'), { recursive: true }); + await writeFile(path.join(source, 'package.json'), JSON.stringify({ main: 'dist-electron/main.js' })); await writeFile(path.join(source, 'dist-electron', 'main.js'), [ 'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});', 'const unrelatedConfiguration = process.env.NIANCODE_E2E;', @@ -253,6 +254,7 @@ describe('final Pi product artifact verification', () => { roots.push(root); const source = path.join(root, 'source'); await mkdir(path.join(source, 'dist-electron'), { recursive: true }); + await writeFile(path.join(source, 'package.json'), JSON.stringify({ main: 'dist-electron/main.js' })); await writeFile(path.join(source, 'dist-electron', 'main.js'), [ 'const dC = Object.freeze({});', 'function createTrust() { return Object.freeze({ get: load, sourceMarker: "makelore.plugin-trust.code-owned.v1" }); }', @@ -275,6 +277,22 @@ describe('final Pi product artifact verification', () => { )).toMatchObject({ productionTrust: 'official-key-absent-fail-closed' }); }); + it('does not accept a stale unreachable trust marker outside package.json.main reachability', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-marketplace-stale-trust-asar-')); + roots.push(root); + const source = path.join(root, 'source'); + await mkdir(path.join(source, 'dist-electron'), { recursive: true }); + await writeFile(path.join(source, 'package.json'), JSON.stringify({ main: 'dist-electron/main.js' })); + await writeFile(path.join(source, 'dist-electron', 'main.js'), 'export const app = true;'); + await writeFile(path.join(source, 'dist-electron', 'stale-trusted-keys.js'), [ + 'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});', + 'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";', + ].join('\n')); + const appAsar = path.join(root, 'app.asar'); + await createPackage(source, appAsar); + await expect(readPackagedMarketplaceTrustSource(appAsar)).rejects.toThrow('trust source'); + }); + it('rejects a packaged plugin tree that drops an SDK asset or catalog marker', async () => { const fixture = await bundledResourceFixture(); await rm(path.join( diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx index a78c67f..c5cc9eb 100644 --- a/tests/unit/plugin-marketplace-pages.test.tsx +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -112,6 +112,35 @@ describe('My Plugins', () => { expect(screen.getByText('稳定版 2.0.0')).toBeVisible(); }); + it('updates an installed Beta package only through the explicit Beta action', () => { + const onUpdate = vi.fn(); + const onInstallBeta = vi.fn(); + render(); + + expect(screen.queryByRole('button', { name: '更新灵感笔记' })).not.toBeInTheDocument(); + const updateBeta = screen.getByRole('button', { name: '更新 Beta灵感笔记' }); + fireEvent.click(updateBeta); + 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(); + expect(screen.getByText('当前 Beta 频道暂无可用版本;不会静默切回稳定版。')).toBeVisible(); + expect(screen.queryByRole('button', { name: '更新灵感笔记' })).not.toBeInTheDocument(); + }); + it('names signature, yanked/not-ready, and incompatible failures without hiding the old install', () => { render( { a.resolve(make('makelore.a', 'A older')); await aRequest; } expect(store.getState().library?.items[0].title).toBe('B newer'); + expect(store.getState().library?.items.map(({ pluginId }) => pluginId).sort()).toEqual([ + 'makelore.a', 'makelore.b', + ]); } }); + it('keeps independent Library and device mutations when their responses complete out of order', async () => { + const acquire = deferred(); + const install = deferred(); + const store = createPluginMarketplaceStore({ + acquire: vi.fn(() => acquire.promise), + install: vi.fn(() => install.promise), + }); + store.getState().activateAccount('account-a'); + const libraryMutation = store.getState().acquire('makelore.notes'); + const deviceMutation = store.getState().install('makelore.data'); + install.resolve({ status: 'installed', pluginId: 'makelore.data', version: '1.0.0' }); + await deviceMutation; + acquire.resolve({ + library: library('Notes acquired'), + installations: [], + }); + await libraryMutation; + expect(store.getState().library?.items[0]?.title).toBe('Notes acquired'); + expect(store.getState().installations['makelore.data']).toMatchObject({ status: 'installed' }); + }); + it('calls only the selected state mutation', async () => { const acquire = vi.fn().mockResolvedValue(projection('Acquired')); const install = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'makelore.notes', version: '1.0.0' });