diff --git a/.project-docs/30-worklog/tasks/20260828-marketplace-mlm06-r3-remediation-9e4c2a71.md b/.project-docs/30-worklog/tasks/20260828-marketplace-mlm06-r3-remediation-9e4c2a71.md new file mode 100644 index 0000000..9a3f1f3 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260828-marketplace-mlm06-r3-remediation-9e4c2a71.md @@ -0,0 +1,201 @@ +# Task: Marketplace MLM-06 R3 fixed-range remediation + +## Identity + +- Task ID: 20260828-marketplace-mlm06-r3-remediation-9e4c2a71 +- Mode: Feature +- Branch: codex/20260828-marketplace-mlm06-r3-remediation-9e4c2a71-marketplace-mlm06-r3-remediation +- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r3-remediation-9e4c2a71 +- Base commit: 57962591de71fc0a9f142dbcd130e3308754e423 +- Owner: marketplace-client-coordinator-r3-remediator +- Status: Ready for Integration + +## Scope + +- Sole MLM-06 R3 fixed-range remediation over the eight accepted actionable roots + handed off from exact client frontier + `57962591de71fc0a9f142dbcd130e3308754e423`. Product/test changes stay in this + isolated MakeLore worktree and are limited to the assigned resolver, Package + Store/archive validation, Renderer Marketplace store/My Plugins, project + projection, packaged artifact verifier, focused tests/E2E, and this task record. +- Preserve Marketplace Release A only. Data Service remains bundled, + system-included, typed, and zero Token Point; hosted Provider/Release B, + arbitrary local code/MCP/hooks, generic invoke/config/ledger, XMA-01, + deployment, publication, push, and PR creation are outside this task. +- Deliver one source commit containing this task record, with sole parent + `57962591de71fc0a9f142dbcd130e3308754e423`, a clean worktree, and + `READY_FOR_INTEGRATION` task-context completion. + +## Intent And Constraints + +- Human-confirmed recovery was required because the original implementation owner + stopped responding while leaving a known dirty worktree. The coordinator + interrupted that writer, ran `release --force`, reversibly backed up its untracked + task record, and successfully reclaimed this exact task/worktree with + `--adopt-existing`. The eight existing product/test paths were never stashed, + reset, cleaned, deleted, or moved. This owner is now the sole writer. +- Concurrent Task Gate passed after recovery: project-doc structure is valid and + task-context status matches this task ID, feature mode, owner, absolute worktree, + branch, and exact base. The coordinator, user root, server repository, and prior + review/source worktrees remain read-only. +- Planning Gate passed after reading the required project-memory startup set, + relevant peer records, implementation spec, ticket graph, accepted design, and + ADR-006. Canonical integrated memory predates Marketplace; the frozen spec/design, + coordinator ledger, and R3 fixed-range findings are the current authority. +- Implement test-first at each public seam. Do not add compatibility layers, + arbitrary prefixes, hidden request chaining, account/Library conflation, broad + archive-security frameworks, fabricated yanked fields, runtime key overrides, or + unrelated UI redesign. Explicit device uninstall remains distinct from Library + removal and background cleanup. +- Production Ed25519 activation remains HOLD. Tests may inject ephemeral keys only; + no production private key or arbitrary runtime trust source may be generated, + read, or committed. + +## Project Context Loaded + +- Task identity: `20260828-marketplace-mlm06-r3-remediation-9e4c2a71`, feature + mode, branch + `codex/20260828-marketplace-mlm06-r3-remediation-9e4c2a71-marketplace-mlm06-r3-remediation`, + worktree + `D:\Datas\OthersProjects\makelore-plugin-marketplace-mlm06-r3-remediation-9e4c2a71`, + owner `marketplace-client-coordinator-r3-remediator`, exact base + `57962591de71fc0a9f142dbcd130e3308754e423`. +- Other active Marketplace owners are completed source/remediation tasks, + read-only fixed-range reviewers, or the coordinator. No owner has write authority + in this worktree and no unresolved semantic conflict changes the eight-root plan. +- Main owns account, package, trust, filesystem, and effective-runtime authority; + Renderer consumes bounded projections. Account Library, Device Installation, + Project Selection, Agent assignment, authorization, and billing remain separate. +- The accepted eight roots are: bundled Skill collision reservation; pre-queue + account binding; independent same-plugin Library/device intents; explicit + uninstall versus cleanup; reachable packaged markers; canonical archive + validation; `client_incompatible` Project Plugins projection; and no-version My + Plugins presentation. +- Gate result: Passed. Project positioning is still a placeholder and shared + integrated memory predates Marketplace, both explicitly recorded as stale context + rather than silently treated as current authority. + +## Fixed-Range Ownership / Red Plan + +1. Reserve every non-Marketplace bundled Skill ID, including Data Service, and + reject conflicting Marketplace resources without rewriting configuration. +2. Capture the current account binding before queued Package Store work and verify + it before execution and completion so A-originated work cannot execute as B. +3. Split same-plugin Renderer Library and device intents while retaining latest-only + ordering within each domain and merging independent successful results. +4. Separate explicit uninstall from background cleanup; remove all unprotected + current/old releases and report `kept` only for actual protections. +5. Bind Marketplace artifact proof to the real packaged `package.json.main` + reachability graph and reject disconnected marker strings. +6. Enforce the canonical archive allowlist, extension/UTF-8 rules, and 256 KiB + manifest/Skill limits before atomic install, without adding a generic framework. +7. Project `client_incompatible` as bounded Project Plugins unavailable state. +8. Render no-version unavailable installation projections as not installed and not + deletable; keep versioned incompatible packages present and removable. + +## Implementation Plan + +1. Review the preserved dirty diff against all eight accepted roots and existing + focused tests; retain correct work and identify still-missing paths without + rewriting completed slices. +2. For each missing root, add an exact red test, apply the smallest green change, + and run the focused public-boundary regression with a stated failure consequence. +3. Run affected Marketplace/Pi/Data Service regressions, full unit/pressure, + typecheck, lint, Vite build, Windows Electron, Marketplace E2E, x64 packaging, + and the real artifact verifier. Record exact baseline/environment deviations. +4. Update this record with the recovery and RED→GREEN ledger, run diff/doc gates, + create the single source commit with the exact parent, complete task_context, and + verify a clean handoff. + +## Outcome + +- Recovered and completed the preserved implementation without resetting, stashing, + cleaning, or replacing any predecessor work. The only discarded filesystem data + was the trace CLI's own temporary extraction directory after diagnosis. +- Reserved every bundled/non-Marketplace Skill owner, so a signed Marketplace + package can no longer shadow the bundled Data Service Skill or another package's + Skill root. Project and Agent assignments remain unchanged and regain effect if + the original package returns. +- Bound Package Store sync/install/uninstall operations to the account captured + before queueing, with execution- and completion-time rechecks. Explicit uninstall + now removes every unprotected Release, while background cleanup may retain the + selected current Release; account snapshots and active workers remain protected. +- Split Renderer Library and device mutation intent per plugin/domain. Independent + successful operations merge instead of one domain silently suppressing the other. +- Hardened Release A archive admission to the canonical Skill-only tree, server- + aligned text/image extensions, fatal UTF-8 manifests/text, declared Skill roots, + and 256 KiB manifest/Skill limits. Script/native payloads and undeclared assets + fail before the atomic install write. +- Project Plugins maps `client_incompatible` to the bounded unavailable state. My + Plugins treats a no-version unavailable projection as not downloaded and not + deletable, while an incompatible versioned package remains visible/removable. +- Packaged proof now walks only files reachable from `package.json.main`, including + Main imports/requires, minified static ESM imports, dynamic Renderer chunks, and + HTML assets. Disconnected marker strings cannot satisfy trust or Marketplace + coverage. Test ASAR creation waits for the returned WritableStream `finish` + signal, eliminating partial archive reads without adding a production retry. +- Stabilized the Marketplace Electron test at its real bootstrap boundary: the + initial module-selection screen must be ready before replacing Main's Host API + handler and reloading with persisted auth. This removes an old-page/new-page auth + initialization race without changing production authentication. +- No hosted Provider, charging/Token Point path, arbitrary code/MCP/hooks, generic + invoke/config/ledger, publication, deployment, XMA-01, production key material, + or server change was introduced. + +## Verification + +- Recovery baseline: the inherited focused set produced 43 passes / 15 failures. + Thirteen failures were an expired fixed Admission fixture; the remaining product + reds proved the missing archive rejection, Project `client_incompatible` state, + and no-version My Plugins action. Correcting the fixture and implementing the + owned seams turned those boundaries green. +- Explicit uninstall edge RED: when an old Release was protected but current was + not, the old implementation returned `kept` and retained current. The focused + regression is green after explicit/background cleanup separation. +- Direct final focused: 6 files / 73 tests passed. +- Marketplace/Pi/Project Plugins/Data Service adjacent regression: 28 files / 229 + tests passed. +- Full unit run: 208 files / 1,799 passed / 2 skipped; dedicated pressure run 1/1 + passed. +- `pnpm typecheck`: passed. +- `pnpm run lint:check`: 0 errors; exactly five pre-existing out-of-scope warnings + remain (`Home` one hook warning, `Makelore` four Fast Refresh warnings). +- `pnpm run build:vite`: passed (Renderer 2,261 modules; Main 193; Preload 1; + utility worker 9). Existing dynamic-import and chunk-size warnings remain. +- The first clean-source `pnpm run package:win` passed through Python/uv staging, + Pi runtime bundling, win-unpacked, and NSIS. The first real + `verify:artifact:pi` correctly went RED because Vite's minified + `import{...}from"..."` edge was not traversed and the reachable graph could not + prove Renderer catalog/Library markers. A matching realistic ASAR unit fixture + failed 1/13; after accepting minified static ESM syntax it passed 13/13 and the + same real app.asar verifier returned overall `result: pass`, Marketplace + `result: pass`, Data Service 10 tools, Pi 0.84.2 closure complete, and only the + inherited real-Provider/cross-platform `partial-pass` waivers. +- `pnpm run test:electron:windows`: 2 files / 6 tests passed. +- Marketplace E2E bootstrap diagnosis: the original focused scenario failed 3/5, + and the direct-localStorage hypothesis failed 4/5 and was reverted. Waiting for + initial Renderer bootstrap before handler replacement passed 5/5; the original + Marketplace + Project Plugins target then passed 3/3. +- Full Electron E2E: 28 passed / 1 known baseline failure. The sole failure remains + `tests/e2e/pi-coding-first-chat.spec.ts:575`, where the current-conversation model + combobox is disabled and `selectOption` times out; both Marketplace tests and the + Project Plugins test pass in the same run. +- `git diff --check`: passed before documentation finalization. +- After the source commit is amended with the minified-ESM regression, the client + coordinator reruns clean-HEAD `package:win`, `verify:artifact:pi`, + `verify:artifact:win`, and `verify:publish-runtime` so embedded Git evidence names + the final source. Exact hashes belong to the repository-local integration + checkpoint and remain a handoff condition. + +## Follow-ups + +- The client coordinator must integrate only the single source commit, record the + clean-HEAD Windows/package verifier ledger, then start fresh fixed-range R4 + Standards and Spec reviews. XMA-01 stays closed until both repositories pass. +- Production Marketplace activation remains on hold until the official Ed25519 + public key is supplied. Release B remains deferred until a named Provider contract + exists. + +## Promotion Candidates + +- None recorded. diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index ea0a418..1800a5c 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -497,7 +497,8 @@ export function createCodingProjectPluginService( if (effective && effectiveReason?.code === 'project_disabled') state = 'disabled'; else if (effective && !hasEffectiveSkill && effectiveReason && ['account_required', 'library_required', 'library_unavailable', 'release_not_installed', - 'release_invalid', 'runtime_suspended', 'policy_unavailable', 'policy_unsupported'] + 'release_invalid', 'client_incompatible', 'runtime_suspended', 'policy_unavailable', + 'policy_unsupported'] .includes(effectiveReason.code)) state = 'unavailable'; return { id: definition.id, diff --git a/electron/coding-plugins/effective-resolver.ts b/electron/coding-plugins/effective-resolver.ts index 37c96cd..9be6abe 100644 --- a/electron/coding-plugins/effective-resolver.ts +++ b/electron/coding-plugins/effective-resolver.ts @@ -220,6 +220,10 @@ function validateBinding(value: AccountBinding | null): AccountBinding | null { 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; + for (const { id } of definition.skills) owned.add(id); + } for (const { definition } of records) { if (definition.provenance.source !== 'marketplace') continue; const conflicts = definition.skills.some(({ id }) => owned.has(id)); diff --git a/electron/coding-plugins/package-store.ts b/electron/coding-plugins/package-store.ts index 8e34afd..e35fb91 100644 --- a/electron/coding-plugins/package-store.ts +++ b/electron/coding-plugins/package-store.ts @@ -52,6 +52,8 @@ const DEFAULT_MAX_ARCHIVE_BYTES = 16 * 1024 * 1024; const DEFAULT_MAX_EXTRACTED_BYTES = 32 * 1024 * 1024; const DEFAULT_MAX_FILES = 256; const DEFAULT_MAX_FILE_BYTES = 4 * 1024 * 1024; +const PLUGIN_MANIFEST_MAX_BYTES = 256 * 1024; +const PLUGIN_SKILL_MAX_BYTES = 256 * 1024; const ORPHAN_ARCHIVE_FILE = '.makelore-release.zip'; const MAX_PLUGIN_ID = 128; const MAX_RELEASE_ID = 128; @@ -76,6 +78,12 @@ const INDEX_RELEASE_KEYS = new Set([ 'max_makelore_version', ]); const CURRENT_SELECTION_ROOT_KEYS = new Set(['schema_version', 'current']); +const PACKAGE_MANIFEST_PATHS = new Set(['plugin.json', 'com.makelore/capability.json']); +const PACKAGE_TEXT_EXTENSIONS = new Set([ + '.css', '.html', '.json', '.md', '.markdown', '.mustache', '.hbs', '.ini', + '.jinja', '.jinja2', '.template', '.txt', '.toml', '.tmpl', '.xml', '.yaml', '.yml', +]); +const PACKAGE_IMAGE_EXTENSIONS = new Set(['.jpeg', '.jpg', '.png', '.svg', '.webp']); type UnknownRecord = Record; @@ -445,6 +453,55 @@ function entryIsSymlink(entry: AdmZip.IZipEntry): boolean { return (mode & 0xf000) === 0xa000; } +function assertUtf8(content: Uint8Array, filePath: string): void { + try { + new TextDecoder('utf-8', { fatal: true }).decode(content); + } catch { + fail('plugin_artifact_invalid', `${filePath} must be UTF-8`); + } +} + +function validateArchiveFile(filePath: string, content: Uint8Array): void { + if (PACKAGE_MANIFEST_PATHS.has(filePath)) { + if (content.byteLength > PLUGIN_MANIFEST_MAX_BYTES) { + fail('plugin_artifact_invalid', `${filePath} exceeds its byte limit`); + } + assertUtf8(content, filePath); + return; + } + if (!filePath.startsWith('skills/')) { + fail('plugin_artifact_invalid', 'package files must be under a declared Skill'); + } + const extension = path.posix.extname(filePath).toLowerCase(); + if (!PACKAGE_TEXT_EXTENSIONS.has(extension) && !PACKAGE_IMAGE_EXTENSIONS.has(extension)) { + fail('plugin_artifact_invalid', 'archive asset extension is not supported'); + } + if (filePath.endsWith('/SKILL.md') && content.byteLength > PLUGIN_SKILL_MAX_BYTES) { + fail('plugin_artifact_invalid', 'Skill exceeds its byte limit'); + } + if (PACKAGE_TEXT_EXTENSIONS.has(extension)) assertUtf8(content, filePath); +} + +function validateArchiveDefinition( + files: readonly string[], + definition: CodingPluginDefinition, +): void { + for (const manifestPath of PACKAGE_MANIFEST_PATHS) { + if (!files.includes(manifestPath)) fail('plugin_artifact_invalid', `archive is missing ${manifestPath}`); + } + const skillIds = new Set(definition.skills.map(({ id }) => id)); + for (const filePath of files) { + if (PACKAGE_MANIFEST_PATHS.has(filePath)) continue; + const [, skillId] = filePath.split('/'); + if (!skillId || !skillIds.has(skillId)) { + fail('plugin_artifact_invalid', 'archive file is outside a declared Skill'); + } + } + for (const { entryPath } of definition.skills) { + if (!files.includes(entryPath)) fail('plugin_artifact_invalid', 'archive is missing a declared Skill entry'); + } +} + async function extractArchive( bytes: Uint8Array, destination: string, @@ -453,7 +510,7 @@ async function extractArchive( readonly maxFiles: number; readonly maxFileBytes: number; }, -): Promise { +): Promise { let archive: AdmZip; try { archive = new AdmZip(toBuffer(bytes)); @@ -465,6 +522,7 @@ async function extractArchive( fail('plugin_artifact_invalid', 'artifact file count is outside its bound'); } const seen = new Set(); + const files: string[] = []; let extractedBytes = 0; for (const entry of entries) { if (entryIsSymlink(entry)) fail('plugin_artifact_invalid', 'archive symlinks are not supported'); @@ -496,6 +554,8 @@ async function extractArchive( if (content.byteLength !== declaredSize || content.byteLength > options.maxFileBytes) { fail('plugin_artifact_invalid', 'archive entry size is invalid'); } + validateArchiveFile(relative, content); + files.push(relative); await mkdir(path.dirname(target), { recursive: true }); try { await writeFile(target, content, { flag: 'wx' }); @@ -503,6 +563,7 @@ async function extractArchive( fail('plugin_artifact_invalid', 'archive extraction could not create a file'); } } + return files; } function mapVerificationFailure(code: PluginSignatureFailureCode): PluginPackageStoreErrorCode { @@ -610,8 +671,9 @@ export class PluginPackageStore { } async syncLibrary(): Promise { + const binding = this.requireBinding(); return this.withOperation(async () => { - const binding = this.requireBinding(); + this.assertBinding(binding); if (!this.marketplace.readLibrary) fail('plugin_install_failed', 'Marketplace Library is unavailable'); const snapshot = await this.marketplace.readLibrary(); this.assertBinding(binding); @@ -621,7 +683,15 @@ export class PluginPackageStore { } async resolveAndInstall(input: ResolveInstallInput): Promise { - return this.withOperation(() => this.resolveAndInstallLocked(input)); + validPluginId(input.pluginId); + if (input.channel !== undefined && input.channel !== 'stable' && input.channel !== 'beta') { + fail('plugin_release_unavailable', 'invalid Marketplace channel'); + } + if ((input.channel ?? 'stable') === 'beta' && input.explicitBeta !== true) { + fail('plugin_beta_selection_required'); + } + const binding = this.requireBinding(); + return this.withOperation(() => this.resolveAndInstallLocked(input, binding)); } async getInstalled(pluginId: string): Promise { @@ -633,17 +703,18 @@ export class PluginPackageStore { async removeUnused(pluginId: string): Promise { const validated = validPluginId(pluginId); - return this.withOperation(() => this.removeUnusedLocked(validated)); + return this.withOperation(() => this.removeUnusedLocked(validated, undefined, 'background')); } /** Remove a device package while leaving the account Library projection intact. */ async uninstall(pluginId: string): Promise { const validated = validPluginId(pluginId); + const binding = this.requireBinding(); return this.withOperation(async () => { - const binding = this.requireBinding(); + this.assertBinding(binding); this.accountCache.invalidatePlugin(binding, validated); this.assertBinding(binding); - return this.removeUnusedLocked(validated); + return this.removeUnusedLocked(validated, binding, 'explicit'); }); } @@ -660,14 +731,17 @@ export class PluginPackageStore { return clone(index.releases); } - private async resolveAndInstallLocked(input: ResolveInstallInput): Promise { + private async resolveAndInstallLocked( + input: ResolveInstallInput, + binding: AccountBinding, + ): Promise { const pluginId = validPluginId(input.pluginId); if (input.channel !== undefined && input.channel !== 'stable' && input.channel !== 'beta') { fail('plugin_release_unavailable', 'invalid Marketplace channel'); } const channel = input.channel ?? 'stable'; if (channel === 'beta' && input.explicitBeta !== true) fail('plugin_beta_selection_required'); - const binding = this.requireBinding(); + this.assertBinding(binding); const index = await this.readIndex(); const current = await this.getInstalledFromIndex( index, @@ -713,6 +787,7 @@ export class PluginPackageStore { } if (item.action === 'keep') { if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release'); + this.assertBinding(binding); return { status: 'kept', pluginId, @@ -738,6 +813,7 @@ export class PluginPackageStore { if (existingRecord && existingRecord.sha256 === item.sha256 && await isDirectoryPath(this.releaseDirectory(existingRecord))) { const existing = await this.getInstalledFromIndex(index, pluginId, item.releaseId); if (existing && !existing.unavailableReason) { + this.assertBinding(binding); await this.setCurrentSelection( await this.readCurrentSelection(), pluginId, @@ -801,12 +877,13 @@ export class PluginPackageStore { const extractedPath = path.join(staging, 'package'); await writeFile(archivePath, toBuffer(artifact), { flag: 'wx' }); await mkdir(extractedPath, { recursive: true }); - await extractArchive(artifact, extractedPath, { + const archiveFiles = await extractArchive(artifact, extractedPath, { maxExtractedBytes: this.maxExtractedBytes, maxFiles: this.maxFiles, maxFileBytes: this.maxFileBytes, }); const definition = await this.loadDefinition(extractedPath, grant, descriptor); + validateArchiveDefinition(archiveFiles, definition); this.assertBinding(binding); await mkdir(path.dirname(packageRoot), { recursive: true }); if (await isDirectoryPath(packageRoot) || await pathExists(packageRoot)) { @@ -817,12 +894,15 @@ export class PluginPackageStore { moved = true; const record = this.installedRecord(grant, channel); const records = index.releases.filter((candidate) => !(candidate.pluginId === pluginId && candidate.releaseId === grant.releaseId)); + this.assertBinding(binding); try { await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: [...records, record] })); } catch { throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed'); } + this.assertBinding(binding); await this.setCurrentSelection(await this.readCurrentSelection(), pluginId, record.releaseId); + this.assertBinding(binding); return { status: 'installed', pluginId, @@ -869,6 +949,7 @@ export class PluginPackageStore { const definition = await this.loadDefinition(input.packageRoot, input.grant, descriptor); this.assertBinding(input.binding); const record = this.installedRecord(input.grant, input.channel); + this.assertBinding(input.binding); try { await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, @@ -877,7 +958,9 @@ export class PluginPackageStore { } catch { throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed'); } + this.assertBinding(input.binding); await this.setCurrentSelection(input.currentSelection, input.pluginId, record.releaseId); + this.assertBinding(input.binding); return { status: 'installed', pluginId: input.pluginId, @@ -933,12 +1016,18 @@ export class PluginPackageStore { }); } - private async removeUnusedLocked(validated: string): Promise { + private async removeUnusedLocked( + validated: string, + binding?: AccountBinding, + mode: 'background' | 'explicit' = 'background', + ): Promise { + if (binding) this.assertBinding(binding); const index = await this.readIndex(); const currentSelection = await this.readCurrentSelection(); const records = index.releases.filter((record) => record.pluginId === validated); if (records.length === 0) { if (currentSelection.current[validated] !== undefined) { + if (binding) this.assertBinding(binding); const current = { ...currentSelection.current }; delete current[validated]; await this.writeCurrentSelection({ @@ -951,21 +1040,36 @@ 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 = selected && records.length > 1 - ? records.filter((record) => record.releaseId !== selected.releaseId && !protectedIds.has(record.releaseId)) - : records.filter((record) => !protectedIds.has(record.releaseId)); + const removable = mode === 'explicit' + ? records.filter((record) => !protectedIds.has(record.releaseId)) + : records.length === 1 && !protectedIds.has(selected.releaseId) + ? records + : records.filter((record) => ( + record.releaseId !== selected.releaseId && !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' }; + 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 { @@ -979,15 +1083,21 @@ export class PluginPackageStore { schema_version: CURRENT_SELECTION_SCHEMA_VERSION, current: Object.freeze(nextCurrent), }); + if (binding) this.assertBinding(binding); await Promise.all(removable.map(async (record) => { await rm(this.releaseDirectory(record), { recursive: true, force: true }); })); + if (binding) this.assertBinding(binding); const currentAfter = nextCurrent[validated] ? remaining.find((record) => record.pluginId === validated && record.releaseId === nextCurrent[validated]) : undefined; const selectedAfter = selected && remaining.some((record) => ( record.pluginId === validated && record.releaseId === selected.releaseId )) ? selected : undefined; + if (mode === 'explicit' && !currentAfter + && remaining.some((record) => record.pluginId === validated && protectedIds.has(record.releaseId))) { + return { status: 'kept', pluginId: validated, reason: 'active_worker_reference' }; + } const reportedAfter = currentAfter ?? selectedAfter ?? remaining.find((record) => record.pluginId === validated); if (reportedAfter) { return { diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs index 7f11ac1..7b1e6d7 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -318,20 +318,22 @@ export function verifyMarketplaceClientArtifact(appAsarContents, productionTrust } /** - * Read the Marketplace trust source from the packaged application itself. - * Checkout sources are not evidence of what an installed app will trust. + * Read the package.main-reachable application graph from the packaged app. + * Checkout sources and unrelated app.asar files are not product evidence. */ -export async function readPackagedMarketplaceTrustSource(appAsar) { +async function readPackagedApplicationGraph(appAsar) { 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'); + const readArchiveText = (entry) => { + if (!entries.has(entry)) throw new Error(`Packaged app.asar entry is missing: ${entry}`); + return 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'); + } catch (error) { + const reason = error instanceof Error ? error.message : 'unknown error'; + throw new Error(`Packaged app.asar package.json is unreadable: ${reason}`); } if (typeof packageJson.main !== 'string' || packageJson.main.trim().length === 0) { throw new Error('Packaged app.asar package.json.main is missing'); @@ -348,9 +350,10 @@ export async function readPackagedMarketplaceTrustSource(appAsar) { } return result.join('/'); }; - const resolveModule = (from, specifier) => { + const resolveEntry = (from, specifier) => { if (!specifier.startsWith('.')) return null; - const base = normalizeAsarPath(`${dirname(from).replaceAll('\\', '/')}/${specifier}`); + const withoutSuffix = specifier.split(/[?#]/u, 1)[0]; + const base = normalizeAsarPath(`${dirname(from).replaceAll('\\', '/')}/${withoutSuffix}`); if (!base) return null; const candidates = [base, `${base}.js`, `${base}.mjs`, `${base}.cjs`, `${base}/index.js`]; return candidates.find((candidate) => entries.has(candidate)) ?? null; @@ -372,16 +375,28 @@ export async function readPackagedMarketplaceTrustSource(appAsar) { } reachable.push({ entry, source }); const specifiers = []; - const importPattern = /(?:import|export)\s+(?:[\s\S]*?\sfrom\s*)?['"]([^'"]+)['"]/gu; + const importPattern = /\b(?:import|export)(?:[^"'`;]*?\bfrom)?\s*['"]([^'"]+)['"]/gu; + const dynamicImportPattern = /\bimport\(\s*['"]([^'"]+)['"]\s*\)/gu; const requirePattern = /\brequire\(\s*['"]([^'"]+)['"]\s*\)/gu; + const htmlEntryPattern = /['"]([^'"]+\.html(?:[?#][^'"]*)?)['"]/gu; + const htmlAssetPattern = /\b(?:src|href)\s*=\s*['"]([^'"]+)['"]/gu; for (const match of source.matchAll(importPattern)) specifiers.push(match[1]); + for (const match of source.matchAll(dynamicImportPattern)) specifiers.push(match[1]); for (const match of source.matchAll(requirePattern)) specifiers.push(match[1]); + for (const match of source.matchAll(htmlEntryPattern)) specifiers.push(match[1]); + if (entry.endsWith('.html')) { + for (const match of source.matchAll(htmlAssetPattern)) specifiers.push(match[1]); + } for (const specifier of specifiers) { - const resolved = resolveModule(entry, specifier); + const resolved = resolveEntry(entry, specifier); if (resolved) pending.push(resolved); } } + return { packageJson, reachable }; +} + +function findPackagedMarketplaceTrustSource(reachable) { for (const { source } of reachable) { const trustAssignment = source.match( /CODE_OWNED_PLUGIN_SIGNING_KEYS\s*=\s*Object\.freeze\(\s*\{\}\s*(?:as\s+[^)]*)?\)/u, @@ -398,6 +413,31 @@ export async function readPackagedMarketplaceTrustSource(appAsar) { throw new Error('Packaged app.asar does not contain the Marketplace trust source'); } +/** + * Read the Marketplace trust source from the packaged application itself. + * Checkout sources are not evidence of what an installed app will trust. + */ +export async function readPackagedMarketplaceTrustSource(appAsar) { + const { reachable } = await readPackagedApplicationGraph(appAsar); + return findPackagedMarketplaceTrustSource(reachable); +} + +/** + * Verify Marketplace contract markers only in the package.main-reachable app + * graph. An arbitrary string elsewhere in app.asar is not evidence that the + * installed Main/Renderer contract is present. + */ +export async function verifyPackagedMarketplaceClientArtifact(appAsar) { + const { reachable } = await readPackagedApplicationGraph(appAsar); + const reachableContents = Buffer.from(reachable.map(({ source }) => source).join('\n')); + const trustSource = findPackagedMarketplaceTrustSource(reachable); + if (!reachableContents.includes(Buffer.from(CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER))) { + throw new Error('Packaged app.asar reachable graph does not contain the Marketplace trust provenance marker'); + } + const verifiedTrustSource = `${trustSource}\n${CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER}`; + return verifyMarketplaceClientArtifact(reachableContents, verifiedTrustSource); +} + async function filesContainingNeedles(root, needles) { const matches = []; const visit = async (path) => { @@ -648,7 +688,6 @@ export async function verifyPiProductArtifact({ projectRoot, executable }) { throw new Error(`Pi runtime manifest contains absolute paths: ${JSON.stringify(absoluteManifestValues)}`); } const appAsarContents = await readFile(appAsar); - const marketplaceTrustSource = await readPackagedMarketplaceTrustSource(appAsar); const physicalOpenCodePaths = await collectForbiddenResourcePaths(resourcesDirectory); const asarOpenCodePaths = collectForbiddenAsarPaths(appAsar); const openCodeResourcePaths = classifyOpenCodeResourcePaths([ @@ -666,7 +705,7 @@ export async function verifyPiProductArtifact({ projectRoot, executable }) { resourcesDirectory, appAsarContents, }); - const marketplace = verifyMarketplaceClientArtifact(appAsarContents, marketplaceTrustSource); + const marketplace = await verifyPackagedMarketplaceClientArtifact(appAsar); const actualSkills = bundledPluginResources.coreResources.skills; const missingExtensionMarkers = EXTENSION_CONTRACT_MARKERS.filter( (marker) => !appAsarContents.includes(Buffer.from(marker)), diff --git a/src/pages/MyPlugins/index.tsx b/src/pages/MyPlugins/index.tsx index c1e8269..486d601 100644 --- a/src/pages/MyPlugins/index.tsx +++ b/src/pages/MyPlugins/index.tsx @@ -64,26 +64,27 @@ export function MyPluginsView(props: MyPluginsViewProps) {
{props.library?.items.map((plugin) => { const installed = props.installations[plugin.pluginId]; + const hasDevicePackage = Boolean(installed?.version); const removed = plugin.removedAt !== null; const suspended = plugin.runtimeStatus === 'suspended'; const retired = plugin.catalogStatus === 'retired'; - const installedChannel = installed?.channel ?? 'stable'; + const installedChannel = hasDevicePackage ? installed?.channel ?? 'stable' : '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 updateAvailable = Boolean(hasDevicePackage && channelVersion && installed?.version !== channelVersion); + const betaChannelUnavailable = Boolean(hasDevicePackage && 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 ?? '不可用'}

{plugin.betaVersion ?

Beta 版 {plugin.betaVersion}

: null}{installed?.version ? <>

设备版本 {installed.version}

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

:

尚未下载到设备

}
+

{plugin.title}

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

{plugin.summary}

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

{plugin.betaVersion ?

Beta 版 {plugin.betaVersion}

: null}{hasDevicePackage ? <>

设备版本 {installed?.version}

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

:

尚未下载到设备

}
{suspended ?

运行已暂停

: null}{retired ?

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

: null}{betaChannelUnavailable ?

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

: null}{failure ?

{failure}

: null}
{removed ? - : !installed ? + : !hasDevicePackage ? : updateAvailable ? : 设备版本已是最新} {!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion && installedChannel !== 'beta' ? : null} {!removed && plugin.acquisition !== 'system_included' ? : null} - {installed ? : null} + {hasDevicePackage ? : null}
diff --git a/src/stores/plugin-marketplace.ts b/src/stores/plugin-marketplace.ts index 0026bcb..59a4d5e 100644 --- a/src/stores/plugin-marketplace.ts +++ b/src/stores/plugin-marketplace.ts @@ -93,12 +93,13 @@ export function createPluginMarketplaceStore( let catalogGeneration = 0; let libraryIntent = 0; let latestLibraryReadIntent = 0; - const latestPluginMutationIntent = new Map(); + const latestLibraryMutationIntent = new Map(); + const latestDeviceMutationIntent = new Map(); return createStore((set, get) => { - const beginMutation = (pluginId: string): number => { + const beginMutation = (pluginId: string, domain: 'library' | 'device'): number => { const intent = ++libraryIntent; - latestPluginMutationIntent.set(pluginId, intent); + (domain === 'library' ? latestLibraryMutationIntent : latestDeviceMutationIntent).set(pluginId, intent); return intent; }; @@ -107,12 +108,18 @@ export function createPluginMarketplaceStore( intent: number, generation: number, accountKey: string, + domain: 'library' | 'device', ): 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; + && (domain === 'library' ? latestLibraryMutationIntent : latestDeviceMutationIntent).get(pluginId) === intent + // A later explicit read is the newest Library projection, so an older + // Library mutation must not replace it. Device state is independent. + && (domain === 'device' || latestLibraryReadIntent <= intent); + + const latestPluginMutationIntent = (pluginId: string): number => Math.max( + latestLibraryMutationIntent.get(pluginId) ?? 0, + latestDeviceMutationIntent.get(pluginId) ?? 0, + ); const mergeLibraryForPlugin = ( current: MarketplaceLibrarySnapshot | null, @@ -125,8 +132,8 @@ export function createPluginMarketplaceStore( 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; + const leftIntent = latestPluginMutationIntent(left.pluginId); + const rightIntent = latestPluginMutationIntent(right.pluginId); return rightIntent - leftIntent; }); return { @@ -170,10 +177,10 @@ export function createPluginMarketplaceStore( const generation = scopeGeneration; const accountKey = get().accountKey; if (!accountKey) throw new Error('请先登录后管理我的插件'); - const intent = beginMutation(pluginId); + const intent = beginMutation(pluginId, 'library'); try { const projection = await action(pluginId); - if (!canCommitMutation(pluginId, intent, generation, accountKey)) return; + if (!canCommitMutation(pluginId, intent, generation, accountKey, 'library')) return; const merged = mergeLibraryProjection(pluginId, projection); set({ library: merged.library, @@ -182,7 +189,7 @@ export function createPluginMarketplaceStore( libraryError: null, }); } catch (error) { - if (canCommitMutation(pluginId, intent, generation, accountKey)) { + if (canCommitMutation(pluginId, intent, generation, accountKey, 'library')) { set({ libraryError: message(error) }); } throw error; @@ -197,10 +204,10 @@ export function createPluginMarketplaceStore( const generation = scopeGeneration; const accountKey = get().accountKey; if (!accountKey) throw new Error('请先登录后管理设备插件'); - const intent = beginMutation(pluginId); + const intent = beginMutation(pluginId, 'device'); try { const result = await action(pluginId); - if (!canCommitMutation(pluginId, intent, generation, accountKey)) return; + if (!canCommitMutation(pluginId, intent, generation, accountKey, 'device')) return; set((state) => { const installations = { ...state.installations }; if (result.status === 'removed') delete installations[pluginId]; @@ -208,7 +215,7 @@ export function createPluginMarketplaceStore( return { installations, libraryError: null }; }); } catch (error) { - if (canCommitMutation(pluginId, intent, generation, accountKey)) { + if (canCommitMutation(pluginId, intent, generation, accountKey, 'device')) { const reason = message(error); set((state) => ({ installations: { @@ -250,7 +257,8 @@ export function createPluginMarketplaceStore( scopeGeneration += 1; libraryIntent += 1; latestLibraryReadIntent = libraryIntent; - latestPluginMutationIntent.clear(); + latestLibraryMutationIntent.clear(); + latestDeviceMutationIntent.clear(); set({ accountKey, library: null, diff --git a/tests/e2e/plugin-marketplace.spec.ts b/tests/e2e/plugin-marketplace.spec.ts index 23d6249..d91fb14 100644 --- a/tests/e2e/plugin-marketplace.spec.ts +++ b/tests/e2e/plugin-marketplace.spec.ts @@ -61,6 +61,7 @@ test.describe('Plugin Marketplace', () => { const app = await launchElectronApp({ skipSetup: true }); try { const page = await getStableWindow(app); + await expect(page.getByTestId('ai-module-option-programming')).toBeVisible(); await app.evaluate(({ ipcMain }) => { let installedChannel: 'stable' | 'beta' | null = 'stable'; const installedVersion = '1.0.0'; diff --git a/tests/unit/coding-plugin-composition.test.ts b/tests/unit/coding-plugin-composition.test.ts index 51f779e..b1bbeef 100644 --- a/tests/unit/coding-plugin-composition.test.ts +++ b/tests/unit/coding-plugin-composition.test.ts @@ -139,6 +139,53 @@ describe('coding plugin bounded product service', () => { expect(result.items).not.toContainEqual(expect.objectContaining({ id: 'makelore.removed' })); }); + it('projects a client-incompatible installed package as unavailable', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-incompatible-')); + roots.push(root); + await createCodingProjectMetadata(root, { now: '2026-08-28T00:00:00.000Z' }); + await createCodingProjectAgent(root, { + id: 'builder', avatarId: 'avatar-01', roleName: '实现者', name: 'Builder', + model: null, modelResolution: 'required', skillIds: ['notes'], + responsibility: { mission: 'Build', owns: [], boundaries: [], collaborators: [], principles: [] }, + }); + const definition = { + ...DATA_SERVICE_PLUGIN_DEFINITION, + id: 'makelore.notes', displayName: 'Notes', description: 'Notes', + requiresBackend: false, runtimeKind: 'skill_only' as const, + acquisitionMode: 'user_acquired' as const, releaseId: 'release-notes', + provenance: { source: 'marketplace' as const, packageRoot: 'C:/packages/release-notes' }, + adapterId: '', operations: [], tools: [], + skills: [{ id: 'notes', entryPath: 'skills/notes/SKILL.md', grants: [] }], + surfaces: {}, + }; + const service = createCodingProjectPluginService({ + projects: { getProject: vi.fn().mockResolvedValue({ id: 'local-a', path: root }) }, + projectPlugins: { + getEnabledPluginIds: vi.fn().mockResolvedValue([definition.id]), setEnabled: vi.fn(), + }, + policyClient: { + refresh: vi.fn(), + getState: () => ({ status: 'unavailable' as const, revision: 0, lastVerifiedAt: null, catalog: null }), + }, + adapters: [], definitions: [definition], + effectiveResolver: { + resolve: vi.fn().mockResolvedValue({ + accountSessionId: 'account-a\u00001', projectId: 'local-a', + pluginReleaseIds: [], effectiveSkillIds: [], skillEntries: [], + toolDefinitions: [], runtimePolicies: [], + unavailableReasons: [{ + pluginId: definition.id, code: 'client_incompatible', + message: 'Plugin Release is incompatible with this MakeLore client', + }], + }), + } as never, + }); + + await expect(service.list('local-a')).resolves.toMatchObject({ + items: [{ id: definition.id, enabled: true, state: 'unavailable' }], + }); + }); + it('projects the exact three capabilities and fourteen mixed-policy operations', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-policy-join-')); roots.push(root); diff --git a/tests/unit/coding-plugin-effective-resolver.test.ts b/tests/unit/coding-plugin-effective-resolver.test.ts index 7687b20..5c97443 100644 --- a/tests/unit/coding-plugin-effective-resolver.test.ts +++ b/tests/unit/coding-plugin-effective-resolver.test.ts @@ -5,7 +5,10 @@ import { createEffectivePluginResolver, type EffectivePluginResolver, } from '../../electron/coding-plugins/effective-resolver'; -import type { CodingPluginDefinition } from '../../shared/coding-plugins'; +import { + DATA_SERVICE_PLUGIN_DEFINITION, + type CodingPluginDefinition, +} from '../../shared/coding-plugins'; const skillOnlyDefinition: CodingPluginDefinition = { id: 'makelore.notes', @@ -205,6 +208,41 @@ describe('effective plugin resolver', () => { expect(result.unavailableReasons).toEqual([]); }); + it('reserves every bundled Skill owner, including Data Service, before accepting Marketplace packages', async () => { + const marketplaceDataServiceCollision: CodingPluginDefinition = { + ...skillOnlyDefinition, + id: 'makelore.data-service-shadow', + releaseId: 'data-service-shadow-1', + provenance: { source: 'marketplace', packageRoot: 'C:/packages/data-service-shadow-1' }, + skills: [{ id: 'data-service', entryPath: 'skills/data-service/SKILL.md', grants: [] }], + }; + const result = await createEffectivePluginResolver({ + definitions: [DATA_SERVICE_PLUGIN_DEFINITION, marketplaceDataServiceCollision], + getAccountBinding: () => binding, + getLibrary: vi.fn(async () => ({ + ...library(), + items: [{ ...library().items[0], pluginId: marketplaceDataServiceCollision.id }], + })), + getInstalled: vi.fn(async (pluginId: string) => pluginId === marketplaceDataServiceCollision.id + ? installed(marketplaceDataServiceCollision) + : null), + getEnabledPluginIds: vi.fn(async () => [ + DATA_SERVICE_PLUGIN_DEFINITION.id, + marketplaceDataServiceCollision.id, + ]), + }).resolve({ + projectId: 'project-a', + projectPath: 'C:/project-a', + assignedSkillIds: ['data-service'], + role: 'parent', + }); + + expect(result.pluginReleaseIds).toEqual([]); + expect(result.unavailableReasons).not.toEqual([ + expect.objectContaining({ pluginId: marketplaceDataServiceCollision.id }), + ]); + }); + 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 99a25c9..31404c2 100644 --- a/tests/unit/coding-plugin-marketplace-client.test.ts +++ b/tests/unit/coding-plugin-marketplace-client.test.ts @@ -86,7 +86,7 @@ function makeResolveResult(input: ResolveRequest, itemOverrides: Record> = {}, +): Buffer { const zip = new AdmZip(); - zip.addFile('plugin.json', Buffer.from(JSON.stringify({ + const files = new Map([ + ['plugin.json', JSON.stringify({ $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', name: PLUGIN_ID, version: '1.0.0', description: 'Example Skill', author: { name: 'MakeLore' }, extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } }, - }))); - zip.addFile('com.makelore/capability.json', Buffer.from(JSON.stringify({ + })], + ['com.makelore/capability.json', JSON.stringify({ schemaVersion: 2, pluginId: PLUGIN_ID, contractVersion: 1, @@ -115,8 +118,13 @@ function buildSkillOnlyArchive(): Buffer { runtime: { kind: 'skill_only' }, skills: [{ id: 'example-skill', entry: '../skills/example-skill/SKILL.md', grants: [] }], tools: [], - }))); - zip.addFile('skills/example-skill/SKILL.md', Buffer.from('# Example\n')); + })], + ['skills/example-skill/SKILL.md', '# Example\n'], + ]); + for (const [filePath, content] of Object.entries(overrides)) files.set(filePath, content); + for (const [filePath, content] of files) { + zip.addFile(filePath, Buffer.isBuffer(content) ? content : Buffer.from(content)); + } return zip.toBuffer(); } @@ -156,7 +164,7 @@ function signedGrant( sha256, signingKeyId: options.signingKeyId ?? 'test-key', descriptorSignature: signature, - expiresAt: '2026-08-29T00:00:00Z', + expiresAt: '2100-01-01T00:00:00Z', contentUrl: `/api/plugin-marketplace/v1/releases/${releaseId}/content?release_admission_id=${ADMISSION_ID}`, }, publicKey: publicKey.export({ type: 'spki', format: 'der' }) as Buffer, @@ -749,12 +757,87 @@ describe('PluginPackageStore', () => { 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.removeUnused(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('explicitly uninstalls every unprotected release without selecting by install time', 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: 'removed', pluginId: PLUGIN_ID, releaseId: 'release-current', + }); + await expect(store.readInstalledIndex()).resolves.toEqual([]); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull(); + await expect(readFile(path.join(temporaryRoot, 'current.json'), 'utf8')).resolves.toContain('"current":{}'); + }); + + it('removes an unprotected current Release while retaining only an active-worker Release', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const protectedRelease = signedGrant(archive, { releaseId: 'release-worker' }); + const selectedRelease = signedGrant(archive, { + releaseId: 'release-current', signingKeyId: 'test-key-current', + }); + let current = protectedRelease; + 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', protectedRelease.publicKey], + ['test-key-current', selectedRelease.publicKey], + ]), + clientVersion: '1.0.0', + }); + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + current = selectedRelease; + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + store.registerActiveWorker('release-worker'); + + await expect(store.uninstall(PLUGIN_ID)).resolves.toEqual({ + status: 'kept', pluginId: PLUGIN_ID, reason: 'active_worker_reference', + }); + await expect(store.readInstalledIndex()).resolves.toEqual([ + expect.objectContaining({ releaseId: 'release-worker' }), + ]); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull(); + }); + it('preserves the old release across download, signature, and extraction failures', async () => { temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); const oldArchive = buildSkillOnlyArchive(); @@ -886,6 +969,82 @@ describe('PluginPackageStore', () => { await expect(store.readInstalledIndex()).resolves.toEqual([]); }); + it('captures the account before queued package mutations execute', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const { grant, publicKey } = signedGrant(archive); + let binding: AccountBinding | null = ACCOUNT_A; + const firstResolve = deferred(); + const resolve = vi.fn(async (input: ResolveRequest) => ( + resolve.mock.calls.length === 1 + ? firstResolve.promise + : makeResolveResult(input, { sha256: grant.sha256, sizeBytes: grant.sizeBytes }) + )); + const marketplace: MarketplaceClient = { + resolve, + issueDownload: vi.fn(async () => grant), + downloadContent: async () => archive, + getCurrentAccountBinding: () => binding, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', publicKey]]), + getAccountBinding: () => binding, + }); + + const inFlight = store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + await vi.waitFor(() => expect(resolve).toHaveBeenCalledTimes(1)); + const queued = store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + binding = ACCOUNT_B; + firstResolve.resolve(makeResolveResult({ makeloreVersion: '1.0.0', channel: 'stable' })); + + await expect(inFlight).rejects.toMatchObject({ code: 'plugin_account_changed' }); + await expect(queued).rejects.toMatchObject({ code: 'plugin_account_changed' }); + expect(resolve).toHaveBeenCalledTimes(1); + await expect(store.readInstalledIndex()).resolves.toEqual([]); + }); + + it('does not let a queued uninstall switch accounts while waiting', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const { grant, publicKey } = signedGrant(archive); + let binding: AccountBinding | null = ACCOUNT_A; + const resolve = vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + sha256: grant.sha256, + sizeBytes: grant.sizeBytes, + })); + const marketplace: MarketplaceClient = { + resolve, + issueDownload: vi.fn(async () => grant), + downloadContent: async () => archive, + getCurrentAccountBinding: () => binding, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', publicKey]]), + getAccountBinding: () => binding, + }); + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + + const firstResolve = deferred(); + resolve.mockImplementationOnce(async () => firstResolve.promise); + const inFlight = store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + await vi.waitFor(() => expect(resolve).toHaveBeenCalledTimes(2)); + const queued = store.uninstall(PLUGIN_ID); + binding = ACCOUNT_B; + firstResolve.resolve(makeResolveResult({ makeloreVersion: '1.0.0', channel: 'stable' }, { + action: 'keep', + })); + + await expect(inFlight).rejects.toMatchObject({ code: 'plugin_account_changed' }); + await expect(queued).rejects.toMatchObject({ code: 'plugin_account_changed' }); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID }); + }); + it('rejects archive traversal before materializing a package', async () => { temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); const zip = new AdmZip(); @@ -912,6 +1071,68 @@ describe('PluginPackageStore', () => { await expect(store.readInstalledIndex()).resolves.toEqual([]); }); + it('enforces the canonical archive contract before installing a package', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const oversizedManifest = JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: PLUGIN_ID, + version: '1.0.0', + description: 'x'.repeat(256 * 1024), + author: { name: 'MakeLore' }, + extensions: { 'com.makelore': { capabilityManifest: './com.makelore/capability.json' } }, + }); + const cases = [ + { + name: 'unsupported asset extension', + archive: buildSkillOnlyArchive({ 'skills/example-skill/assets/run.exe': Buffer.from('not executable') }), + }, + { + name: 'non-UTF-8 Skill text', + archive: buildSkillOnlyArchive({ 'skills/example-skill/SKILL.md': Buffer.from([0xff, 0xfe, 0xfd]) }), + }, + { + name: 'non-UTF-8 capability manifest', + archive: buildSkillOnlyArchive({ 'com.makelore/capability.json': Buffer.from([0xff, 0xfe, 0xfd]) }), + }, + { + name: 'asset outside a declared Skill', + archive: buildSkillOnlyArchive({ 'skills/other-skill/notes.md': '# Hidden\n' }), + }, + { + name: 'oversized manifest', + archive: buildSkillOnlyArchive({ 'plugin.json': oversizedManifest }), + }, + { + name: 'oversized Skill', + archive: buildSkillOnlyArchive({ 'skills/example-skill/SKILL.md': Buffer.alloc(256 * 1024 + 1, 0x61) }), + }, + ]; + + for (const [index, scenario] of cases.entries()) { + const { grant, publicKey } = signedGrant(scenario.archive, { releaseId: `release-invalid-${index}` }); + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + releaseId: grant.releaseId, + sha256: grant.sha256, + sizeBytes: grant.sizeBytes, + })), + issueDownload: vi.fn(async () => grant), + downloadContent: async () => scenario.archive, + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', publicKey]]), + getAccountBinding: () => ACCOUNT_A, + }); + await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }), scenario.name) + .rejects.toMatchObject({ code: 'plugin_artifact_invalid' }); + await expect(store.readInstalledIndex()).resolves.toEqual([]); + } + }); + it('requires explicit beta selection and rejects content paths outside the server route', async () => { const fetcher = vi.fn().mockResolvedValue(response({ release_admission_id: ADMISSION_ID, diff --git a/tests/unit/pi-product-artifact.test.ts b/tests/unit/pi-product-artifact.test.ts index 1212aa1..4bd2f7e 100644 --- a/tests/unit/pi-product-artifact.test.ts +++ b/tests/unit/pi-product-artifact.test.ts @@ -4,6 +4,7 @@ import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { finished } from 'node:stream/promises'; import { afterEach, describe, expect, it } from 'vitest'; import { @@ -15,6 +16,7 @@ import { verifyBundledCodingPluginResources, defaultProductExecutable, readPackagedMarketplaceTrustSource, + verifyPackagedMarketplaceClientArtifact, validatePiArtifactMetadata, verifyMarketplaceClientArtifact, } from '../../scripts/lib/pi-product-artifact.mjs'; @@ -24,6 +26,11 @@ const roots: string[] = []; const PI_PACKAGE = '@earendil-works/pi-coding-agent'; const { createPackage } = createRequire(import.meta.url)('@electron/asar'); +async function createAsarFixture(source: string, archive: string) { + const output = await createPackage(source, archive); + await finished(output); +} + afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); @@ -149,7 +156,7 @@ describe('final Pi product artifact verification', () => { ); await writeFile(path.join(source, 'resources', 'opencode-runtime', 'legacy.js'), 'export {};'); const archive = path.join(root, 'app.asar'); - await createPackage(source, archive); + await createAsarFixture(source, archive); const inventory = collectForbiddenAsarPaths(archive); expect(inventory.entryCount).toBeGreaterThan(2); @@ -240,10 +247,11 @@ describe('final Pi product artifact verification', () => { await writeFile(path.join(source, 'dist-electron', 'main.js'), [ 'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});', 'const unrelatedConfiguration = process.env.NIANCODE_E2E;', + 'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";', 'export const marketplace = true;', ].join('\n')); const appAsar = path.join(root, 'app.asar'); - await createPackage(source, appAsar); + await createAsarFixture(source, appAsar); await expect(readPackagedMarketplaceTrustSource(appAsar)).resolves.toBe( 'CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({})', ); @@ -260,7 +268,7 @@ describe('final Pi product artifact verification', () => { 'function createTrust() { return Object.freeze({ get: load, sourceMarker: "makelore.plugin-trust.code-owned.v1" }); }', ].join('\n')); const appAsar = path.join(root, 'app.asar'); - await createPackage(source, appAsar); + await createAsarFixture(source, appAsar); await expect(readPackagedMarketplaceTrustSource(appAsar)).resolves.toContain( 'makelore.plugin-trust.code-owned.v1', ); @@ -289,10 +297,59 @@ describe('final Pi product artifact verification', () => { 'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";', ].join('\n')); const appAsar = path.join(root, 'app.asar'); - await createPackage(source, appAsar); + await createAsarFixture(source, appAsar); await expect(readPackagedMarketplaceTrustSource(appAsar)).rejects.toThrow('trust source'); }); + it('binds Marketplace route, Renderer, and effective markers to the package main graph', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-marketplace-contract-asar-')); + roots.push(root); + const source = path.join(root, 'source'); + await mkdir(path.join(source, 'dist-electron'), { recursive: true }); + await mkdir(path.join(source, 'dist', 'assets'), { 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({});', + 'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";', + 'loadFile("../dist/index.html");', + ].join('\n')); + await writeFile(path.join(source, 'dist', 'index.html'), [ + '', + ].join('\n')); + await writeFile(path.join(source, 'dist', 'assets', 'index.js'), 'import("./project-plugins.js");'); + await writeFile( + path.join(source, 'dist', 'assets', 'project-plugins.js'), + 'import{marketplace}from"./plugin-marketplace.js";export{marketplace};', + ); + await writeFile(path.join(source, 'dist', 'assets', 'plugin-marketplace.js'), [ + 'makelore-plugin-release.v1 skill_only plugin_signature_invalid signing key is not trusted', + '/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/', + 'effectiveSkillIds pluginReleaseIds', + '/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件', + ].join('\n')); + const appAsar = path.join(root, 'app.asar'); + await createAsarFixture(source, appAsar); + await expect(verifyPackagedMarketplaceClientArtifact(appAsar)).resolves.toMatchObject({ result: 'pass' }); + + const staleSource = path.join(root, 'stale-source'); + await mkdir(path.join(staleSource, 'dist-electron'), { recursive: true }); + await writeFile(path.join(staleSource, 'package.json'), JSON.stringify({ main: 'dist-electron/main.js' })); + await writeFile(path.join(staleSource, 'dist-electron', 'main.js'), [ + 'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});', + 'export const sourceMarker = "makelore.plugin-trust.code-owned.v1";', + ].join('\n')); + await writeFile(path.join(staleSource, 'dist-electron', 'stale-marketplace.js'), [ + 'makelore-plugin-release.v1 skill_only plugin_signature_invalid signing key is not trusted', + '/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/', + 'effectiveSkillIds pluginReleaseIds', + '/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件', + ].join('\n')); + const staleAsar = path.join(root, 'stale.asar'); + await createAsarFixture(staleSource, staleAsar); + await expect(verifyPackagedMarketplaceClientArtifact(staleAsar)) + .rejects.toThrow('Marketplace contract markers'); + }); + 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 c5cc9eb..fec96b3 100644 --- a/tests/unit/plugin-marketplace-pages.test.tsx +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -154,6 +154,25 @@ describe('My Plugins', () => { expect(screen.getByText('设备版本 1.5.0')).toBeVisible(); }); + it('does not present a no-version unavailable projection as an installed device package', () => { + render(); + + expect(screen.getByText('尚未下载到设备')).toBeVisible(); + expect(screen.getByRole('button', { name: '下载灵感笔记' })).toBeVisible(); + expect(screen.queryByRole('button', { name: '删除设备上的灵感笔记' })).not.toBeInTheDocument(); + expect(screen.queryByText('设备版本已是最新')).not.toBeInTheDocument(); + expect(screen.getByText(/当前 MakeLore 版本不兼容/)).toBeVisible(); + }); + it('honestly groups stable Main failure codes without claiming hidden authority', () => { const { rerender } = render( { expect(store.getState().installations['makelore.data']).toMatchObject({ status: 'installed' }); }); + it('merges same-plugin Library and device mutations in either completion order', async () => { + for (const order of ['library-then-device', 'device-then-library'] as const) { + 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.notes'); + const installed = { status: 'installed' as const, pluginId: 'makelore.notes', version: '1.0.0' }; + const acquired = { library: library('Notes acquired'), installations: [] }; + if (order === 'library-then-device') { + acquire.resolve(acquired); await libraryMutation; + install.resolve(installed); await deviceMutation; + } else { + install.resolve(installed); await deviceMutation; + acquire.resolve(acquired); await libraryMutation; + } + expect(store.getState().library?.items[0]?.title).toBe('Notes acquired'); + expect(store.getState().installations['makelore.notes']).toMatchObject(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' });