From 1614f7efc1fc81efc3b4d1c80827948976f1427b Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Fri, 28 Aug 2026 21:01:51 +0800 Subject: [PATCH] fix: close marketplace client review findings --- ...-marketplace-mlm06-remediation-6d3a9c82.md | 255 ++++++++++++ README.md | 2 + electron/api/coding-product-services.ts | 52 ++- electron/api/routes/plugin-marketplace.ts | 32 +- .../coding-plugins/account-plugin-cache.ts | 15 + electron/coding-plugins/effective-resolver.ts | 10 +- electron/coding-plugins/marketplace-client.ts | 89 ++++- electron/coding-plugins/package-store.ts | 362 ++++++++++++++---- electron/coding-plugins/registry.ts | 16 +- electron/coding-plugins/trusted-keys.ts | 2 + electron/coding-runtime/pi/extension-host.ts | 1 + electron/coding-runtime/pi/resource-loader.ts | 31 +- scripts/lib/pi-product-artifact.mjs | 42 +- src/lib/host-api.ts | 7 +- src/lib/plugin-marketplace.ts | 7 + src/pages/MyPlugins/index.tsx | 21 +- src/stores/plugin-marketplace.ts | 52 ++- .../coding-plugin-marketplace-client.test.ts | 151 +++++++- tests/unit/coding-product-services.test.ts | 8 +- tests/unit/host-api.test.ts | 26 ++ tests/unit/pi-product-artifact.test.ts | 48 ++- tests/unit/pi-resource-loader.test.ts | 27 ++ tests/unit/plugin-marketplace-pages.test.tsx | 28 +- tests/unit/plugin-marketplace-routes.test.ts | 19 +- tests/unit/plugin-marketplace-store.test.ts | 64 ++++ 25 files changed, 1224 insertions(+), 143 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260828-marketplace-mlm06-remediation-6d3a9c82.md diff --git a/.project-docs/30-worklog/tasks/20260828-marketplace-mlm06-remediation-6d3a9c82.md b/.project-docs/30-worklog/tasks/20260828-marketplace-mlm06-remediation-6d3a9c82.md new file mode 100644 index 0000000..65964e9 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260828-marketplace-mlm06-remediation-6d3a9c82.md @@ -0,0 +1,255 @@ +# Task: Marketplace MLM-06 fixed-range remediation + +## Identity + +- Task ID: 20260828-marketplace-mlm06-remediation-6d3a9c82 +- Mode: Feature +- Branch: codex/20260828-marketplace-mlm06-remediation-6d3a9c82-marketplace-mlm06-remediation +- Worktree: D:\Datas\OthersProjects\makelore-plugin-marketplace-client-mlm06-remediation-6d3a9c82 +- Base commit: 8dfa5428606076b847966750134061de6fbe91ba +- Owner: marketplace-mlm06-remediator +- Status: Ready for Integration + +## Scope + +- Implement the sole MLM-06 fixed-range remediation from the exact reviewed + client head `8dfa5428606076b847966750134061de6fbe91ba`. +- Close all 11 deduplicated actionable roots from the Standards and Spec reviews: + skill/package-root pairing; explicit device uninstall; packaged trust proof; + precise package-store orphan recovery; same-account Library read ordering; + README architecture/product synchronization; whole-response Marketplace + deadlines; cached-release rollback current selection; cross-plugin mutation + intent ordering; explicit-beta Main/My Plugins action; and bounded yanked, + incompatible, signature, and unavailable projections. +- Own only the remediation seams and their focused tests: `electron/coding-plugins` + registry/resource loading, Package Store/account cache/marketplace client and + lifecycle seams; the packaged artifact verifier and proof; Main Marketplace + beta/status projection; Renderer Marketplace/My Plugins stores/facades/pages; + `README.md`; and this task record. Preserve all unrelated changes already in + the coordinator product head. + +## Intent And Constraints + +- Use one isolated feature worktree and one source commit. The source commit must + have sole parent `8dfa5428606076b847966750134061de6fbe91ba`, be clean, and end + in `READY_FOR_INTEGRATION` after task documentation gates. +- Keep every mutation behind the existing Main Host API and closed DTOs. Do not + add Provider/hosted runtime, billing, Token Point, arbitrary code/MCP/hooks, + project schema changes, or XMA-01/live/deployment work. +- Preserve the single frozen effective snapshot. Every Skill entry must remain + paired with its verified package root through resource loading, bridge, CLI, + and lifecycle consumers; no compatibility union or first-match root fallback. +- Explicit device uninstall is distinct from Library remove: it may remove an + unprotected installed package, while active workers and account snapshots keep + their releases protected and Library rows remain intact. +- Packaged trust proof must inspect the actual packaged `app.asar`/build artifact + trust source and prove no private key; checkout source files are not evidence. +- Orphan recovery may reuse/remove only the exact, fully validated matching + plugin/release package left by the interrupted rename/index sequence. +- Same-account Library reads use current selection intent/generation; the + different-plugin mutation race uses its own request intent/epoch allocated + before the request. Account A/B isolation, duplicate coalescing, and explicit + beta/current-versus-stable semantics remain intact. +- Request deadlines cover both Marketplace response headers and body consumption + under one overall Abort/deadline. Rollback changes the Package Store current + selection while retaining immutable releases and active-worker freeze. +- UI changes stay in the existing light design system, use bounded user copy and + explicit transitions/hit targets, and record Before/After interface impact in + this task record. Keep the official platform public-key production activation + hold explicit. + +## Project Context Loaded + +Task context: +- Task ID: `20260828-marketplace-mlm06-remediation-6d3a9c82` +- Mode: Feature +- Branch: `codex/20260828-marketplace-mlm06-remediation-6d3a9c82-marketplace-mlm06-remediation` +- Worktree: `D:\Datas\OthersProjects\makelore-plugin-marketplace-client-mlm06-remediation-6d3a9c82` +- Base commit: `8dfa5428606076b847966750134061de6fbe91ba` +- Other active local tasks: Marketplace coordinator; earlier MLM-01 through + MLM-05 records/worktrees; MLM-06 Standards and Spec fixed-range review records. +- Overlap or semantic-conflict assessment: Overlap is intentional and limited to + the listed review-accepted seams. No peer may write this remediation worktree, + and no semantic conflict remains with the accepted Release A boundary. The + same-account Library read race and different-plugin mutation race are separate + state-intent problems with separate tests and guards. User root worktrees and + all server/Operations worktrees remain out of scope. + +Read: +- `C:\Users\7brot\.agents\skills\implement-spec\SKILL.md` +- `C:\Users\7brot\.codex\skills\maintain-project-docs\SKILL.md` +- `C:\Users\7brot\.codex\skills\make-interfaces-feel-better\SKILL.md` and + `typography.md`, `surfaces.md`, `animations.md`, `performance.md` +- `D:\Datas\OthersProjects\makelore\AGENTS.md`, project-memory startup set, + architecture/domain/evidence/reflection/commitment/stale indexes, and peer + task records +- canonical Marketplace implementation spec, ticket graph, and accepted design + proposal in `D:\Datas\PythonProjects\works-square-server-plugin-marketplace-spec-7c2e9a41` + +Relevant understanding: +- Main owns authentication, filesystem, Marketplace access, package trust, + install paths, release/admission authority, and bounded error projection; + Renderer consumes closed safe projections only. +- Release A separates Operations publication, account Library, device install, + project selection, Agent assignment, runtime authorization, and billing. + Acquire/download/install/update/enable/assignment never write Token Points. +- Schema-v1 bundled Data Service and schema-v2 declarative `skill_only` remain + the only active runtime paths. Release B Provider/hosted/charges is closed. +- The coordinator head already passed the first fixed-range reviews except the + listed review roots. This task must preserve its single effective resolver, + frozen worker snapshots, account isolation, and current UI contract. + +Gate result: +- Concurrent Task Gate: Passed. `check_project_docs.py` passed; task-context + ownership exactly matches the task ID, owner, feature mode, branch, isolated + worktree, and exact base. +- Planning Gate: Passed. Required memory/spec/design/peer scope was read; the + accepted review findings provide disjoint, explicit ownership and no unresolved + semantic conflict blocks the remediation plan. + +## Implementation Plan + +1. Add failing tests for each accepted root at its existing seam, including + duplicate relative Skill paths, install/delete protection states, app.asar + trust proof, exact orphan recovery, header/body timeout, rollback projection, + same-account read intent, cross-plugin mutation epochs, explicit beta/status + projections, and bounded UI copy/routes. +2. Implement the smallest cohesive fixes in registry/resource loader, package + store/account cache/marketplace client, verifier, Main projection, and + Renderer store/pages. Keep immutable release and account/admission boundaries. +3. Run focused and adjacent unit suites after each red/green seam, then typecheck, + lint, Vite build, Marketplace E2E, Windows Electron/package/artifact proof, + and document drift. Record unrelated baseline failures without widening scope. +4. Update outcome/verification and complete task-context only after a clean single + source commit with the exact sole parent. + +## Outcome + +- Implemented the complete MLM-06 fixed-range remediation in the isolated client + worktree without changing the coordinator or user root worktrees. The single + frozen effective snapshot now preserves each marketplace Skill with its + verified package root, and the same pairing flows through resource loading, + Extension Host registration, CLI/context, and lifecycle consumers. Package + Store state now has an explicit current-release selection, exact orphan + recovery, explicit device uninstall, active-worker protection, and account + resolve-cache invalidation without changing Library membership. Marketplace + body and header reads share one bounded deadline; Main exposes the explicit + beta action and bounded release/library/runtime errors; Renderer state keeps + same-account reads and cross-plugin mutations ordered by their own intent + generations. The packaged proof reads the actual app.asar trust seam and + records a code-owned, empty, fail-closed trust table with no private-key or + runtime-key override. README now documents the Marketplace/My Plugins/Project + Plugins separation and Main/Package Store/effective-runtime architecture. + +### Fixed-range roots + +1. Skill/package-root pairing: effective snapshot entries retain the verified + package root; resource loading no longer selects the first matching root, and + collision coverage proves two same-relative-path Skills stay isolated. +2. Device uninstall: explicit uninstall removes only the unprotected device + package and its account resolve-cache entry; Library rows remain, active + workers and snapshots remain protected, and a later resolve can reinstall. +3. Packaged trust proof: verifier reads compiled JavaScript from the actual + app.asar, recognizes the code-owned provenance marker plus empty frozen + table, and rejects environment/private-key material. The official production + Ed25519 public key is still an explicit activation HOLD. +4. Orphan recovery: a crash left between release-directory rename and index + write recovers only the exact plugin/release directory with its matching + archive and fully validated descriptor/artifact/signature/compatibility; + unrelated or incomplete directories remain conflicts. +5. Same-account Library reads: generation and mutation-epoch guards prevent an + older response from replacing a newer read intent while preserving duplicate + request coalescing. +6. Documentation: README covers Marketplace, My Plugins, Project Plugins, + Main, Package Store, effective snapshots, skill-only scope, and production + key activation HOLD. +7. Request deadline: Marketplace headers and bounded body consumption execute + under one Abort/deadline, including never-body response coverage. +8. Rollback selection: cached immutable releases switch an explicit Package + Store current pointer; projections and next workers use that pointer rather + than installed-time ordering, while active workers stay frozen. +9. Library mutation ordering: each mutation allocates its request intent before + sending; stale responses cannot overwrite newer different-plugin mutations, + with both completion orders and account A/B isolation covered. +10. Explicit beta: Main has a real beta route/facade and My Plugins exposes a + user-triggered Beta action only; beta never follows stable rollout buckets + or auto-acquires a release. +11. Bounded status projection: yanked, incompatible, signature, artifact, + runtime-suspended, library-required, and backend-unavailable results remain + distinct through Main/Host/Renderer routes and bounded My Plugins copy, + including cached/unavailable Library entries. + +### Interface impact + +- Before: My Plugins and Project Plugins could expose stale or ambiguous + installation state, and release errors collapsed into generic unavailable + messages; Marketplace requests could hang after headers; Beta had no explicit + Main action. +- After: the existing light design system presents explicit Beta, install, + unavailable, and rollback-aware states with bounded Chinese copy, stable hit + targets, and independent loading/mutation transitions. No new authority, + account identity, filesystem path, or open-ended runtime payload crosses into + Renderer. + +## Verification + +- Planning/concurrency gates: `check_project_docs.py` passed; task-context + identity matched task ID, owner, feature mode, branch, isolated worktree, and + exact base `8dfa5428606076b847966750134061de6fbe91ba`. +- First natural RED: the five review-targeted suites reported **33 passed / 9 + failed**; failures covered unpaired same-entryPath Skill roots, uninstall + remaining kept, missing packaged trust reader, never-body deadline, and stale + intent commits. No source implementation was treated as green before these + failures were observed. +- Focused GREEN checkpoints: initial package/resource seams **18 + 8 passed**; + final fixed-range focused run **8 files / 82 passed**; post-marker trust and + contract run **2 files / 30 passed**; the broader focused checkpoint was **9 + files / 101 passed**. Adjacent Marketplace/Pi/Host/Package Store suites were + **13 files / 66 passed**. +- `pnpm exec vitest run --exclude tests/unit/coding-chat-pressure.test.tsx + --maxWorkers=1`: **208 files / 1779 passed / 2 skipped**. The repository + `pnpm test` 4-worker attempt reached **207 files / 1769 passed / 2 skipped** + before one Vitest worker exited unexpectedly without an assertion failure; + the serial rerun is the final full-unit evidence. +- `pnpm exec vitest run tests/unit/coding-chat-pressure.test.tsx + --maxWorkers=1`: **1 file / 1 passed**. +- `pnpm run typecheck`: passed after the final trust-marker change. + `pnpm run lint:check`: **0 errors**, five existing warnings in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `pnpm run build:vite`: passed for Renderer, Main, Preload, and utility + bundles; only existing dynamic-import/chunk-size warnings. The final + `pnpm run package:stage:win-x64` also passed with **130 packages / 6 assets**. +- `pnpm run test:e2e`: **27 passed / 1 failed**. Marketplace catalog/detail + flow and Project Plugins passed. The one unchanged baseline failure is + `tests/e2e/pi-coding-first-chat.spec.ts:539`: the existing `当前对话模型` + combobox remained disabled during `selectOption` until its 30-second timeout; + no Marketplace test failed. +- `pnpm run test:electron:windows`: **2 files / 6 passed**. +- `pnpm run package:win`: Python win32-x64 and x64 staging were successful, but + the required win32-arm64 uv download from GitHub failed with + `UND_ERR_CONNECT_TIMEOUT`; this is recorded as an environment deviation and + is not claimed as a full package:win pass. Direct Electron Builder then + produced the x64 unpacked/NSIS artifact successfully. +- `pnpm run verify:artifact:pi`: passed against the actual fresh Windows + `app.asar`; Marketplace proof reports + `official-key-absent-fail-closed`, `productionKeyIds=[]`, and + `privateKeyMaterialInTrustSource=false`, with packaged Pi closure, bundled + Data Service resources, extension markers, and runtime probe passing. The + verifier no longer reads checkout `trusted-keys.ts`. +- Pre-commit source checks: `git diff --check` and the fixed-range forbidden + source scan passed. No Provider/hosted runtime, billing, Token Point, + arbitrary code/MCP/hooks, publication, XMA-01, push, PR, or deploy was added. + +## Follow-ups + +- Official platform Ed25519 public key material has not been supplied; keep + production activation on HOLD. Tests use only injected temporary key stores, + and no production private key was generated or committed. +- The full multi-architecture `package:win` remains dependent on the external + GitHub win32-arm64 uv download; x64 artifact evidence is separate and + complete. Keep the known disabled-model-combobox E2E baseline visible until + its owning runtime test is fixed. + +## Promotion Candidates + +- None recorded. diff --git a/README.md b/README.md index 8045cfb..a708fd7 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - AI 绘画项目栏会在当前项目下保留会话历史,默认显示最近五条消息摘要和更新时间,更多会话可展开;新建或点击历史条目都在同一项目中切换并恢复完整对话。 - AI 绘画项目支持从侧栏删除。确认删除时必须完整输入项目名称;删除后项目及其会话、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。服务端采用软删除,数据库记录和对象存储暂不物理清理。未提交的任务会被取消并释放预留积分,已提交或运行中的任务继续后台结算但对用户隐藏;删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 - AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始 HTML 被禁用,Markdown 图片节点直接加载服务端校验后的无凭据 HTTPS URL,包括 SVG 和 Electron 支持的其他图片格式,不经过服务端下载、识别、转码或镜像。下载按钮打开系统保存对话框,Main 不按 `Content-Length`、声明字节数或客户端上限阻断下载,流式校验 SHA-256 与 ZIP 签名后原子保存。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。 +- 插件市场 Release A:插件中心(Marketplace)提供运营精选的免费 `skill_only` 插件;“免费获取”只写入账号 Library,“下载/更新”才写入本机 Package Store,“启用到项目”和“分配给伙伴”仍是 Project Plugins 中彼此独立的动作。我的插件(My Plugins)展示账号获取状态、本机安装/更新/删除设备包、移除后的 tombstone 和 bounded unavailable reason;Project Plugins 继续只修改项目选择,不会因获取或下载自动启用或分配。 +- 插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。下一代 Pi worker 使用同一个 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 - 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 会话输入框,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 - 字体系统:Renderer UI 内嵌 Inter Variable 与经过字符子集化的 Source Han Sans SC WOFF2,按字符范围统一中英文并保留系统中文字体 fallback;代码、路径和日志使用独立等宽字体。 diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 048b2bf..f67bc84 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -101,6 +101,7 @@ export interface CodingPluginMarketplaceService { acquire(pluginId: string): Promise; remove(pluginId: string): Promise; install(pluginId: string): Promise; + installBeta(pluginId: string): Promise; update(pluginId: string): Promise; uninstall(pluginId: string): Promise; } @@ -127,25 +128,33 @@ function publicInstallation(snapshot: InstallationSnapshot): PublicPluginInstall async function publicLibrary( library: MarketplaceLibrarySnapshot, - packageStore: Pick, + packageStore: Pick, ): Promise { - const libraryPluginIds = new Set(library.items.map(({ pluginId }) => pluginId)); - const latest = new Map>[number]>(); - for (const record of await packageStore.readInstalledIndex()) { - if (!libraryPluginIds.has(record.pluginId)) continue; - const current = latest.get(record.pluginId); - if (!current || record.installedAt >= current.installedAt) latest.set(record.pluginId, record); - } - return { - library, - installations: [...latest.values()] - .sort((left, right) => left.pluginId.localeCompare(right.pluginId)) - .map((record) => ({ - status: 'installed', + const installations = await Promise.all(library.items.map(async ({ pluginId }) => { + try { + const record = await packageStore.getInstalled(pluginId); + return record ? { + status: 'installed' as const, pluginId: record.pluginId, releaseId: record.releaseId, version: record.version, - })), + } : null; + } catch (error) { + const candidate = error && typeof error === 'object' && 'code' in error + && typeof error.code === 'string' + ? error.code + : ''; + const reason = /^[a-z][a-z0-9_]{0,63}$/u.test(candidate) + ? candidate + : 'plugin_backend_unavailable'; + return { status: 'unavailable' as const, pluginId, reason }; + } + })); + return { + library, + installations: installations + .filter((installation): installation is NonNullable => installation !== null) + .sort((left, right) => left.pluginId.localeCompare(right.pluginId)), }; } @@ -161,8 +170,18 @@ export function createCodingPluginMarketplaceService( await options.onChanged?.({ pluginId, kind }); return publicInstallation(snapshot); }; + const installBeta = async (pluginId: string): Promise => { + const snapshot = await options.packageStore.resolveAndInstall({ + pluginId, + makeloreVersion: options.clientVersion, + channel: 'beta', + explicitBeta: true, + }); + await options.onChanged?.({ pluginId, kind: 'install' }); + return publicInstallation(snapshot); + }; const uninstall = async (pluginId: string): Promise => { - const snapshot = await options.packageStore.removeUnused(pluginId); + const snapshot = await options.packageStore.uninstall(pluginId); await options.onChanged?.({ pluginId, kind: 'uninstall' }); return publicInstallation(snapshot); }; @@ -181,6 +200,7 @@ export function createCodingPluginMarketplaceService( return publicLibrary(snapshot, options.packageStore); }, install: (pluginId) => install(pluginId, 'install'), + installBeta, update: (pluginId) => install(pluginId, 'update'), uninstall, }; diff --git a/electron/api/routes/plugin-marketplace.ts b/electron/api/routes/plugin-marketplace.ts index f9b6a25..35ce641 100644 --- a/electron/api/routes/plugin-marketplace.ts +++ b/electron/api/routes/plugin-marketplace.ts @@ -12,8 +12,21 @@ const ROOT = '/api/coding/plugin-marketplace'; const DETAIL = /^\/api\/coding\/plugin-marketplace\/plugins\/([^/]+)$/u; const LIBRARY_MUTATION = /^\/api\/coding\/plugin-marketplace\/library\/([^/]+)$/u; const INSTALLATION = /^\/api\/coding\/plugin-marketplace\/install\/([^/]+)$/u; +const BETA_INSTALLATION = /^\/api\/coding\/plugin-marketplace\/install\/([^/]+)\/beta$/u; const UPDATE = /^\/api\/coding\/plugin-marketplace\/update\/([^/]+)$/u; const PLUGIN_ID = /^[a-z][a-z0-9.-]{0,127}$/u; +const BOUNDED_MARKETPLACE_CODES = new Set([ + 'plugin_auth_required', + 'plugin_account_changed', + 'plugin_library_required', + 'plugin_release_not_ready', + 'plugin_release_yanked', + 'plugin_incompatible_client', + 'plugin_signature_invalid', + 'plugin_artifact_invalid', + 'plugin_runtime_suspended', + 'plugin_backend_unavailable', +]); class PluginMarketplaceRouteError extends Error { constructor( @@ -99,7 +112,8 @@ function sendError(res: ServerResponse, error: unknown): void { : error.status === 409 ? 409 : error.status === 422 ? 422 : 503; - const code = status === 401 ? 'plugin_auth_required' + const code = BOUNDED_MARKETPLACE_CODES.has(error.code) ? error.code + : status === 401 ? 'plugin_auth_required' : status === 403 ? 'plugin_library_required' : status === 404 ? 'plugin_not_found' : status === 409 ? (error.code === 'marketplace_account_changed' @@ -123,12 +137,16 @@ function sendError(res: ServerResponse, error: unknown): void { } if (error instanceof PluginPackageStoreError) { const status = error.code === 'plugin_release_unavailable' || error.code === 'plugin_beta_selection_required' - || error.code === 'plugin_release_conflict' || error.code === 'plugin_incompatible_client' + || error.code === 'plugin_release_conflict' || error.code === 'plugin_release_not_ready' + || error.code === 'plugin_incompatible_client' + || error.code === 'plugin_release_yanked' || error.code === 'plugin_runtime_suspended' ? 409 + : error.code === 'plugin_library_required' ? 403 : error.code === 'plugin_artifact_invalid' || error.code === 'plugin_signature_invalid' || error.code === 'plugin_manifest_invalid' ? 422 : error.code === 'plugin_account_changed' ? 409 : 503; - const code = error.code === 'plugin_account_changed' ? 'plugin_account_changed' + const code = BOUNDED_MARKETPLACE_CODES.has(error.code) ? error.code + : error.code === 'plugin_account_changed' ? 'plugin_account_changed' : status === 409 ? 'plugin_release_not_ready' : status === 422 ? 'plugin_artifact_invalid' : 'plugin_backend_unavailable'; @@ -165,6 +183,7 @@ function isKnownPath(pathname: string): boolean { || DETAIL.test(pathname) || LIBRARY_MUTATION.test(pathname) || INSTALLATION.test(pathname) + || BETA_INSTALLATION.test(pathname) || UPDATE.test(pathname); } @@ -221,6 +240,13 @@ export async function handlePluginMarketplaceRoutes( sendJson(res, 200, result); return true; } + const betaInstallationMatch = BETA_INSTALLATION.exec(url.pathname); + if (betaInstallationMatch && req.method === 'POST') { + exactNoQuery(url); + await exactEmptyBody(req); + sendJson(res, 200, await marketplace.installBeta(routePluginId(betaInstallationMatch[1]))); + return true; + } const updateMatch = UPDATE.exec(url.pathname); if (updateMatch && req.method === 'POST') { exactNoQuery(url); diff --git a/electron/coding-plugins/account-plugin-cache.ts b/electron/coding-plugins/account-plugin-cache.ts index e44a4e6..b2cfbe1 100644 --- a/electron/coding-plugins/account-plugin-cache.ts +++ b/electron/coding-plugins/account-plugin-cache.ts @@ -175,6 +175,21 @@ export class AccountPluginCache { return result; } + /** + * Forget device-resolution snapshots for one plugin in one account binding. + * The Library projection is intentionally retained so uninstalling a device + * package never becomes an account Library mutation. + */ + invalidatePlugin(binding: AccountBinding, pluginId: string): void { + const record = this.records.get(bindingId(binding)); + if (!record) return; + for (const [key, snapshot] of record.resolves.entries()) { + const items = snapshot.items.filter((item) => item.pluginId !== pluginId); + if (items.length === 0) record.resolves.delete(key); + else if (items.length !== snapshot.items.length) record.resolves.set(key, { ...snapshot, items }); + } + } + clearAccount(binding: AccountBinding): void { assertBinding(binding); for (const [key, record] of this.records.entries()) { diff --git a/electron/coding-plugins/effective-resolver.ts b/electron/coding-plugins/effective-resolver.ts index afb3005..f1cd94f 100644 --- a/electron/coding-plugins/effective-resolver.ts +++ b/electron/coding-plugins/effective-resolver.ts @@ -24,6 +24,8 @@ import type { export interface SkillEntry { readonly id: CodingSkillId; readonly entryPath: string; + /** Main-verified package root for an installed Marketplace Skill. */ + readonly packageRoot?: string; } /** A policy row copied from the last verified server catalog. */ @@ -326,7 +328,13 @@ export class EffectivePluginResolver { if (definition.releaseId) pluginReleaseIds.push(definition.releaseId); for (const skill of selectedSkills) { effectiveSkillIds.push(skill.id); - skillEntries.push({ id: skill.id, entryPath: skill.entryPath }); + skillEntries.push({ + id: skill.id, + entryPath: skill.entryPath, + ...(definition.provenance.source === 'marketplace' + ? { packageRoot: definition.provenance.packageRoot } + : {}), + }); } if (!definition.requiresBackend) continue; for (const operation of definition.operations) { diff --git a/electron/coding-plugins/marketplace-client.ts b/electron/coding-plugins/marketplace-client.ts index 8bf9e22..f9a74c7 100644 --- a/electron/coding-plugins/marketplace-client.ts +++ b/electron/coding-plugins/marketplace-client.ts @@ -6,7 +6,7 @@ import { getWorksSquareAccountBinding, subscribeWorksSquareSession, } from '../services/works-square-session'; -import { proxyAwareFetch, fetchWithDeadline } from '../utils/proxy-fetch'; +import { proxyAwareFetch, runWithDeadline } from '../utils/proxy-fetch'; import { AccountPluginCache, type AccountBinding, @@ -202,7 +202,17 @@ export type MarketplaceErrorCode = | 'marketplace_response_invalid' | 'marketplace_response_too_large' | 'marketplace_download_invalid' - | 'marketplace_beta_selection_required'; + | 'marketplace_beta_selection_required' + | 'plugin_auth_required' + | 'plugin_account_changed' + | 'plugin_library_required' + | 'plugin_release_not_ready' + | 'plugin_release_yanked' + | 'plugin_incompatible_client' + | 'plugin_signature_invalid' + | 'plugin_artifact_invalid' + | 'plugin_runtime_suspended' + | 'plugin_backend_unavailable'; export class MarketplaceClientError extends Error { constructor( @@ -236,6 +246,19 @@ function fail(code: MarketplaceErrorCode, message: string = code, status = 0): n throw new MarketplaceClientError(code, status, message); } +const SERVER_ERROR_CODES = new Set([ + 'plugin_auth_required', + 'plugin_account_changed', + 'plugin_library_required', + 'plugin_release_not_ready', + 'plugin_release_yanked', + 'plugin_incompatible_client', + 'plugin_signature_invalid', + 'plugin_artifact_invalid', + 'plugin_runtime_suspended', + 'plugin_backend_unavailable', +]); + function exactKeys( value: UnknownRecord, required: readonly string[], @@ -586,8 +609,7 @@ async function readBoundedBytes(response: Response, maximum: number): Promise Buffer.from(chunk))); } -async function readJson(response: Response, maximum: number): Promise { - const bytes = await readBoundedBytes(response, maximum); +function parseJsonBytes(bytes: Uint8Array): unknown { let source: string; try { source = new TextDecoder('utf-8', { fatal: true }).decode(bytes); @@ -602,6 +624,48 @@ async function readJson(response: Response, maximum: number): Promise { } } +function failForResponse( + response: Response, + bytes: Uint8Array | null, + fallbackMessage: string, +): never { + let code: MarketplaceErrorCode | null = null; + let responseMessage = fallbackMessage; + if (bytes && bytes.byteLength > 0) { + try { + const payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) as unknown; + if (isRecord(payload)) { + if (typeof payload.code === 'string' && SERVER_ERROR_CODES.has(payload.code as MarketplaceErrorCode)) { + code = payload.code as MarketplaceErrorCode; + } + if (typeof payload.error === 'string' && payload.error.length > 0 && payload.error.length <= 256) { + responseMessage = payload.error; + } + } + } catch { + // A malformed error body must stay a bounded generic client failure. + } + } + fail(code ?? 'marketplace_request_failed', responseMessage, response.status); +} + +async function fetchResponseWithBodyDeadline( + fetchImpl: FetchImplementation, + input: string | URL, + init: RequestInit, + timeoutMs: number, + maximum: number, +): Promise<{ response: Response; bytes: Uint8Array | null }> { + return runWithDeadline(async (signal) => { + const response = await fetchImpl(input, { ...init, signal }); + if (response.status >= 300 && response.status < 400) { + await response.body?.cancel().catch(() => undefined); + return { response, bytes: null }; + } + return { response, bytes: await readBoundedBytes(response, maximum) }; + }, timeoutMs, init.signal); +} + function normalizedBase(value: string): string { try { const url = new URL(value); @@ -955,7 +1019,7 @@ class MarketplaceClientImpl implements MarketplaceClient { let url = localUrl.toString(); let sendAuthorization = true; for (let redirect = 0; redirect <= 3; redirect += 1) { - const response = await fetchWithDeadline( + const { response, bytes } = await fetchResponseWithBodyDeadline( this.fetchImpl as typeof fetch, url, { @@ -967,6 +1031,7 @@ class MarketplaceClientImpl implements MarketplaceClient { redirect: 'manual', }, this.requestTimeoutMs, + Math.min(this.maxArtifactBytes, grant.sizeBytes), ); if (response.status === 401 && sendAuthorization && !refreshed) { await response.body?.cancel().catch(() => undefined); @@ -995,10 +1060,9 @@ class MarketplaceClientImpl implements MarketplaceClient { continue; } if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - fail(response.status === 401 ? 'marketplace_auth_required' : 'marketplace_request_failed', 'Marketplace download failed', response.status); + failForResponse(response, bytes, 'Marketplace download failed'); } - const bytes = await readBoundedBytes(response, Math.min(this.maxArtifactBytes, grant.sizeBytes)); + if (!bytes) fail('marketplace_download_invalid', 'Marketplace response body is unavailable'); if (bytes.byteLength !== grant.sizeBytes) fail('marketplace_download_invalid', 'Marketplace artifact size does not match its grant'); this.assertBinding(binding); return bytes; @@ -1083,11 +1147,12 @@ class MarketplaceClientImpl implements MarketplaceClient { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(options.etag ? { 'If-None-Match': options.etag } : {}), }; - const response = await fetchWithDeadline( + const { response, bytes } = await fetchResponseWithBodyDeadline( this.fetchImpl as typeof fetch, url, { method, headers, body, redirect: 'manual' }, this.requestTimeoutMs, + this.maxResponseBytes, ); if (response.status === 401 && !refreshed && token) { await response.body?.cancel().catch(() => undefined); @@ -1102,10 +1167,10 @@ class MarketplaceClientImpl implements MarketplaceClient { return { status: response.status, headers: response.headers, value: null, notModified: true }; } if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - fail(response.status === 401 ? 'marketplace_auth_required' : 'marketplace_request_failed', 'Marketplace request failed', response.status); + failForResponse(response, bytes, 'Marketplace request failed'); } - const value = parser(await readJson(response, this.maxResponseBytes)); + if (!bytes) fail('marketplace_response_invalid', 'Marketplace response body is unavailable'); + const value = parser(parseJsonBytes(bytes)); if (binding) this.assertBinding(binding); return { status: response.status, headers: response.headers, value, notModified: false }; } diff --git a/electron/coding-plugins/package-store.ts b/electron/coding-plugins/package-store.ts index 5d10626..ba79cbe 100644 --- a/electron/coding-plugins/package-store.ts +++ b/electron/coding-plugins/package-store.ts @@ -46,10 +46,12 @@ import type { import type { PluginSigningKeyStore } from './trusted-keys'; const INDEX_SCHEMA_VERSION = 1; +const CURRENT_SELECTION_SCHEMA_VERSION = 1; 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 ORPHAN_ARCHIVE_FILE = '.makelore-release.zip'; const MAX_PLUGIN_ID = 128; const MAX_RELEASE_ID = 128; const MAX_VERSION = 128; @@ -69,6 +71,7 @@ const INDEX_RELEASE_KEYS = new Set([ 'size_bytes', 'installed_at', ]); +const CURRENT_SELECTION_ROOT_KEYS = new Set(['schema_version', 'current']); type UnknownRecord = Record; @@ -154,6 +157,11 @@ export type PluginPackageStoreErrorCode = | 'plugin_manifest_invalid' | 'plugin_runtime_not_supported' | 'plugin_release_conflict' + | 'plugin_release_not_ready' + | 'plugin_release_yanked' + | 'plugin_runtime_suspended' + | 'plugin_library_required' + | 'plugin_backend_unavailable' | 'plugin_install_failed'; export class PluginPackageStoreError extends Error { @@ -171,6 +179,11 @@ interface IndexDocument { readonly releases: readonly InstalledReleaseRecord[]; } +interface CurrentSelectionDocument { + readonly schema_version: typeof CURRENT_SELECTION_SCHEMA_VERSION; + readonly current: Readonly>; +} + function isRecord(value: unknown): value is UnknownRecord { return value !== null && typeof value === 'object' && !Array.isArray(value); } @@ -325,6 +338,33 @@ function serializeIndex(document: IndexDocument): Uint8Array { })}\n`, 'utf8'); } +function parseCurrentSelectionDocument(value: unknown): CurrentSelectionDocument { + const root = isRecord(value) ? value : fail('plugin_store_index_invalid', 'current selection root must be an object'); + assertExactKeys(root, CURRENT_SELECTION_ROOT_KEYS, 'current selection'); + if (root.schema_version !== CURRENT_SELECTION_SCHEMA_VERSION) { + fail('plugin_store_index_invalid', 'unsupported current selection schema'); + } + if (!isRecord(root.current) || Object.keys(root.current).length > DEFAULT_MAX_FILES) { + fail('plugin_store_index_invalid', 'invalid current selection'); + } + const current: Record = {}; + for (const [pluginId, releaseId] of Object.entries(root.current)) { + const validId = validPluginId(pluginId, 'current plugin_id', 'plugin_store_index_invalid'); + current[validId] = validReleaseId(releaseId, `current ${validId}.release_id`, 'plugin_store_index_invalid'); + } + return Object.freeze({ + schema_version: CURRENT_SELECTION_SCHEMA_VERSION, + current: Object.freeze(current), + }); +} + +function serializeCurrentSelection(document: CurrentSelectionDocument): Uint8Array { + return Buffer.from(`${JSON.stringify({ + schema_version: document.schema_version, + current: document.current, + })}\n`, 'utf8'); +} + async function parseJsonFile(filePath: string): Promise { const bytes = await readFile(filePath); let source: string; @@ -482,6 +522,7 @@ export class PluginPackageStore { private readonly rootDir: string; private readonly packagesDir: string; private readonly indexPath: string; + private readonly currentSelectionPath: string; private readonly marketplace: MarketplacePackageClientPort; private readonly accountCache: AccountPluginCache; private readonly getAccountBindingImpl: () => AccountBinding | null; @@ -502,6 +543,7 @@ export class PluginPackageStore { this.rootDir = path.resolve(options.rootDir ?? path.join(getDataDir(), 'coding-plugins')); this.packagesDir = path.join(this.rootDir, 'packages'); this.indexPath = path.join(this.rootDir, 'index.json'); + this.currentSelectionPath = path.join(this.rootDir, 'current.json'); this.marketplace = options.marketplace; this.accountCache = options.accountCache ?? new AccountPluginCache(); this.getAccountBindingImpl = options.getAccountBinding @@ -554,37 +596,23 @@ export class PluginPackageStore { async getInstalled(pluginId: string): Promise { const validated = validPluginId(pluginId); const index = await this.readIndex(); - return this.getInstalledFromIndex(index, validated); + const current = await this.readCurrentSelection(); + return this.getInstalledFromIndex(index, validated, undefined, current); } async removeUnused(pluginId: string): Promise { + const validated = validPluginId(pluginId); + return this.withOperation(() => this.removeUnusedLocked(validated)); + } + + /** Remove a device package while leaving the account Library projection intact. */ + async uninstall(pluginId: string): Promise { const validated = validPluginId(pluginId); return this.withOperation(async () => { - const index = await this.readIndex(); - const records = index.releases.filter((record) => record.pluginId === validated); - if (records.length === 0) 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 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: latest.releaseId, version: latest.version }; - const remaining = index.releases.filter((record) => !removable.includes(record)); - try { - await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining })); - } catch { - throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed'); - } - await Promise.all(removable.map(async (record) => { - await rm(this.releaseDirectory(record), { recursive: true, force: true }); - })); - return { status: 'removed', pluginId: validated, releaseId: latest.releaseId, version: latest.version }; + const binding = this.requireBinding(); + this.accountCache.invalidatePlugin(binding, validated); + this.assertBinding(binding); + return this.removeUnusedLocked(validated); }); } @@ -610,7 +638,12 @@ export class PluginPackageStore { if (channel === 'beta' && input.explicitBeta !== true) fail('plugin_beta_selection_required'); const binding = this.requireBinding(); const index = await this.readIndex(); - const current = await this.getInstalledFromIndex(index, pluginId); + const current = await this.getInstalledFromIndex( + index, + pluginId, + undefined, + await this.readCurrentSelection(), + ); const installed = input.installed === undefined ? (current ? [toInstalledInput(current)] : []) : input.installed === null ? [] : [input.installed]; @@ -633,7 +666,20 @@ export class PluginPackageStore { if (item.channel !== undefined && item.channel !== null && item.channel !== channel) { fail('plugin_release_unavailable', 'resolve channel does not match the requested channel'); } - if (item.action === 'unavailable') fail('plugin_release_unavailable', item.reason ?? 'Plugin Release is unavailable'); + if (item.action === 'unavailable') { + const reason = item.reason; + if (reason === 'plugin_release_not_ready' + || reason === 'plugin_release_yanked' + || reason === 'plugin_runtime_suspended' + || reason === 'plugin_library_required' + || reason === 'plugin_incompatible_client' + || reason === 'plugin_signature_invalid' + || reason === 'plugin_artifact_invalid' + || reason === 'plugin_backend_unavailable') { + fail(reason, reason); + } + fail('plugin_release_unavailable', reason ?? 'Plugin Release is unavailable'); + } if (item.action === 'keep') { if (!current) fail('plugin_release_unavailable', 'resolve requested keep without an installed Release'); return { @@ -660,6 +706,11 @@ 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) { + await this.setCurrentSelection( + await this.readCurrentSelection(), + pluginId, + existing.releaseId, + ); return { status: 'kept', pluginId, @@ -679,6 +730,17 @@ export class PluginPackageStore { if (error instanceof PluginPackageStoreError) throw error; throw this.mapMarketplaceError(error); } + const packageRoot = this.releaseDirectory({ pluginId, releaseId: grant.releaseId } as InstalledReleaseRecord); + if (await pathExists(packageRoot)) { + return this.recoverOrphanedRelease({ + index, + currentSelection: await this.readCurrentSelection(), + packageRoot, + pluginId, + grant, + binding, + }); + } let artifact: Uint8Array; try { artifact = await this.marketplace.downloadContent(grant); @@ -687,29 +749,7 @@ export class PluginPackageStore { if (error instanceof PluginPackageStoreError) throw error; throw this.mapMarketplaceError(error); } - const expiresAt = typeof grant.expiresAt === 'string' ? Date.parse(grant.expiresAt) : Number.NaN; - if (!Number.isFinite(expiresAt) || expiresAt <= this.now()) { - fail('plugin_release_unavailable', 'download admission has expired'); - } - if (!(artifact instanceof Uint8Array)) fail('plugin_artifact_invalid', 'downloaded artifact is not binary data'); - if (artifact.byteLength !== grant.sizeBytes || artifact.byteLength > this.maxArchiveBytes || digest(artifact) !== grant.sha256) { - fail('plugin_artifact_invalid', 'downloaded artifact does not match its grant'); - } - const descriptor = this.buildDescriptor(grant); - if (!isMakeLoreVersionCompatible( - this.clientVersion, - descriptor.minMakeloreVersion, - descriptor.maxMakeloreVersion, - )) { - fail('plugin_incompatible_client', 'Release is incompatible with this MakeLore client'); - } - const verification = this.signatureVerifier.verify({ - keyId: grant.signingKeyId, - signature: grant.descriptorSignature, - descriptor, - artifact, - }); - if (!verification.ok) fail(mapVerificationFailure(verification.code), verification.message); + const descriptor = this.verifyArtifact(artifact, grant); try { await mkdir(this.rootDir, { recursive: true }); } catch { @@ -734,32 +774,21 @@ export class PluginPackageStore { }); const definition = await this.loadDefinition(extractedPath, grant, descriptor); this.assertBinding(binding); - const packageRoot = this.releaseDirectory({ pluginId, releaseId: grant.releaseId } as InstalledReleaseRecord); await mkdir(path.dirname(packageRoot), { recursive: true }); if (await isDirectoryPath(packageRoot) || await pathExists(packageRoot)) { fail('plugin_release_conflict', 'immutable Release directory already exists'); } + await writeFile(path.join(extractedPath, ORPHAN_ARCHIVE_FILE), toBuffer(artifact), { flag: 'wx' }); await rename(extractedPath, packageRoot); moved = true; - const record: InstalledReleaseRecord = Object.freeze({ - pluginId, - releaseId: grant.releaseId, - version: grant.version, - packageSchemaVersion: grant.packageSchemaVersion, - contractVersion: grant.contractVersion, - runtimeKind: 'skill_only', - sha256: grant.sha256, - sizeBytes: grant.sizeBytes, - installedAt: new Date(this.now()).toISOString(), - }); + const record = this.installedRecord(grant); 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] })); } catch { - await rm(packageRoot, { recursive: true, force: true }).catch(() => undefined); - moved = false; throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed'); } + await this.setCurrentSelection(await this.readCurrentSelection(), pluginId, record.releaseId); return { status: 'installed', pluginId, @@ -782,6 +811,151 @@ export class PluginPackageStore { } } + private async recoverOrphanedRelease(input: { + readonly index: IndexDocument; + readonly currentSelection: CurrentSelectionDocument; + readonly packageRoot: string; + readonly pluginId: string; + readonly grant: DownloadGrant; + readonly binding: AccountBinding; + }): Promise { + const existingRecord = input.index.releases.find((record) => ( + record.pluginId === input.pluginId && record.releaseId === input.grant.releaseId + )); + if (existingRecord) fail('plugin_release_conflict', 'immutable Release directory already exists'); + let artifact: Uint8Array; + try { + artifact = await readFile(path.join(input.packageRoot, ORPHAN_ARCHIVE_FILE)); + } catch { + fail('plugin_release_conflict', 'immutable Release directory cannot be recovered'); + } + 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); + try { + await this.writeIndex(this.indexPath, serializeIndex({ + schema_version: INDEX_SCHEMA_VERSION, + releases: [...input.index.releases, record], + })); + } catch { + throw new PluginPackageStoreError('plugin_install_failed', 'package index replacement failed'); + } + await this.setCurrentSelection(input.currentSelection, input.pluginId, record.releaseId); + return { + status: 'installed', + pluginId: input.pluginId, + releaseId: record.releaseId, + version: record.version, + packageRoot: input.packageRoot, + definition, + }; + } + + private verifyArtifact(artifact: Uint8Array, grant: DownloadGrant): PluginReleaseDescriptor { + const expiresAt = typeof grant.expiresAt === 'string' ? Date.parse(grant.expiresAt) : Number.NaN; + if (!Number.isFinite(expiresAt) || expiresAt <= this.now()) { + fail('plugin_release_unavailable', 'download admission has expired'); + } + if (!(artifact instanceof Uint8Array)) fail('plugin_artifact_invalid', 'downloaded artifact is not binary data'); + if (artifact.byteLength !== grant.sizeBytes || artifact.byteLength > this.maxArchiveBytes || digest(artifact) !== grant.sha256) { + fail('plugin_artifact_invalid', 'downloaded artifact does not match its grant'); + } + const descriptor = this.buildDescriptor(grant); + if (!isMakeLoreVersionCompatible( + this.clientVersion, + descriptor.minMakeloreVersion, + descriptor.maxMakeloreVersion, + )) { + fail('plugin_incompatible_client', 'Release is incompatible with this MakeLore client'); + } + const verification = this.signatureVerifier.verify({ + keyId: grant.signingKeyId, + signature: grant.descriptorSignature, + descriptor, + artifact, + }); + if (!verification.ok) fail(mapVerificationFailure(verification.code), verification.message); + return descriptor; + } + + private installedRecord(grant: DownloadGrant): InstalledReleaseRecord { + return Object.freeze({ + pluginId: grant.pluginId, + releaseId: grant.releaseId, + version: grant.version, + packageSchemaVersion: grant.packageSchemaVersion, + contractVersion: grant.contractVersion, + runtimeKind: 'skill_only', + sha256: grant.sha256, + sizeBytes: grant.sizeBytes, + installedAt: new Date(this.now()).toISOString(), + }); + } + + private async removeUnusedLocked(validated: string): Promise { + 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) { + const current = { ...currentSelection.current }; + delete current[validated]; + await this.writeCurrentSelection({ + schema_version: CURRENT_SELECTION_SCHEMA_VERSION, + current: Object.freeze(current), + }); + } + 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 remaining = index.releases.filter((record) => !removable.includes(record)); + try { + await this.writeIndex(this.indexPath, serializeIndex({ schema_version: INDEX_SCHEMA_VERSION, releases: remaining })); + } catch { + throw new PluginPackageStoreError('plugin_install_failed', 'package index cleanup failed'); + } + const nextCurrent = { ...currentSelection.current }; + if (nextCurrent[validated] && !remaining.some((record) => ( + 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]; + } + await this.writeCurrentSelection({ + schema_version: CURRENT_SELECTION_SCHEMA_VERSION, + current: Object.freeze(nextCurrent), + }); + await Promise.all(removable.map(async (record) => { + await rm(this.releaseDirectory(record), { recursive: true, force: true }); + })); + 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 }; + } + private buildDescriptor(grant: DownloadGrant): PluginReleaseDescriptor { try { return buildPluginReleaseDescriptor({ @@ -850,6 +1024,39 @@ export class PluginPackageStore { } } + private async readCurrentSelection(): Promise { + try { + return parseCurrentSelectionDocument(await parseJsonFile(this.currentSelectionPath)); + } catch (error) { + if (isNotFound(error)) { + return { schema_version: CURRENT_SELECTION_SCHEMA_VERSION, current: Object.freeze({}) }; + } + if (error instanceof PluginPackageStoreError) throw error; + throw new PluginPackageStoreError('plugin_store_index_invalid', 'current package selection is unavailable'); + } + } + + private async writeCurrentSelection(document: CurrentSelectionDocument): Promise { + try { + await atomicWriteIndex(this.currentSelectionPath, serializeCurrentSelection(document)); + } catch { + throw new PluginPackageStoreError('plugin_install_failed', 'package current selection replacement failed'); + } + } + + private async setCurrentSelection( + currentSelection: CurrentSelectionDocument, + pluginId: string, + releaseId: string, + ): Promise { + if (currentSelection.current[pluginId] === releaseId) return; + const current = { ...currentSelection.current, [pluginId]: releaseId }; + await this.writeCurrentSelection({ + schema_version: CURRENT_SELECTION_SCHEMA_VERSION, + current: Object.freeze(current), + }); + } + private releaseDirectory(record: Pick): string { return path.join(this.packagesDir, record.pluginId, record.releaseId); } @@ -858,13 +1065,18 @@ export class PluginPackageStore { index: IndexDocument, pluginId: string, releaseId?: string, + currentSelection?: CurrentSelectionDocument, ): Promise { + const pointedReleaseId = releaseId === undefined ? currentSelection?.current[pluginId] : undefined; const records = index.releases .map((record, index) => ({ record, index })) .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); - for (const record of records) { + const selected = pointedReleaseId === undefined + ? records + : records.filter((record) => record.releaseId === pointedReleaseId); + for (const record of selected) { const packageRoot = this.releaseDirectory(record); if (!await isDirectoryPath(packageRoot)) continue; try { @@ -895,6 +1107,24 @@ export class PluginPackageStore { if (isRecord(error) && error.code === 'marketplace_beta_selection_required') { return new PluginPackageStoreError('plugin_beta_selection_required'); } + if (isRecord(error) && ( + error.code === 'plugin_release_yanked' + || error.code === 'plugin_release_not_ready' + || error.code === 'plugin_runtime_suspended' + || error.code === 'plugin_library_required' + || error.code === 'plugin_backend_unavailable' + || error.code === 'plugin_incompatible_client' + || error.code === 'plugin_signature_invalid' + || error.code === 'plugin_artifact_invalid' + )) { + return new PluginPackageStoreError( + error.code, + typeof error.message === 'string' ? error.message : error.code, + ); + } + if (isRecord(error) && error.code === 'plugin_auth_required') { + return new PluginPackageStoreError('plugin_account_changed', 'Marketplace authentication is required'); + } return new PluginPackageStoreError('plugin_install_failed', 'Marketplace package operation failed'); } diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts index 3b9b58b..c7e0fe4 100644 --- a/electron/coding-plugins/registry.ts +++ b/electron/coding-plugins/registry.ts @@ -95,7 +95,7 @@ export interface ResolvedWorkerResources { catalogRevision: number; pluginIds: readonly string[]; effectiveSkillIds: readonly string[]; - skillEntries: readonly { id: string; entryPath: string }[]; + skillEntries: readonly { id: string; entryPath: string; packageRoot?: string }[]; tools: readonly CodingPluginToolDefinition[]; /** The exact Main-owned snapshot used to produce these legacy fields. */ effectiveSnapshot?: EffectivePluginSnapshot; @@ -401,7 +401,11 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor catalogRevision: this.options.effectiveResolver.getPolicyState().revision, pluginIds: [...pluginIds], effectiveSkillIds: [...snapshot.effectiveSkillIds], - skillEntries: snapshot.skillEntries.map(({ id, entryPath }) => ({ id, entryPath })), + skillEntries: snapshot.skillEntries.map(({ id, entryPath, packageRoot }) => ({ + id, + entryPath, + ...(packageRoot ? { packageRoot } : {}), + })), tools: snapshot.toolDefinitions.map((tool) => structuredClone(tool)), effectiveSnapshot: snapshot, skillRoots: [...skillRoots], @@ -457,7 +461,13 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor if (selectedSkills.length === 0) continue; for (const skill of selectedSkills) { effectiveSkillIds.push(skill.id); - skillEntries.push({ id: skill.id, entryPath: skill.entryPath }); + skillEntries.push({ + id: skill.id, + entryPath: skill.entryPath, + ...(definition.provenance.source === 'marketplace' + ? { packageRoot: definition.provenance.packageRoot } + : {}), + }); } for (const tool of definition.tools) { const granted = selectedSkills.some(({ grants }) => grants.includes(tool.capabilityId)); diff --git a/electron/coding-plugins/trusted-keys.ts b/electron/coding-plugins/trusted-keys.ts index f5bd2a0..6bd8764 100644 --- a/electron/coding-plugins/trusted-keys.ts +++ b/electron/coding-plugins/trusted-keys.ts @@ -13,6 +13,7 @@ export const PLUGIN_SIGNING_KEY_ACTIVATION_HOLD = true as const; export interface PluginSigningKeyStore { get(keyId: string): Uint8Array | string | null; + readonly sourceMarker?: string; } function decodeKey(value: string): Uint8Array | null { @@ -36,6 +37,7 @@ export function loadCodeOwnedPluginSigningKey(keyId: string): Uint8Array | null export function createCodeOwnedPluginTrustStore(): PluginSigningKeyStore { return Object.freeze({ get: loadCodeOwnedPluginSigningKey, + sourceMarker: 'makelore.plugin-trust.code-owned.v1', }); } diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index 810b403..90bea7c 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -227,6 +227,7 @@ export class PiManagedExtensionHost { .map((entry) => [entry.id.trim(), { id: entry.id.trim(), entryPath: entry.entryPath.trim().replaceAll('\\', '/'), + ...(entry.packageRoot?.trim() ? { packageRoot: path.resolve(entry.packageRoot) } : {}), }]), ).values()]; const tools = role === 'child' diff --git a/electron/coding-runtime/pi/resource-loader.ts b/electron/coding-runtime/pi/resource-loader.ts index 3e0cc75..538268c 100644 --- a/electron/coding-runtime/pi/resource-loader.ts +++ b/electron/coding-runtime/pi/resource-loader.ts @@ -12,6 +12,7 @@ const MANAGED_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; export interface PiSkillEntry { id: string; entryPath: string; + packageRoot?: string; } export interface BundledCodingSkillsPathInput { @@ -157,6 +158,12 @@ function normalizeSkillEntries(skillEntries: readonly PiSkillEntry[]): PiSkillEn const skillId = rawEntry.id.trim(); const entryPath = rawEntry.entryPath.trim().replaceAll('\\', '/'); if (!skillId || !entryPath) throw new Error('Effective coding Skill entry is invalid'); + const packageRoot = typeof rawEntry.packageRoot === 'string' + ? rawEntry.packageRoot.trim() + : undefined; + if (rawEntry.packageRoot !== undefined && !packageRoot) { + throw new Error('Effective coding Skill package root is invalid'); + } if (entryPath.startsWith('/') || /^[A-Za-z]:\//u.test(entryPath) || /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(entryPath)) { throw new Error(`Effective coding Skill entry path must be relative: ${entryPath}`); @@ -167,7 +174,11 @@ function normalizeSkillEntries(skillEntries: readonly PiSkillEntry[]): PiSkillEn } if (seen.has(skillId)) continue; seen.add(skillId); - result.push({ id: skillId, entryPath: relative }); + result.push({ + id: skillId, + entryPath: relative, + ...(packageRoot ? { packageRoot: path.resolve(packageRoot) } : {}), + }); } return result; } @@ -194,14 +205,16 @@ async function resolveSkillEntryPath( entry: PiSkillEntry, skillRoots: readonly string[] = [], ): Promise { - const roots = [ - path.resolve(bundledSkillsDir), - ...resolveBundledCodingPluginRootPaths(path.join( - path.dirname(path.resolve(bundledSkillsDir)), - 'coding-plugins', - )), - ...skillRoots.map((root) => path.resolve(root)), - ]; + const roots = entry.packageRoot + ? [path.resolve(entry.packageRoot)] + : [ + path.resolve(bundledSkillsDir), + ...resolveBundledCodingPluginRootPaths(path.join( + path.dirname(path.resolve(bundledSkillsDir)), + 'coding-plugins', + )), + ...skillRoots.map((root) => path.resolve(root)), + ]; for (const root of roots) { const candidate = pathWithin(root, entry.entryPath); if (!candidate) continue; diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs index 7302983..0037903 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -23,7 +23,7 @@ import { packagedResourcesDirectory, } from '../probe-pi-packaged-runtime.mjs'; -const { listPackage } = createRequire(import.meta.url)('@electron/asar'); +const { extractFile, listPackage } = createRequire(import.meta.url)('@electron/asar'); const PRODUCT_NAME = 'Makelore'; const LINUX_EXECUTABLE_NAME = 'niancode'; @@ -56,6 +56,7 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({ '我的插件', ]), }); +const CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER = 'makelore.plugin-trust.code-owned.v1'; const PI_AI_PROVIDER_PREFIX = 'pi-runtime/node_modules/@earendil-works/pi-ai/dist/providers/'; const PI_AI_PROVIDER_ASAR_PREFIX = 'app.asar/node_modules/@earendil-works/pi-ai/dist/providers/'; export const BUNDLED_CODING_PLUGIN_RESOURCE_ROOT = 'resources/coding-plugins'; @@ -296,7 +297,9 @@ export function verifyMarketplaceClientArtifact(appAsarContents, productionTrust if (missing.length > 0) { throw new Error(`Packaged app.asar does not contain Marketplace contract markers: ${missing.join(', ')}`); } - if (!/CODE_OWNED_PLUGIN_SIGNING_KEYS\s*=\s*Object\.freeze\(\s*\{\}\s+as/u.test(productionTrustSource) + const hasEmptyCodeOwnedTrust = /(?:CODE_OWNED_PLUGIN_SIGNING_KEYS\s*=\s*)?Object\.freeze\(\s*\{\}\s*(?:as\s+[^)]*)?\)/u.test(productionTrustSource); + if (!hasEmptyCodeOwnedTrust + || !productionTrustSource.includes(CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER) || productionTrustSource.includes('process.env') || productionTrustSource.includes('-----BEGIN PRIVATE KEY-----') || productionTrustSource.includes('-----BEGIN ED25519 PRIVATE KEY-----')) { @@ -314,6 +317,36 @@ 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. + */ +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, ''); + let source; + try { + source = extractFile(appAsar, filename).toString('utf8'); + } catch { + continue; + } + const trustAssignment = source.match( + /CODE_OWNED_PLUGIN_SIGNING_KEYS\s*=\s*Object\.freeze\(\s*\{\}\s*(?:as\s+[^)]*)?\)/u, + ); + if (trustAssignment) return trustAssignment[0]; + + const compiledTrust = source.match( + /(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*Object\.freeze\(\s*\{\}\s*\)\s*;(?=[\s\S]{0,512}?sourceMarker\s*:\s*["']makelore\.plugin-trust\.code-owned\.v1["'])/u, + ); + if (compiledTrust) { + return `${compiledTrust[0]} sourceMarker: ${CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER}`; + } + } + throw new Error('Packaged app.asar does not contain the Marketplace trust source'); +} + async function filesContainingNeedles(root, needles) { const matches = []; const visit = async (path) => { @@ -564,10 +597,7 @@ 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 readFile( - join(root, 'electron', 'coding-plugins', 'trusted-keys.ts'), - 'utf8', - ); + const marketplaceTrustSource = await readPackagedMarketplaceTrustSource(appAsar); const physicalOpenCodePaths = await collectForbiddenResourcePaths(resourcesDirectory); const asarOpenCodePaths = collectForbiddenAsarPaths(appAsar); const openCodeResourcePaths = classifyOpenCodeResourcePaths([ diff --git a/src/lib/host-api.ts b/src/lib/host-api.ts index e01a532..a134846 100644 --- a/src/lib/host-api.ts +++ b/src/lib/host-api.ts @@ -111,17 +111,22 @@ function telemetryRoute(path: string): string { async function parseResponse(response: Response): Promise { if (!response.ok) { let message = `${response.status} ${response.statusText}`; + let backendCode: string | undefined; try { - const payload = await response.json() as { error?: string }; + const payload = await response.json() as { error?: string; code?: unknown }; if (payload?.error) { message = payload.error; } + if (typeof payload?.code === 'string' && /^[a-z][a-z0-9_]{0,63}$/u.test(payload.code)) { + backendCode = payload.code; + } } catch { // ignore body parse failure } throw normalizeAppError(new Error(message), { source: 'browser-fallback', status: response.status, + ...(backendCode ? { backendCode } : {}), }); } diff --git a/src/lib/plugin-marketplace.ts b/src/lib/plugin-marketplace.ts index 3d199f7..2311d86 100644 --- a/src/lib/plugin-marketplace.ts +++ b/src/lib/plugin-marketplace.ts @@ -277,6 +277,13 @@ async function installationMutation(method: 'POST' | 'DELETE', segment: 'install return parseMarketplaceInstallation(await fetcher(`/api/coding/plugin-marketplace/${segment}/${encodeURIComponent(pluginId(id))}`, { method, body: '{}' })); } +export async function installBetaMarketplacePlugin(id: string, fetcher = defaultFetch) { + return parseMarketplaceInstallation(await fetcher( + `/api/coding/plugin-marketplace/install/${encodeURIComponent(pluginId(id))}/beta`, + { method: 'POST', body: '{}' }, + )); +} + export async function installMarketplacePlugin(id: string, fetcher = defaultFetch) { return installationMutation('POST', 'install', id, fetcher); } diff --git a/src/pages/MyPlugins/index.tsx b/src/pages/MyPlugins/index.tsx index fee74c8..5beffdf 100644 --- a/src/pages/MyPlugins/index.tsx +++ b/src/pages/MyPlugins/index.tsx @@ -12,8 +12,13 @@ function failureText(reason?: string): string | null { if (!reason) return null; const messages: string[] = []; if (reason.includes('signature')) messages.push('签名校验失败,旧版本仍保留。'); - if (reason.includes('yanked') || reason.includes('not_ready')) messages.push('版本已撤回或未就绪。'); - if (reason.includes('incompatible')) messages.push('当前 MakeLore 版本不兼容。'); + if (reason.includes('artifact_invalid')) messages.push('插件包校验失败,旧版本仍保留。'); + if (reason.includes('yanked')) messages.push('此版本已撤回,旧版本仍保留。'); + if (reason.includes('not_ready')) messages.push('此版本尚未就绪,旧版本仍保留。'); + if (reason.includes('incompatible')) messages.push('当前 MakeLore 版本不兼容,旧版本仍保留。'); + if (reason.includes('runtime_suspended')) messages.push('插件运行已暂停,设备版本未替换。'); + if (reason.includes('backend_unavailable')) messages.push('Marketplace 服务暂不可用,设备版本未替换。'); + if (reason.includes('library_required')) messages.push('请先在账号插件库中获取此插件。'); if (messages.length === 0) messages.push('当前版本暂不可用,旧版本仍保留。'); return messages.join(' '); } @@ -21,7 +26,13 @@ function failureText(reason?: string): string | null { function actionErrorText(error?: string | null): string | null { if (!error) return null; if (error.includes('plugin_artifact_invalid')) return '插件签名或包校验失败,设备上此前可用版本仍保留。'; - if (error.includes('plugin_release_not_ready')) return '版本已撤回、尚未就绪或与当前 MakeLore 不兼容;不会替换此前可用版本。'; + if (error.includes('plugin_signature_invalid')) return '插件签名校验失败,设备上此前可用版本仍保留。'; + if (error.includes('plugin_release_yanked')) return '此版本已撤回;不会替换此前可用版本。'; + if (error.includes('plugin_release_not_ready')) return '版本尚未就绪;不会替换此前可用版本。'; + if (error.includes('plugin_incompatible_client')) return '当前 MakeLore 版本不兼容;不会替换此前可用版本。'; + if (error.includes('plugin_runtime_suspended')) return '插件运行已暂停;不会替换此前可用版本。'; + if (error.includes('plugin_backend_unavailable')) return 'Marketplace 服务暂不可用;不会替换此前可用版本。'; + if (error.includes('plugin_library_required')) return '请先在账号插件库中获取此插件。'; return error; } @@ -33,6 +44,7 @@ export type MyPluginsViewProps = { pending: Record; onRefresh(): void | Promise; onInstall(pluginId: string): void | Promise; + onInstallBeta(pluginId: string): void | Promise; onUpdate(pluginId: string): void | Promise; onUninstall(pluginId: string): void | Promise; onRemove(pluginId: string): void | Promise; @@ -66,6 +78,7 @@ export function MyPluginsView(props: MyPluginsViewProps) { : !installed ? : updateAvailable ? : 设备版本已是最新} + {!removed && plugin.acquisition !== 'system_included' && plugin.betaVersion ? : null} {!removed && plugin.acquisition !== 'system_included' ? : null} {installed ? : null} @@ -94,5 +107,5 @@ export function MyPlugins() { toast.error(reason instanceof Error ? reason.message : String(reason)); }); const store = pluginMarketplaceStore.getState; - return safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />; + return safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />; } diff --git a/src/stores/plugin-marketplace.ts b/src/stores/plugin-marketplace.ts index d5b3a7d..089f784 100644 --- a/src/stores/plugin-marketplace.ts +++ b/src/stores/plugin-marketplace.ts @@ -2,6 +2,7 @@ import { createStore, type StoreApi } from 'zustand'; import { useStore } from 'zustand'; import { acquireMarketplacePlugin, + installBetaMarketplacePlugin, installMarketplacePlugin, readMarketplaceCatalog, readMarketplaceDetail, @@ -26,6 +27,7 @@ export type PluginMarketplaceDependencies = { acquire(pluginId: string): Promise; remove(pluginId: string): Promise; install(pluginId: string): Promise; + installBeta(pluginId: string): Promise; update(pluginId: string): Promise; uninstall(pluginId: string): Promise; }; @@ -50,6 +52,7 @@ export type PluginMarketplaceState = { acquire(pluginId: string): Promise; remove(pluginId: string): Promise; install(pluginId: string): Promise; + installBeta(pluginId: string): Promise; update(pluginId: string): Promise; uninstall(pluginId: string): Promise; }; @@ -61,6 +64,7 @@ const defaults: PluginMarketplaceDependencies = { acquire: acquireMarketplacePlugin, remove: removeMarketplacePlugin, install: installMarketplacePlugin, + installBeta: installBetaMarketplacePlugin, update: updateMarketplacePlugin, uninstall: uninstallMarketplacePlugin, }; @@ -68,11 +72,13 @@ const defaults: PluginMarketplaceDependencies = { function message(error: unknown): string { const base = error instanceof Error ? error.message : String(error); if (!error || typeof error !== 'object') return base; + const errorCode = 'code' in error && typeof error.code === 'string' ? error.code : null; const details = 'details' in error && error.details && typeof error.details === 'object' ? error.details as Record : null; const backendCode = details?.backendCode; - return typeof backendCode === 'string' ? `${backendCode}: ${base}` : base; + const prefixed = typeof backendCode === 'string' ? `${backendCode}: ${base}` : base; + return errorCode && !prefixed.includes(errorCode) ? `${errorCode}: ${prefixed}` : prefixed; } function installationMap(items: readonly MarketplaceInstallation[]): Record { @@ -86,6 +92,7 @@ export function createPluginMarketplaceStore( let scopeGeneration = 0; let catalogGeneration = 0; let libraryMutationEpoch = 0; + let libraryReadGeneration = 0; return createStore((set, get) => { const pending = async (key: string, operation: () => Promise): Promise => { @@ -109,9 +116,11 @@ export function createPluginMarketplaceStore( const generation = scopeGeneration; const accountKey = get().accountKey; if (!accountKey) throw new Error('请先登录后管理我的插件'); + const mutationEpoch = ++libraryMutationEpoch; try { const projection = await action(pluginId); - if (generation !== scopeGeneration || get().accountKey !== accountKey) return; + if (generation !== scopeGeneration || get().accountKey !== accountKey + || mutationEpoch !== libraryMutationEpoch) return; libraryMutationEpoch += 1; set({ library: projection.library, @@ -120,7 +129,8 @@ export function createPluginMarketplaceStore( libraryError: null, }); } catch (error) { - if (generation === scopeGeneration && get().accountKey === accountKey) { + if (generation === scopeGeneration && get().accountKey === accountKey + && mutationEpoch === libraryMutationEpoch) { set({ libraryError: message(error) }); } throw error; @@ -135,9 +145,11 @@ export function createPluginMarketplaceStore( const generation = scopeGeneration; const accountKey = get().accountKey; if (!accountKey) throw new Error('请先登录后管理设备插件'); + const mutationEpoch = ++libraryMutationEpoch; try { const result = await action(pluginId); - if (generation !== scopeGeneration || get().accountKey !== accountKey) return; + if (generation !== scopeGeneration || get().accountKey !== accountKey + || mutationEpoch !== libraryMutationEpoch) return; libraryMutationEpoch += 1; set((state) => { const installations = { ...state.installations }; @@ -146,8 +158,26 @@ export function createPluginMarketplaceStore( return { installations, libraryError: null }; }); } catch (error) { - if (generation === scopeGeneration && get().accountKey === accountKey) { - set({ libraryError: message(error) }); + if (generation === scopeGeneration && get().accountKey === accountKey + && mutationEpoch === libraryMutationEpoch) { + const reason = message(error); + set((state) => ({ + installations: { + ...state.installations, + [pluginId]: { + status: 'unavailable', + pluginId, + ...(state.installations[pluginId]?.releaseId + ? { releaseId: state.installations[pluginId].releaseId } + : {}), + ...(state.installations[pluginId]?.version + ? { version: state.installations[pluginId].version } + : {}), + reason, + }, + }, + libraryError: reason, + })); } throw error; } @@ -169,7 +199,8 @@ export function createPluginMarketplaceStore( activateAccount(accountKey) { if (get().accountKey === accountKey) return; scopeGeneration += 1; - libraryMutationEpoch = 0; + libraryReadGeneration += 1; + libraryMutationEpoch += 1; set({ accountKey, library: null, @@ -211,6 +242,7 @@ export function createPluginMarketplaceStore( } }, async loadLibrary() { + const readGeneration = ++libraryReadGeneration; const generation = scopeGeneration; const accountKey = get().accountKey; const mutationEpoch = libraryMutationEpoch; @@ -222,6 +254,7 @@ export function createPluginMarketplaceStore( try { const projection = await deps.readLibrary(); if (generation === scopeGeneration && get().accountKey === accountKey + && readGeneration === libraryReadGeneration && mutationEpoch === libraryMutationEpoch) { set({ library: projection.library, @@ -231,7 +264,9 @@ export function createPluginMarketplaceStore( }); } } catch (error) { - if (generation === scopeGeneration && get().accountKey === accountKey) { + if (generation === scopeGeneration && get().accountKey === accountKey + && readGeneration === libraryReadGeneration + && mutationEpoch === libraryMutationEpoch) { set({ libraryState: 'error', libraryError: message(error) }); } throw error; @@ -240,6 +275,7 @@ export function createPluginMarketplaceStore( acquire: (pluginId) => libraryAction(`acquire:${pluginId}`, pluginId, deps.acquire), remove: (pluginId) => libraryAction(`remove:${pluginId}`, pluginId, deps.remove), install: (pluginId) => installAction(`install:${pluginId}`, pluginId, deps.install), + installBeta: (pluginId) => installAction(`install-beta:${pluginId}`, pluginId, deps.installBeta), update: (pluginId) => installAction(`update:${pluginId}`, pluginId, deps.update), uninstall: (pluginId) => installAction(`uninstall:${pluginId}`, pluginId, deps.uninstall), }; diff --git a/tests/unit/coding-plugin-marketplace-client.test.ts b/tests/unit/coding-plugin-marketplace-client.test.ts index f3052d0..6070305 100644 --- a/tests/unit/coding-plugin-marketplace-client.test.ts +++ b/tests/unit/coding-plugin-marketplace-client.test.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { createHash, generateKeyPairSync, sign } from 'node:crypto'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import path from 'node:path'; import AdmZip from 'adm-zip'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -117,6 +117,7 @@ function signedGrant( archive: Buffer, options: { readonly releaseId?: string; + readonly signingKeyId?: string; readonly minMakeloreVersion?: string; readonly maxMakeloreVersion?: string | null; } = {}, @@ -146,7 +147,7 @@ function signedGrant( maxMakeloreVersion: options.maxMakeloreVersion ?? null, sizeBytes: archive.byteLength, sha256, - signingKeyId: 'test-key', + signingKeyId: options.signingKeyId ?? 'test-key', descriptorSignature: signature, expiresAt: '2026-08-29T00:00:00Z', contentUrl: `/api/plugin-marketplace/v1/releases/${releaseId}/content?release_admission_id=${ADMISSION_ID}`, @@ -321,6 +322,36 @@ describe('Marketplace client and account cache', () => { await expect(client.readCatalog({ limit: 10 })).rejects.toMatchObject({ code: 'marketplace_response_too_large' }); }); + it('applies one deadline to response headers and a body that never completes', async () => { + const neverBody = new ReadableStream({ start() { /* intentionally never closes */ } }); + const fetcher = vi.fn().mockResolvedValue(new Response(neverBody, { status: 200 })); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + requestTimeoutMs: 20, + getAccessToken: async () => null, + subscribeSession: () => () => undefined, + }); + await expect(client.readCatalog({ limit: 10 })).rejects.toMatchObject({ code: 'marketplace_request_failed' }); + }); + + it('preserves a bounded server release status from the response body', async () => { + const fetcher = vi.fn().mockResolvedValue(response({ + success: false, + code: 'plugin_release_yanked', + error: 'Release is no longer available', + }, { status: 409 })); + const client = createMarketplaceClient({ + fetchImpl: fetcher, + apiBaseUrl: 'https://square.example', + getAccessToken: async () => null, + subscribeSession: () => () => undefined, + }); + await expect(client.readCatalog({ limit: 10 })).rejects.toMatchObject({ + code: 'plugin_release_yanked', status: 409, + }); + }); + it('rejects a malformed authenticated response with a stable client error', async () => { const fetcher = vi.fn().mockResolvedValue(response({ items: [] })); const client = createMarketplaceClient({ @@ -471,6 +502,92 @@ describe('PluginPackageStore', () => { await expect(failingStore.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' })) .rejects.toMatchObject({ code: 'plugin_install_failed' }); await expect(failingStore.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: RELEASE_ID }); + + await expect(stat(path.join(temporaryRoot, 'packages', PLUGIN_ID, 'release-2'))) + .resolves.toMatchObject({ isDirectory: expect.any(Function) }); + const recoveredStore = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace: replacementMarketplace, + clientVersion: '1.0.0', + keyStore: new Map([['test-key', replacement.publicKey]]), + getAccountBinding: () => ACCOUNT_A, + }); + await expect(recoveredStore.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' })) + .resolves.toMatchObject({ status: 'installed', releaseId: 'release-2' }); + await expect(recoveredStore.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-2' }); + }); + + it('uninstalls a device package without removing the account Library snapshot', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const { grant, publicKey } = signedGrant(archive); + const accountCache = new AccountPluginCache(); + const library: MarketplaceLibrarySnapshot = { + items: [{ + pluginId: PLUGIN_ID, title: 'Example', summary: 'Example', category: 'tools', + acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', + runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, + stableVersion: '1.0.0', betaVersion: null, + }], total: 1, stale: false, fetchedAt: 1, + }; + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + sha256: grant.sha256, sizeBytes: grant.sizeBytes, + })), + issueDownload: vi.fn(async () => grant), + downloadContent: async () => archive, + readLibrary: vi.fn(async () => library), + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, marketplace, accountCache, getAccountBinding: () => ACCOUNT_A, + keyStore: new Map([['test-key', publicKey]]), clientVersion: '1.0.0', + }); + await store.syncLibrary(); + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({ status: 'removed', pluginId: PLUGIN_ID }); + expect(accountCache.getLibrary(ACCOUNT_A)).toEqual(library); + expect(accountCache.referencedReleaseIds()).toEqual(new Set()); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toBeNull(); + }); + + it('makes a cached rollback the Package Store current selection while retaining both immutable releases', async () => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const archive = buildSkillOnlyArchive(); + const first = signedGrant(archive, { releaseId: 'release-1' }); + const second = signedGrant(archive, { releaseId: 'release-2', signingKeyId: 'test-key-2' }); + let current = second; + 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', now: (() => { let value = 1; return () => value++ * 1_000; })(), + }); + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + current = first; + await store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' }); + await expect(store.readInstalledIndex()).resolves.toHaveLength(2); + await expect(store.getInstalled(PLUGIN_ID)).resolves.toMatchObject({ releaseId: 'release-1' }); + const index = JSON.parse(await readFile(path.join(temporaryRoot, 'index.json'), 'utf8')) as { + releases: Array<{ release_id: string }>; + }; + expect(index.releases.map(({ release_id }) => release_id)).toEqual(['release-2', 'release-1']); + + store.registerActiveWorker('release-1'); + await expect(store.uninstall(PLUGIN_ID)).resolves.toMatchObject({ + status: 'removed', 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('preserves the old release across download, signature, and extraction failures', async () => { @@ -664,4 +781,34 @@ describe('PluginPackageStore', () => { await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, channel: 'beta', makeloreVersion: '1.0.0' })) .rejects.toMatchObject({ code: 'plugin_beta_selection_required' }); }); + + it.each([ + 'plugin_release_yanked', + 'plugin_incompatible_client', + 'plugin_signature_invalid', + ] as const)('preserves bounded resolve unavailable code %s for the UI projection', async (reason) => { + temporaryRoot = await mkdtemp(path.join(process.cwd(), '.marketplace-test-')); + const marketplace: MarketplaceClient = { + resolve: vi.fn(async (input: ResolveRequest) => makeResolveResult(input, { + action: 'unavailable', + releaseId: null, + version: null, + sha256: null, + sizeBytes: null, + releaseAdmissionId: null, + reason, + })), + issueDownload: vi.fn(), + downloadContent: vi.fn(), + getCurrentAccountBinding: () => ACCOUNT_A, + } as MarketplaceClient; + const store = new PluginPackageStore({ + rootDir: temporaryRoot, + marketplace, + getAccountBinding: () => ACCOUNT_A, + clientVersion: '1.0.0', + }); + await expect(store.resolveAndInstall({ pluginId: PLUGIN_ID, makeloreVersion: '1.0.0' })) + .rejects.toMatchObject({ code: reason }); + }); }); diff --git a/tests/unit/coding-product-services.test.ts b/tests/unit/coding-product-services.test.ts index fc3a972..d9fb72e 100644 --- a/tests/unit/coding-product-services.test.ts +++ b/tests/unit/coding-product-services.test.ts @@ -325,13 +325,14 @@ describe('Marketplace public Library projection', () => { }, ]; - it('rebuilds fresh-process installation state and exposes only the latest safe release', async () => { + it('rebuilds fresh-process installation state from the Package Store current selection', 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], packageRoot: 'ignored', definition: {} }), resolveAndInstall: vi.fn(), removeUnused: vi.fn(), }; const service = createCodingPluginMarketplaceService({ @@ -343,7 +344,7 @@ describe('Marketplace public Library projection', () => { library: snapshot, installations: [{ status: 'installed', pluginId: 'makelore.notes', - releaseId: 'release-new', version: '1.5.0', + releaseId: 'release-old', version: '1.0.0', }], }); expect(JSON.stringify(result)).not.toMatch(/sha256|sizeBytes|installedAt|packageRoot|definition|account|admission|token/i); @@ -356,6 +357,7 @@ describe('Marketplace public Library projection', () => { }; const packageStore = { readInstalledIndex: vi.fn().mockResolvedValue(records.slice(1, 2)), + getInstalled: vi.fn().mockResolvedValue({ ...records[1], packageRoot: 'ignored', definition: {} }), resolveAndInstall: vi.fn(), removeUnused: vi.fn(), }; const service = createCodingPluginMarketplaceService({ @@ -367,6 +369,6 @@ describe('Marketplace public Library projection', () => { installations: [{ status: 'installed', pluginId: 'makelore.notes', releaseId: 'release-new', version: '1.5.0' }], }); expect(marketplace[action]).toHaveBeenCalledWith('makelore.notes'); - expect(packageStore.readInstalledIndex).toHaveBeenCalledOnce(); + expect(packageStore.getInstalled).toHaveBeenCalledOnce(); }); }); diff --git a/tests/unit/host-api.test.ts b/tests/unit/host-api.test.ts index 9fde98a..aa5a00e 100644 --- a/tests/unit/host-api.test.ts +++ b/tests/unit/host-api.test.ts @@ -141,6 +141,32 @@ describe('host-api', () => { ); }); + it('preserves a bounded Marketplace status through browser fallback', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + statusText: 'Conflict', + json: async () => ({ + success: false, + code: 'plugin_release_yanked', + error: 'Release is no longer available', + }), + }); + vi.stubGlobal('fetch', fetchMock); + window.localStorage.setItem('niancode:allow-localhost-fallback', '1'); + invokeIpcMock.mockResolvedValueOnce({ + ok: false, + error: { message: 'No handler registered for hostapi:fetch' }, + }); + + const { hostApiFetch } = await import('@/lib/host-api'); + await expect(hostApiFetch('/api/coding/plugin-marketplace/install/notes')) + .rejects.toMatchObject({ + message: 'Release is no longer available', + details: { backendCode: 'plugin_release_yanked', status: 409 }, + }); + }); + it('throws message from legacy non-ok envelope', async () => { invokeIpcMock.mockResolvedValueOnce({ success: true, diff --git a/tests/unit/pi-product-artifact.test.ts b/tests/unit/pi-product-artifact.test.ts index 0daf7c0..a3f30a6 100644 --- a/tests/unit/pi-product-artifact.test.ts +++ b/tests/unit/pi-product-artifact.test.ts @@ -14,6 +14,7 @@ import { collectForbiddenResourcePaths, verifyBundledCodingPluginResources, defaultProductExecutable, + readPackagedMarketplaceTrustSource, validatePiArtifactMetadata, verifyMarketplaceClientArtifact, } from '../../scripts/lib/pi-product-artifact.mjs'; @@ -198,7 +199,8 @@ describe('final Pi product artifact verification', () => { const trustSource = `export const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze( {} as Readonly>, - );`; + ); + export const sourceMarker = 'makelore.plugin-trust.code-owned.v1';`; expect(verifyMarketplaceClientArtifact(artifact, trustSource)).toMatchObject({ schema2SkillOnly: true, productionTrust: 'official-key-absent-fail-closed', @@ -229,6 +231,50 @@ describe('final Pi product artifact verification', () => { )).toThrow('empty code-owned fail-closed store'); }); + it('proves Marketplace trust from the packaged app.asar rather than checkout source', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-marketplace-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, 'dist-electron', 'main.js'), [ + 'const CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({});', + 'const unrelatedConfiguration = process.env.NIANCODE_E2E;', + 'export const marketplace = true;', + ].join('\n')); + const appAsar = path.join(root, 'app.asar'); + await createPackage(source, appAsar); + await expect(readPackagedMarketplaceTrustSource(appAsar)).resolves.toBe( + 'CODE_OWNED_PLUGIN_SIGNING_KEYS = Object.freeze({})', + ); + }); + + it('recognizes a minified trust table only when its packaged provenance marker is present', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-marketplace-compiled-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, 'dist-electron', 'main.js'), [ + 'const dC = Object.freeze({});', + '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 expect(readPackagedMarketplaceTrustSource(appAsar)).resolves.toContain( + 'makelore.plugin-trust.code-owned.v1', + ); + expect(verifyMarketplaceClientArtifact( + Buffer.from([ + '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 dC = Object.freeze({}); sourceMarker: makelore.plugin-trust.code-owned.v1', + )).toMatchObject({ productionTrust: 'official-key-absent-fail-closed' }); + }); + 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/pi-resource-loader.test.ts b/tests/unit/pi-resource-loader.test.ts index 0d5eafa..8057fe0 100644 --- a/tests/unit/pi-resource-loader.test.ts +++ b/tests/unit/pi-resource-loader.test.ts @@ -166,6 +166,33 @@ describe('Pi managed resource loader', () => { expect(resources.catalogRevision).toBe(13); }); + it('keeps each effective Skill paired with its verified package root when relative paths collide', async () => { + const fixture = await fixtureRoot(); + const firstRoot = path.join(fixture.root, 'packages', 'first'); + const secondRoot = path.join(fixture.root, 'packages', 'second'); + const relativeEntry = 'skills/shared/SKILL.md'; + await Promise.all([ + mkdir(path.join(firstRoot, 'skills', 'shared'), { recursive: true }), + mkdir(path.join(secondRoot, 'skills', 'shared'), { recursive: true }), + ]); + await Promise.all([ + writeFile(path.join(firstRoot, relativeEntry), 'first package', 'utf8'), + writeFile(path.join(secondRoot, relativeEntry), 'second package', 'utf8'), + ]); + + const resolved = await resolveExplicitCodingSkillPaths(fixture.skillsDir, [ + { id: 'first-skill', entryPath: relativeEntry, packageRoot: firstRoot }, + { id: 'second-skill', entryPath: relativeEntry, packageRoot: secondRoot }, + ]); + + expect(resolved.skillPaths).toEqual([ + path.join(firstRoot, relativeEntry), + path.join(secondRoot, relativeEntry), + ]); + await expect(readFile(resolved.skillPaths[0]!, 'utf8')).resolves.toBe('first package'); + await expect(readFile(resolved.skillPaths[1]!, 'utf8')).resolves.toBe('second package'); + }); + it('filters a known disabled plugin Skill for the next worker and restores it after re-enable', async () => { const fixture = await fixtureRoot(); const assignedSkillIds = ['grilling', 'data-service']; diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx index c085cc1..a78c67f 100644 --- a/tests/unit/plugin-marketplace-pages.test.tsx +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -68,7 +68,7 @@ describe('My Plugins', () => { const library: MarketplaceLibrarySnapshot = { total: 4, stale: false, fetchedAt: 1, items: [ - { pluginId: 'makelore.notes', title: '灵感笔记', summary: 'Notes', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '2.0.0', betaVersion: null }, + { pluginId: 'makelore.notes', title: '灵感笔记', summary: 'Notes', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '2.0.0', betaVersion: '2.1.0-beta.1' }, { pluginId: 'makelore.removed', title: '旧插件', summary: 'Removed', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', acquiredAt: '2026-08-27T00:00:00Z', removedAt: '2026-08-28T00:00:00Z', stableVersion: '1.0.0', betaVersion: null }, { pluginId: 'makelore.retired', title: '已退役插件', summary: 'Retired', category: '效率', acquisition: 'free', acquisitionMode: 'user_acquired', catalogStatus: 'retired', runtimeStatus: 'enabled', acquiredAt: '2026-08-26T00:00:00Z', removedAt: '2026-08-28T00:00:00Z', stableVersion: '1.0.0', betaVersion: null }, { pluginId: 'makelore.system', title: '开发数据服务', summary: 'Data', category: '系统', acquisition: 'system_included', acquisitionMode: 'system_included', catalogStatus: 'active', runtimeStatus: 'suspended', acquiredAt: null, removedAt: null, stableVersion: '1.0.0', betaVersion: null }, @@ -82,7 +82,7 @@ describe('My Plugins', () => { render(); fireEvent.click(screen.getByRole('button', { name: '更新灵感笔记' })); @@ -98,15 +98,29 @@ describe('My Plugins', () => { expect(screen.getByText('已退役,移除后不可重新获取')).toBeVisible(); }); + it('offers an explicit Beta action without conflating it with stable updates', () => { + const onInstallBeta = vi.fn(); + render(); + const beta = screen.getByRole('button', { name: '安装 Beta灵感笔记' }); + fireEvent.click(beta); + expect(onInstallBeta).toHaveBeenCalledWith('makelore.notes'); + expect(screen.getByText('稳定版 2.0.0')).toBeVisible(); + }); + it('names signature, yanked/not-ready, and incompatible failures without hiding the old install', () => { render(); expect(screen.getByText(/签名校验失败/)).toBeVisible(); - expect(screen.getByText(/版本已撤回或未就绪/)).toBeVisible(); + expect(screen.getByText(/此版本已撤回/)).toBeVisible(); expect(screen.getByText(/当前 MakeLore 版本不兼容/)).toBeVisible(); expect(screen.getByText('设备版本 1.5.0')).toBeVisible(); }); @@ -115,14 +129,14 @@ describe('My Plugins', () => { const { rerender } = render(); expect(screen.getByRole('alert')).toHaveTextContent('签名或包校验失败'); rerender(); - expect(screen.getByRole('alert')).toHaveTextContent('已撤回、尚未就绪或与当前 MakeLore 不兼容'); + expect(screen.getByRole('alert')).toHaveTextContent('版本尚未就绪;不会替换此前可用版本'); }); }); diff --git a/tests/unit/plugin-marketplace-routes.test.ts b/tests/unit/plugin-marketplace-routes.test.ts index 12cd423..a52d308 100644 --- a/tests/unit/plugin-marketplace-routes.test.ts +++ b/tests/unit/plugin-marketplace-routes.test.ts @@ -5,6 +5,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { describe, expect, it, vi } from 'vitest'; import type { HostApiContext } from '../../electron/api/context'; import { handlePluginMarketplaceRoutes } from '../../electron/api/routes/plugin-marketplace'; +import { MarketplaceClientError } from '../../electron/coding-plugins/marketplace-client'; +import { PluginPackageStoreError } from '../../electron/coding-plugins/package-store'; function request(method: string, body?: unknown): IncomingMessage { const req = new EventEmitter(); @@ -85,21 +87,24 @@ describe('Main-owned plugin Marketplace routes', () => { const acquire = vi.fn().mockResolvedValue({ items: [] }); const remove = vi.fn().mockResolvedValue({ items: [] }); const install = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'notes' }); + const installBeta = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'notes', version: '2.0.0-beta.1' }); const update = vi.fn().mockResolvedValue({ status: 'installed', pluginId: 'notes' }); const uninstall = vi.fn().mockResolvedValue({ status: 'removed', pluginId: 'notes' }); const ctx = { codingProducts: { pluginMarketplace: { - acquire, remove, install, update, uninstall, + acquire, remove, install, installBeta, update, uninstall, readCatalog: vi.fn(), readDetail: vi.fn(), readLibrary: vi.fn(), } } } as unknown as HostApiContext; expect((await invoke(ctx, 'PUT', '/api/coding/plugin-marketplace/library/notes')).status).toBe(200); expect((await invoke(ctx, 'DELETE', '/api/coding/plugin-marketplace/library/notes')).status).toBe(200); expect((await invoke(ctx, 'POST', '/api/coding/plugin-marketplace/install/notes')).status).toBe(200); + expect((await invoke(ctx, 'POST', '/api/coding/plugin-marketplace/install/notes/beta')).status).toBe(200); expect((await invoke(ctx, 'POST', '/api/coding/plugin-marketplace/update/notes')).status).toBe(200); expect((await invoke(ctx, 'DELETE', '/api/coding/plugin-marketplace/install/notes')).status).toBe(200); expect(acquire).toHaveBeenCalledWith('notes'); expect(remove).toHaveBeenCalledWith('notes'); expect(install).toHaveBeenCalledWith('notes'); + expect(installBeta).toHaveBeenCalledWith('notes'); expect(update).toHaveBeenCalledWith('notes'); expect(uninstall).toHaveBeenCalledWith('notes'); }); @@ -117,4 +122,16 @@ describe('Main-owned plugin Marketplace routes', () => { expect(forged).toMatchObject({ status: 400, payload: { code: 'plugin_request_invalid' } }); expect(acquire).not.toHaveBeenCalled(); }); + + it.each([ + { error: new MarketplaceClientError('plugin_release_yanked', 409), status: 409, code: 'plugin_release_yanked' }, + { error: new PluginPackageStoreError('plugin_incompatible_client'), status: 409, code: 'plugin_incompatible_client' }, + { error: new PluginPackageStoreError('plugin_signature_invalid'), status: 422, code: 'plugin_signature_invalid' }, + ])('keeps bounded release status $code visible through the Main route', async ({ error, status, code }) => { + const install = vi.fn().mockRejectedValue(error); + const ctx = { codingProducts: { pluginMarketplace: { install } } } as unknown as HostApiContext; + const result = await invoke(ctx, 'POST', '/api/coding/plugin-marketplace/install/notes'); + expect(result).toMatchObject({ status, payload: { success: false, code } }); + expect(JSON.stringify(result.payload)).not.toMatch(/path|token|private|secret|stack/i); + }); }); diff --git a/tests/unit/plugin-marketplace-store.test.ts b/tests/unit/plugin-marketplace-store.test.ts index 9370249..5cd286a 100644 --- a/tests/unit/plugin-marketplace-store.test.ts +++ b/tests/unit/plugin-marketplace-store.test.ts @@ -70,6 +70,53 @@ describe('plugin Marketplace store', () => { expect(store.getState().library?.items[0].title).toBe('Acquired'); }); + it('keeps the newest same-account Library read when an older response completes last', async () => { + const first = deferred(); + const second = deferred(); + const readLibrary = vi.fn() + .mockImplementationOnce(() => first.promise) + .mockImplementationOnce(() => second.promise); + const store = createPluginMarketplaceStore({ readLibrary }); + store.getState().activateAccount('account-a'); + const oldest = store.getState().loadLibrary(); + const newest = store.getState().loadLibrary(); + second.resolve(projection('newest')); await newest; + first.resolve(projection('oldest')); await oldest; + expect(store.getState().library?.items[0].title).toBe('newest'); + }); + + it('does not let an older mutation response overwrite a newer mutation for another plugin', async () => { + const make = (pluginId: string, title: string): MarketplaceLibraryProjection => ({ + library: { + total: 1, stale: false, fetchedAt: 1, + items: [{ + pluginId, title, summary: title, category: 'tools', acquisition: 'free', + acquisitionMode: 'user_acquired', catalogStatus: 'active', runtimeStatus: 'enabled', + acquiredAt: '2026-08-28T00:00:00Z', removedAt: null, stableVersion: '1.0.0', betaVersion: null, + }], + }, + installations: [], + }); + for (const order of ['a-then-b', 'b-then-a'] as const) { + const a = deferred(); + const b = deferred(); + const store = createPluginMarketplaceStore({ + acquire: vi.fn((pluginId: string) => pluginId === 'makelore.a' ? a.promise : b.promise), + }); + store.getState().activateAccount('account-a'); + const aRequest = store.getState().acquire('makelore.a'); + const bRequest = store.getState().acquire('makelore.b'); + if (order === 'a-then-b') { + a.resolve(make('makelore.a', 'A older')); await aRequest; + b.resolve(make('makelore.b', 'B newer')); await bRequest; + } else { + b.resolve(make('makelore.b', 'B newer')); await bRequest; + a.resolve(make('makelore.a', 'A older')); await aRequest; + } + expect(store.getState().library?.items[0].title).toBe('B newer'); + } + }); + 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' }); @@ -86,4 +133,21 @@ describe('plugin Marketplace store', () => { expect(install).toHaveBeenCalledOnce(); expect(setProjectEnabled).not.toHaveBeenCalled(); }); + + it('projects a bounded installation failure on the affected My Plugins entry', async () => { + const error = Object.assign(new Error('Release signature is invalid'), { + code: 'plugin_signature_invalid', + }); + const store = createPluginMarketplaceStore({ + install: vi.fn().mockRejectedValue(error), + }); + store.getState().activateAccount('account-a'); + + await expect(store.getState().install('makelore.notes')).rejects.toBe(error); + expect(store.getState().installations['makelore.notes']).toMatchObject({ + status: 'unavailable', + pluginId: 'makelore.notes', + reason: 'plugin_signature_invalid: Release signature is invalid', + }); + }); });