From e06ccd017c971a1aaf55b7340f76728b3cc5beb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 04:12:18 +0000 Subject: [PATCH 1/8] Fix market-installed plugins: usePluginSdkFor/getPluginSdk can't find themselves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin installed via a market gets a registry id prefixed with - (e.g. "official-container-manager") to avoid colliding with another market publishing the same plugin id (installer.ts's installedPluginManifests()). But the plugin's own bundled code has no idea about that prefix and always calls usePluginSdkFor/getPluginSdk with its bare, self-authored id (e.g. usePluginSdkFor('container-manager')) — it can't know at build time which market, if any, it'll be installed under. usePluginSdkFor's context match compared against the namespaced `id`, so it never matched for any market install, falling through to getPlugin(bareId) — which also only ever indexed PLUGIN_MAP by the namespaced id — and threw "Không có plugin ... trong registry" even though the plugin was installed and actively rendering. getPluginSdk (used by plugins for module-scope stores, e.g. Kafka's/RabbitMQ's consumer stores that must survive route changes) had the exact same bug, unconditionally. Fix: track the original, un-prefixed id separately as `baseId` on PluginManifest/PluginSdk (installer.ts sets it whenever it builds a market-prefixed registry id), match usePluginSdkFor's context check against `baseId` instead of `id`, and have getPlugin() fall back to a baseId scan when the exact (possibly-prefixed) key misses. The `id` field itself is untouched everywhere it actually needs to stay namespaced (storage keys, audit log, PLUGIN_MAP routing) — only the plugin's self-lookup path changes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BCypCuViyQDKGWxWspXKs2 --- src/platform/installer.ts | 4 ++++ src/platform/registry.installer.test.ts | 24 ++++++++++++++++++++++++ src/platform/registry.ts | 15 ++++++++++++++- src/platform/sdk.test.ts | 14 ++++++++++++++ src/platform/sdk.ts | 5 +++++ src/platform/types.ts | 10 ++++++++++ src/platform/usePluginSdkFor.ts | 9 ++++++++- 7 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/platform/installer.ts b/src/platform/installer.ts index c381d3b..fa5fceb 100644 --- a/src/platform/installer.ts +++ b/src/platform/installer.ts @@ -477,6 +477,10 @@ export async function installedPluginManifests(): Promise< source: `installed:${registryId}@${record.manifest.version}`, manifest: { id: registryId, + // Chỉ khác `id` khi có tiền tố market — bundle của plugin gọi + // `usePluginSdkFor(record.manifest.id)` bằng đúng id GỐC này, không + // biết gì về tiền tố market (xem PluginManifest.baseId). + baseId: record.manifest.id, label: record.manifest.label, icon: ICONS_BY_NAME[record.manifest.icon] ?? Puzzle, description: record.manifest.description, diff --git a/src/platform/registry.installer.test.ts b/src/platform/registry.installer.test.ts index a2dc8e1..fb8f92f 100644 --- a/src/platform/registry.installer.test.ts +++ b/src/platform/registry.installer.test.ts @@ -119,6 +119,30 @@ describe('initInstalledPlugins', () => { expect(registry.getPlugin('installed-demo')?.route).toBe('/a'); }); + it('plugin cài qua market: getPlugin() tra được cả bằng id đã tiền tố lẫn baseId gốc', async () => { + // Mô phỏng đúng những gì installedPluginManifests() tự sinh cho một bản + // cài có marketId (xem installer.ts): id đăng ký bị tiền tố + // `-`, baseId giữ nguyên id gốc plugin tự khai. + const { installedPluginManifests } = await import('./installer'); + vi.mocked(installedPluginManifests).mockResolvedValue([ + { + manifest: installedManifest({ id: 'official-container-manager', baseId: 'container-manager', route: '/installed/official-container-manager' }), + source: 'installed:official-container-manager@1.0.0', + }, + ]); + + const registry = await freshRegistry(); + await registry.initInstalledPlugins(); + + // Đúng registry id (dùng cho routing/storage/audit). + expect(registry.getPlugin('official-container-manager')?.baseId).toBe('container-manager'); + // Đúng id GỐC — đây là cái bundle của chính plugin gọi lại chính mình + // bằng (usePluginSdkFor('container-manager')/getPluginSdk('container-manager')). + // Thiếu fallback này là đúng lỗi thật đã xảy ra: "Không có plugin + // 'container-manager' trong registry" dù plugin ĐÃ cài và ĐANG render. + expect(registry.getPlugin('container-manager')?.id).toBe('official-container-manager'); + }); + it('manifest hỏng (thiếu route) bị loại vào PLUGIN_ERRORS, không làm hỏng những manifest hợp lệ khác', async () => { const { installedPluginManifests } = await import('./installer'); vi.mocked(installedPluginManifests).mockResolvedValue([ diff --git a/src/platform/registry.ts b/src/platform/registry.ts index 305e7cf..5bd80b0 100644 --- a/src/platform/registry.ts +++ b/src/platform/registry.ts @@ -128,8 +128,21 @@ export const DEFAULT_PLUGIN_FEATURES: Readonly> = Object records.map((p) => [p.id, p.defaultEnabled]), ); +/** + * Tra theo `id` (khoá registry, có thể đã bị tiền tố `-`) trước; + * không thấy thì thử lại theo `baseId` (id GỐC không tiền tố mà chính plugin + * tự khai và tự gọi lại chính mình bằng đó — xem `PluginManifest.baseId`). + * Cần cho `getPluginSdk`/`usePluginSdkFor`: code BÊN TRONG bundle của một + * plugin cài qua market không biết (và không nên biết) registry đã tiền tố + * mình, nên luôn gọi bằng id gốc. + * + * Nhập nhằng khi hai market khác nhau cùng cài một plugin trùng `baseId` — + * trả về bản ghi ĐĂNG KÝ TRƯỚC. Đây là giới hạn đã có sẵn của việc để một + * plugin module-scope singleton (`getPluginSdk`'s cache) tự nhận diện mình + * bằng đúng một chuỗi id, không phải lỗi mới do fallback này gây ra. + */ export function getPlugin(id: string): PluginRecord | undefined { - return PLUGIN_MAP.get(id); + return PLUGIN_MAP.get(id) ?? records.find((p) => p.baseId === id); } /** diff --git a/src/platform/sdk.test.ts b/src/platform/sdk.test.ts index 21c9dc0..8cadb99 100644 --- a/src/platform/sdk.test.ts +++ b/src/platform/sdk.test.ts @@ -91,6 +91,20 @@ describe('secrets', () => { }); }); +describe('baseId', () => { + it('không khai baseId trong manifest thì mặc định bằng id', () => { + const sdk = createPluginSdk(plugin([])); + expect(sdk.baseId).toBe('demo'); + }); + + it('có baseId trong manifest (plugin cài qua market, id đã bị tiền tố) thì giữ nguyên baseId khác id', () => { + const manifest = { ...plugin([]), id: 'official-demo', baseId: 'demo' }; + const sdk = createPluginSdk(manifest); + expect(sdk.id).toBe('official-demo'); + expect(sdk.baseId).toBe('demo'); + }); +}); + describe('env', () => { it('phơi ra thông tin môi trường mà không cần quyền nào', () => { const sdk = createPluginSdk(plugin([])); diff --git a/src/platform/sdk.ts b/src/platform/sdk.ts index 8c3628f..78496a2 100644 --- a/src/platform/sdk.ts +++ b/src/platform/sdk.ts @@ -130,6 +130,10 @@ export interface PluginFiles { export interface PluginSdk { readonly id: string; + /** Id gốc không tiền tố market — xem `PluginManifest.baseId`. Bằng `id` khi + * plugin không cài qua market. `usePluginSdkFor` so khớp bằng field này, + * không phải `id`, vì đó là cái plugin tự truyền vào lời gọi của chính nó. */ + readonly baseId: string; readonly sdkVersion: string; readonly permissions: readonly PluginPermission[]; readonly env: PluginEnv; @@ -216,6 +220,7 @@ export function createPluginSdk(manifest: PluginManifest): PluginSdk { return { id, + baseId: manifest.baseId ?? id, sdkVersion: SDK_VERSION, permissions: manifest.permissions ?? [], env: { isTauri, isMac: IS_MAC, modKey: MOD_KEY }, diff --git a/src/platform/types.ts b/src/platform/types.ts index e3650e5..dd03c4b 100644 --- a/src/platform/types.ts +++ b/src/platform/types.ts @@ -46,6 +46,16 @@ export type PluginPermission = export interface PluginManifest { /** kebab-case, duy nhất toàn app. Đồng thời là khoá bật/tắt và namespace storage. */ id: string; + /** + * Id GỐC như chính plugin tự biết về mình — khác `id` CHỈ khi một plugin cài + * từ market bị tiền tố `-` để tránh đụng một market khác cùng + * phát hành trùng id (xem `installer.ts`'s `installedPluginManifests()`). + * Bundle của plugin gọi `usePluginSdkFor('container-manager')` bằng đúng id + * này — nó không (và không nên) biết mình đang chạy dưới một registry id đã + * bị tiền tố. Không set thì coi như bằng `id` (mọi plugin compile-time và + * mọi plugin cài không qua market). + */ + baseId?: string; label: string; icon: LucideIcon; description: string; diff --git a/src/platform/usePluginSdkFor.ts b/src/platform/usePluginSdkFor.ts index 9da6658..5b0ac58 100644 --- a/src/platform/usePluginSdkFor.ts +++ b/src/platform/usePluginSdkFor.ts @@ -47,7 +47,14 @@ export function getPluginSdk(pluginId: string): PluginSdk { export function usePluginSdkFor(pluginId: string): PluginSdk { const fromContext = usePluginSdkOptional(); return useMemo(() => { - if (fromContext?.id === pluginId) return fromContext; + // So bằng `baseId`, không phải `id`: một plugin cài từ market chạy dưới + // một registry `id` bị tiền tố `-` (xem installer.ts), nhưng + // bundle của chính plugin đó gọi `usePluginSdkFor(...)` bằng đúng id GỐC + // nó tự khai trong manifest — nó không (và không nên) biết registry đã + // tiền tố mình. So bằng `id` ở đây khiến MỌI plugin cài qua market luôn + // rơi xuống nhánh "không có trong registry" bên dưới, dù đang render + // đúng bên trong cây của chính nó. + if (fromContext?.baseId === pluginId) return fromContext; const manifest = getPlugin(pluginId); if (!manifest) { throw new Error( From d53b3c7ead336ed09fd5deeaffc970b74ced6a3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 04:22:19 +0000 Subject: [PATCH 2/8] installer.test.ts: assert baseId directly (Pullfrog review, PR #153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both existing marketId tests checked the prefixed id/route but never baseId itself — deleting installer.ts's `baseId: record.manifest.id` line kept every test green while reintroducing the exact bug this PR fixes (usePluginSdkFor/getPluginSdk throwing "not in registry" for any market-installed plugin). Confirmed by temporarily removing that line locally: these two new assertions fail, everything else still passes. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BCypCuViyQDKGWxWspXKs2 --- src/platform/installer.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/platform/installer.test.ts b/src/platform/installer.test.ts index fa50e5e..a087ce6 100644 --- a/src/platform/installer.test.ts +++ b/src/platform/installer.test.ts @@ -448,6 +448,7 @@ describe('installedPluginManifests — đổi RemotePluginManifest thành Plugin const [a] = await installer.installedPluginManifests(); expect(a.manifest.id).toBe('demo'); expect(a.manifest.route).toBe('/demo'); + expect(a.manifest.baseId).toBe('demo'); }); it('có marketId thì id/route đăng ký registry được ghép marketId — hai market khác nhau cùng id KHÔNG đụng route/storage của nhau', async () => { @@ -464,6 +465,12 @@ describe('installedPluginManifests — đổi RemotePluginManifest thành Plugin expect(b.manifest.route).toBe('/installed/custom-fork-demo'); expect(a.manifest.id).not.toBe(b.manifest.id); expect(a.manifest.route).not.toBe(b.manifest.route); + // Cái mà usePluginSdkFor()/getPluginSdk() BÊN TRONG chính bundle của + // plugin thực sự gọi lại — mất dòng này ở installer.ts thì cả hai bản + // cài lại ném "không có plugin trong registry" y hệt bug gốc, dù id/route + // ở trên vẫn đúng. Cả hai market đều giữ baseId gốc, KHÔNG ghép marketId. + expect(a.manifest.baseId).toBe('demo'); + expect(b.manifest.baseId).toBe('demo'); }); }); From 7f2525e2d0da562ef1476240daff281f45efe17c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 04:34:58 +0000 Subject: [PATCH 3/8] registry.ts: reject a second install sharing baseId, don't misidentify it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the market-prefix fix: a plugin installed from a bare URL (no marketId, keeps its unprefixed id) and the SAME plugin installed again from a market (id gets prefixed -) don't collide on id/route/order at all — those checks all compare different strings between the two records, so both registered successfully. That's worse than it sounds: getPluginSdk(bareId) (module-scope, no React context — used by Kafka's/RabbitMQ's consumer stores) looks up PLUGIN_MAP by the exact string first, before ever trying the baseId fallback. With both plugins registered, that exact lookup hits the bare-URL install directly regardless of which bundle's code is actually calling — the market-installed instance's module-scope calls would silently receive the URL install's SDK: wrong storage namespace, wrong audit id, wrong permission set. Two independent installs would leak into each other's persisted state. Added a seenBaseIds guard alongside the existing seenIds/seenRoutes/ seenOrders in registerManifest — a second registration sharing an effective baseId (m.baseId ?? m.id) is now rejected into PLUGIN_ERRORS like any other collision, instead of silently coexisting and letting getPlugin()'s baseId fallback (and getPluginSdk's cache) misattribute identity. Also simplified getPlugin()'s doc comment: with duplicates now rejected at registration, its baseId scan can only ever match one record, no more first-wins ambiguity to describe. New test reproduces the exact scenario (URL install + market install of the same plugin) and confirms it now fails closed instead of silently misidentifying; confirmed by temporarily reverting the guard, which makes the test fail for the right reason. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BCypCuViyQDKGWxWspXKs2 --- src/platform/registry.installer.test.ts | 38 +++++++++++++++++++++++++ src/platform/registry.ts | 36 +++++++++++++++++++---- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/platform/registry.installer.test.ts b/src/platform/registry.installer.test.ts index fb8f92f..cd44c8e 100644 --- a/src/platform/registry.installer.test.ts +++ b/src/platform/registry.installer.test.ts @@ -143,6 +143,44 @@ describe('initInstalledPlugins', () => { expect(registry.getPlugin('container-manager')?.id).toBe('official-container-manager'); }); + it('một plugin cài qua URL trần + CÙNG plugin đó cài lại qua market (trùng baseId) — bản thứ hai bị từ chối, không được phép nhận nhầm SDK của bản kia', async () => { + // Kịch bản thật: người dùng cài "container-manager" qua URL manifest dán + // tay trước (không marketId, giữ nguyên id gốc) — rồi sau đó cài lại + // đúng plugin đó nhưng qua market "official". `id`/`route` của hai bản + // ghi này KHÁC NHAU HOÀN TOÀN (một bên không tiền tố, một bên có) nên ba + // kiểm tra seenIds/seenRoutes/seenOrders không bắt được gì — nếu không có + // seenBaseIds, cả hai đăng ký "thành công", và getPluginSdk('container-manager') + // gọi module-scope (không qua context) từ bên trong bản market sẽ ÂM THẦM + // nhận nhầm sdk của bản URL-install (khớp PLUGIN_MAP theo đúng chuỗi + // 'container-manager' trước khi kịp thử baseId) — sai storage/audit/quyền. + const { installedPluginManifests } = await import('./installer'); + vi.mocked(installedPluginManifests).mockResolvedValue([ + { + manifest: installedManifest({ id: 'container-manager', baseId: 'container-manager', route: '/container-manager', order: 100_000 }), + source: 'installed:container-manager@1.0.0', + }, + { + // order khác bản trên: chỉ seenBaseIds mới có thể bắt cặp này — nếu + // vô tình để trùng order/route/id với bản kia, test sẽ pass vì một lý + // do khác hoàn toàn, không kiểm được đúng thứ cần kiểm. + manifest: installedManifest({ id: 'official-container-manager', baseId: 'container-manager', route: '/installed/official-container-manager', order: 100_001 }), + source: 'installed:official-container-manager@1.0.0', + }, + ]); + + const registry = await freshRegistry(); + await registry.initInstalledPlugins(); + + // Bản đầu (URL trần) đăng ký bình thường. + expect(registry.getPlugin('container-manager')?.id).toBe('container-manager'); + // Bản thứ hai (market) bị TỪ CHỐI rõ ràng — không được phép âm thầm tồn + // tại song song rồi gây nhận nhầm danh tính ở getPluginSdk module-scope. + expect(registry.getPlugin('official-container-manager')).toBeUndefined(); + expect( + registry.PLUGIN_ERRORS.some((e) => e.id === 'official-container-manager' && e.reason.includes('container-manager')), + ).toBe(true); + }); + it('manifest hỏng (thiếu route) bị loại vào PLUGIN_ERRORS, không làm hỏng những manifest hợp lệ khác', async () => { const { installedPluginManifests } = await import('./installer'); vi.mocked(installedPluginManifests).mockResolvedValue([ diff --git a/src/platform/registry.ts b/src/platform/registry.ts index 5bd80b0..9fb2b8e 100644 --- a/src/platform/registry.ts +++ b/src/platform/registry.ts @@ -11,7 +11,7 @@ import type { PluginLoadError, PluginManifest, PluginRecord } from './types'; * compile-time), rồi sau đó — lúc app khởi động, xem `initInstalledPlugins()` * — nạp thêm plugin đã CÀI TỪ BÊN NGOÀI (`src/platform/installer.ts`). Cả hai * loại đi qua đúng một hàm đăng ký (`registerManifest`) nên chịu chung một bộ - * luật: hợp lệ, không đụng id/route/order của nhau, và có audit như nhau. + * luật: hợp lệ, không đụng id/baseId/route/order của nhau, và có audit như nhau. * * `eager: true` là cố ý và KHÔNG kéo tool vào bundle khởi động: file manifest * chỉ chứa metadata + một closure `load` chưa được gọi, nên code thật của tool @@ -36,6 +36,20 @@ export const PLUGIN_MAP: Map = new Map(); const seenIds = new Map(); const seenRoutes = new Map(); const seenOrders = new Map(); +/** + * Khoá theo `baseId` HIỆU LỰC (`m.baseId ?? m.id`) — id GỐC mà chính bundle + * của một plugin tự gọi lại chính mình qua `usePluginSdkFor`/`getPluginSdk` + * (xem `PluginManifest.baseId`). Cần THÊM một bảng riêng ngoài `seenIds`: + * hai bản ghi có thể có `id` (khoá registry) khác nhau hoàn toàn — một plugin + * cài từ URL trần giữ nguyên id gốc, một plugin CÙNG id gốc đó cài từ market + * lại bị tiền tố — nên không đụng `seenIds`/`seenRoutes` chút nào, ĐĂNG KÝ + * ĐƯỢC CẢ HAI. Không có bảng này, `getPlugin(baseId)`/`getPluginSdk(baseId)` + * gọi module-scope (không qua React context, không phân biệt được đang chạy + * trong bundle nào) sẽ ÂM THẦM khớp bản ghi SAI — storage/audit/permission + * của một plugin bị gán nhầm cho plugin kia. Từ chối bản đăng ký SAU thay vì + * để xảy ra chuyện đó, giống hệt cách seenIds/seenRoutes/seenOrders đã làm. + */ +const seenBaseIds = new Map(); /** * Đăng ký một manifest — dùng chung cho cả plugin compile-time (glob ở dưới) @@ -68,10 +82,20 @@ function registerManifest( } } + const baseId = m.baseId ?? m.id; const clash = (seenIds.has(m.id) && `id "${m.id}" đã dùng ở ${seenIds.get(m.id)}`) || (seenRoutes.has(m.route) && `route "${m.route}" đã dùng ở ${seenRoutes.get(m.route)}`) || - (seenOrders.has(m.order) && `order ${m.order} đã dùng ở ${seenOrders.get(m.order)}`); + (seenOrders.has(m.order) && `order ${m.order} đã dùng ở ${seenOrders.get(m.order)}`) || + // Hai NGUỒN CÀI KHÁC NHAU (URL trần + market, hay hai market khác nhau) + // cùng phát hành một plugin trùng id gốc: `id`/`route` không đụng (một + // bên có thể bị tiền tố, bên kia thì không) nên ba kiểm tra trên không + // bắt được — nhưng usePluginSdkFor/getPluginSdk gọi module-scope bằng + // đúng chuỗi baseId này thì không phân biệt được đang chạy trong bundle + // nào, sẽ khớp nhầm bản đăng ký TRƯỚC. Từ chối rõ ràng ở đây thay vì để + // âm thầm gán sai storage/audit/permission. + (seenBaseIds.has(baseId) && + `id gốc "${baseId}" đã dùng ở ${seenBaseIds.get(baseId)} (một plugin khác cùng id, cài từ nguồn khác)`); if (clash) { errors.push({ source, id: m.id, reason: clash }); return; @@ -79,6 +103,7 @@ function registerManifest( seenIds.set(m.id, source); seenRoutes.set(m.route, source); seenOrders.set(m.order, source); + seenBaseIds.set(baseId, source); const sdk = createPluginSdk(m); const record: PluginRecord = { @@ -136,10 +161,9 @@ export const DEFAULT_PLUGIN_FEATURES: Readonly> = Object * plugin cài qua market không biết (và không nên biết) registry đã tiền tố * mình, nên luôn gọi bằng id gốc. * - * Nhập nhằng khi hai market khác nhau cùng cài một plugin trùng `baseId` — - * trả về bản ghi ĐĂNG KÝ TRƯỚC. Đây là giới hạn đã có sẵn của việc để một - * plugin module-scope singleton (`getPluginSdk`'s cache) tự nhận diện mình - * bằng đúng một chuỗi id, không phải lỗi mới do fallback này gây ra. + * Không còn nhập nhằng "hai bản ghi cùng baseId" ở đây nữa: `registerManifest` + * đã từ chối bản đăng ký THỨ HAI trùng `baseId` (`seenBaseIds`, phía trên) — + * `find` do đó chỉ có thể khớp ĐÚNG MỘT bản ghi, hoặc không bản ghi nào. */ export function getPlugin(id: string): PluginRecord | undefined { return PLUGIN_MAP.get(id) ?? records.find((p) => p.baseId === id); From 2414485f9bd769781a60b7d3d6598e81d2de5064 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 05:29:06 +0000 Subject: [PATCH 4/8] Replace baseId hack with a proper group+id plugin identity model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes this PR's earlier baseId field with what it was really approximating: every plugin now has an `id` (self-authored, what the plugin's own bundle calls itself via usePluginSdkFor/getPluginSdk) and a `group` (where it was installed from — "core" for the 26 compile-time plugins, "url" for a bare-URL install, or the marketId for a market install). Metadata only: storage/secrets/audit keys stay keyed by bare `id` exactly as before, so no migration for anyone's existing data. `id` still ends up unique system-wide in practice, not just per group: usePluginSdkFor/getPluginSdk are called by a plugin's own bundle code with just the bare id string, no group — module-scope calls (Kafka's/ RabbitMQ's background consumer stores) have no React context to resolve group from. Letting the same id genuinely coexist under two groups would make that lookup ambiguous and silently misattribute storage/audit/ permissions to the wrong plugin. So installer.ts now enforces it at the one point that matters — install time, not registry time: `assertNoConflictingInstall(id, marketId)` checks the already-installed list for the same bare id under a DIFFERENT group and throws a clear "uninstall the old one first" error before Rust ever writes to disk, called from both install call sites (ExtensionInstallDialog, SettingsExtensionInstaller) using the manifest they've already fetched for the confirmation screen — not fetched a second time inside installArtifact itself, which would have doubled a network round-trip AND changed the call count contract every existing install-flow test asserted on. registry.ts drops the seenBaseIds/getPlugin-fallback machinery entirely now that id is flat again: registerManifest just gets group defaulted to 'core' for the 26 compile-time plugins that don't declare it (installer.ts always declares it explicitly for runtime installs), and getPlugin(id) is back to a plain PLUGIN_MAP.get(id) — no ambiguity left to fall back on. Route generation for installed plugins is now uniform for both URL and market installs: /installed//, replacing the old asymmetric "market gets a generated route, URL install keeps its own" split. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BCypCuViyQDKGWxWspXKs2 --- src/components/ExtensionInstallDialog.tsx | 4 + .../SettingsExtensionInstaller.test.tsx | 1 + src/components/SettingsExtensionInstaller.tsx | 4 + ...tingsExtensions.pendingInstallTab.test.tsx | 5 + src/platform/index.ts | 1 + src/platform/installer.test.ts | 73 +++++++++++---- src/platform/installer.ts | 93 +++++++++++++++---- src/platform/registry.installer.test.ts | 60 +++++------- src/platform/registry.ts | 55 +++-------- src/platform/sdk.test.ts | 14 +-- src/platform/sdk.ts | 9 +- src/platform/types.ts | 40 ++++++-- src/platform/usePluginSdkFor.ts | 16 ++-- 13 files changed, 234 insertions(+), 141 deletions(-) diff --git a/src/components/ExtensionInstallDialog.tsx b/src/components/ExtensionInstallDialog.tsx index 4f8b613..8b14e78 100644 --- a/src/components/ExtensionInstallDialog.tsx +++ b/src/components/ExtensionInstallDialog.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { Callout } from '@/components/ui/callout'; import { Spinner } from '@/components/ui/spinner'; import { + assertNoConflictingInstall, currentTargetTriple, fetchArtifactManifestPreview, installArtifact, @@ -94,6 +95,9 @@ export function ExtensionInstallDialog({ urls, marketId, open, onOpenChange, onI setInstalling(true); try { for (const item of installable) { + if (item.manifest.kind === 'plugin') { + await assertNoConflictingInstall(item.manifest.id, marketId); + } await installArtifact(item.url, marketId); } setDone(true); diff --git a/src/components/SettingsExtensionInstaller.test.tsx b/src/components/SettingsExtensionInstaller.test.tsx index 2f4d864..0c9059c 100644 --- a/src/components/SettingsExtensionInstaller.test.tsx +++ b/src/components/SettingsExtensionInstaller.test.tsx @@ -246,6 +246,7 @@ describe('SettingsExtensionInstaller — cài đặt (success) và danh sách h fireEvent.click(screen.getByRole('button', { name: /Preview|Xem trước/ })); await waitFor(() => expect(screen.getByText('Demo')).toBeTruthy()); + invokeMock.mockResolvedValueOnce([]); // list, assertNoConflictingInstall trước khi cài invokeMock.mockResolvedValueOnce(installedPluginRaw()); // install invokeMock.mockResolvedValueOnce([installedPluginRaw(), installedServiceRaw()]); // refresh sau khi cài diff --git a/src/components/SettingsExtensionInstaller.tsx b/src/components/SettingsExtensionInstaller.tsx index 27c4621..f936bac 100644 --- a/src/components/SettingsExtensionInstaller.tsx +++ b/src/components/SettingsExtensionInstaller.tsx @@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input'; import { Callout } from '@/components/ui/callout'; import { Spinner } from '@/components/ui/spinner'; import { + assertNoConflictingInstall, checkForServiceUpdate, checkForUpdate, currentTargetTriple, @@ -146,6 +147,9 @@ export function SettingsExtensionInstaller() { setInstallError(null); setInstalling(true); try { + if (preview?.kind === 'plugin') { + await assertNoConflictingInstall(preview.id, previewMarketId); + } await installArtifact(url.trim(), previewMarketId); setUrl(''); setPreview(null); diff --git a/src/components/SettingsExtensions.pendingInstallTab.test.tsx b/src/components/SettingsExtensions.pendingInstallTab.test.tsx index 598ca01..053ea0e 100644 --- a/src/components/SettingsExtensions.pendingInstallTab.test.tsx +++ b/src/components/SettingsExtensions.pendingInstallTab.test.tsx @@ -37,6 +37,11 @@ vi.mock('@/platform', async (importOriginal) => { currentTargetTriple: () => Promise.resolve('x86_64-unknown-linux-gnu'), fetchArtifactManifestPreview: () => Promise.resolve(pluginManifest()), installArtifact: vi.fn().mockResolvedValue(pluginManifest()), + // Gọi TRƯỚC installArtifact ở ExtensionInstallDialog — thật (không mock) + // sẽ đi qua listInstalledPlugins() nội bộ của installer.ts (KHÔNG đi qua + // barrel này, nên mock listInstalledArtifacts ở trên không chặn được), + // rồi invoke() thật một lệnh Tauri không tồn tại trong jsdom. + assertNoConflictingInstall: () => Promise.resolve(), }; }); // `market.ts` đi qua tauri-plugin-http (không phải `invoke`) khi `isTauri` — diff --git a/src/platform/index.ts b/src/platform/index.ts index e97b0b2..2a2c80f 100644 --- a/src/platform/index.ts +++ b/src/platform/index.ts @@ -34,6 +34,7 @@ export { currentTargetTriple, checkAllForUpdates, recordKey, + assertNoConflictingInstall, } from './installer'; export type { RemotePluginManifest, diff --git a/src/platform/installer.test.ts b/src/platform/installer.test.ts index a087ce6..4ae7ab3 100644 --- a/src/platform/installer.test.ts +++ b/src/platform/installer.test.ts @@ -230,6 +230,49 @@ describe('fetchArtifactManifestPreview / installArtifact / listInstalledArtifact }); }); +describe('assertNoConflictingInstall — gọi TRƯỚC installArtifact/installPlugin bằng manifest lớp gọi đã xem trước', () => { + it('id đã cài từ group KHÁC (market khác, hoặc url ↔ market) — ném lỗi rõ ràng nêu tên group cũ', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([ + installedPluginRaw({ manifest: remoteManifest({ id: 'container-manager' }), market_id: 'official' }), + ]); + + await expect(installer.assertNoConflictingInstall('container-manager', 'custom-fork')).rejects.toThrow( + /container-manager.*official/, + ); + }); + + it('id đã cài, CÙNG group (cài lại/cập nhật từ đúng nguồn cũ) — KHÔNG ném', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([ + installedPluginRaw({ manifest: remoteManifest({ id: 'container-manager' }), market_id: 'official' }), + ]); + + await expect(installer.assertNoConflictingInstall('container-manager', 'official')).resolves.toBeUndefined(); + }); + + it('không marketId cả hai bên (group "url" cả hai) — cài lại từ URL trần vẫn không bị chặn', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([installedPluginRaw({ manifest: remoteManifest({ id: 'container-manager' }) })]); + + await expect(installer.assertNoConflictingInstall('container-manager', undefined)).resolves.toBeUndefined(); + }); + + it('id chưa từng cài ở đâu — không chặn gì', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([]); + + await expect(installer.assertNoConflictingInstall('container-manager', 'official')).resolves.toBeUndefined(); + }); + + it('đọc index lỗi (đĩa hỏng/quyền) — không chặn cài, để lệnh install thật sự tự báo lỗi rõ hơn', async () => { + const installer = await loadInTauri(); + invokeMock.mockRejectedValueOnce(new Error('index.json hỏng')); + + await expect(installer.assertNoConflictingInstall('container-manager', 'official')).resolves.toBeUndefined(); + }); +}); + describe('currentTargetTriple', () => { it('gọi đúng lệnh Rust và trả nguyên chuỗi triple', async () => { const installer = await loadInTauri(); @@ -441,36 +484,30 @@ describe('installedPluginManifests — đổi RemotePluginManifest thành Plugin // trên app thật (cài một plugin ví dụ, xác nhận nó thật sự render) trước // khi coi cơ chế này là đã kiểm chứng đầy đủ. - it('không có marketId (dán tay/bản cài từ trước) thì giữ NGUYÊN id/route gốc', async () => { + it('không có marketId (dán tay/bản cài từ trước) thì group là "url", id giữ nguyên trần', async () => { const installer = await loadInTauri(); invokeMock.mockResolvedValueOnce([installedPluginRaw({ manifest: remoteManifest({ id: 'demo', route: '/demo' }) })]); const [a] = await installer.installedPluginManifests(); expect(a.manifest.id).toBe('demo'); - expect(a.manifest.route).toBe('/demo'); - expect(a.manifest.baseId).toBe('demo'); + expect(a.manifest.group).toBe('url'); + expect(a.manifest.route).toBe('/installed/url/demo'); }); - it('có marketId thì id/route đăng ký registry được ghép marketId — hai market khác nhau cùng id KHÔNG đụng route/storage của nhau', async () => { + it('có marketId thì group là marketId, id VẪN TRẦN — đây là chuỗi usePluginSdkFor()/getPluginSdk() bên trong bundle tự gọi lại chính mình', async () => { const installer = await loadInTauri(); invokeMock.mockResolvedValueOnce([ installedPluginRaw({ manifest: remoteManifest({ id: 'demo', route: '/demo' }), market_id: 'official' }), - installedPluginRaw({ manifest: remoteManifest({ id: 'demo', route: '/demo' }), market_id: 'custom-fork' }), ]); - const [a, b] = await installer.installedPluginManifests(); - expect(a.manifest.id).toBe('official-demo'); - expect(a.manifest.route).toBe('/installed/official-demo'); - expect(b.manifest.id).toBe('custom-fork-demo'); - expect(b.manifest.route).toBe('/installed/custom-fork-demo'); - expect(a.manifest.id).not.toBe(b.manifest.id); - expect(a.manifest.route).not.toBe(b.manifest.route); - // Cái mà usePluginSdkFor()/getPluginSdk() BÊN TRONG chính bundle của - // plugin thực sự gọi lại — mất dòng này ở installer.ts thì cả hai bản - // cài lại ném "không có plugin trong registry" y hệt bug gốc, dù id/route - // ở trên vẫn đúng. Cả hai market đều giữ baseId gốc, KHÔNG ghép marketId. - expect(a.manifest.baseId).toBe('demo'); - expect(b.manifest.baseId).toBe('demo'); + const [a] = await installer.installedPluginManifests(); + // Mất dòng group ở installer.ts thì id vẫn đúng (không tiền tố), nên + // usePluginSdkFor/getPluginSdk vẫn hoạt động — group chỉ ảnh hưởng route + // hiển thị và việc phát hiện xung đột lúc CÀI (assertNoConflictingInstall), + // không phải danh tính SDK của plugin. + expect(a.manifest.id).toBe('demo'); + expect(a.manifest.group).toBe('official'); + expect(a.manifest.route).toBe('/installed/official/demo'); }); }); diff --git a/src/platform/installer.ts b/src/platform/installer.ts index fa5fceb..73baaae 100644 --- a/src/platform/installer.ts +++ b/src/platform/installer.ts @@ -209,6 +209,61 @@ function toCamelArtifactRecord(record: RawInstalledArtifactRecord): InstalledArt }; } +/** `PluginManifest.group` hiệu lực của một `marketId` — `"url"` khi không có + * market (URL dán tay), chính `marketId` khi có. Một hàm DÙNG CHUNG giữa + * `installedPluginManifests()` (đọc group cho registry) và + * `assertNoConflictingInstall()` (so group cũ/mới lúc cài) để hai chỗ không + * bao giờ lệch công thức nhau. */ +function installGroup(marketId: string | null | undefined): string { + return marketId ?? 'url'; +} + +/** + * Chặn cài một plugin nếu `id` của nó đã bị MỘT GROUP KHÁC chiếm — vd đã cài + * "container-manager" qua URL dán tay (group "url"), giờ cài lại đúng plugin + * đó nhưng từ market "official" (group "official"). Cùng `id` khác `group` + * cùng đăng ký được vào registry (`registry.ts` không cấm — mô hình là "id + * chỉ cần duy nhất TRONG group"), nhưng `usePluginSdkFor`/`getPluginSdk` gọi + * MODULE-SCOPE (không qua React context, dùng bởi store nền của Kafka/ + * RabbitMQ) chỉ nhận được đúng chuỗi `id`, không có `group` — hai bản ghi + * cùng tồn tại sẽ khiến lời gọi đó không biết nên trả bản nào, âm thầm gán + * nhầm storage/audit/quyền của bản NÀY cho plugin BẢN KIA. + * + * Chặn ở ĐÂY (trước khi Rust ghi xuống đĩa) thay vì chỉ phát hiện ở + * `registry.ts` lúc khởi động app lần sau: người dùng thấy lỗi NGAY khi bấm + * cài, kèm hướng dẫn gỡ bản cũ trước — không phải một dòng ẩn trong + * `PLUGIN_ERRORS` họ phải tự tìm ra sau khi thắc mắc sao plugin mới cài + * không lên. + * + * GỌI TRƯỚC `installArtifact`/`installPlugin`, không phải bên trong chúng — + * mọi lối gọi (ExtensionInstallDialog, SettingsExtensionInstaller) đều ĐÃ xem + * trước manifest (`fetchArtifactManifestPreview`/`fetchManifestPreview`) để + * hiển thị màn xác nhận trước khi cho bấm Install, nên `id` đã có sẵn trong + * tay — fetch lại lần hai bên trong `installArtifact` chỉ để lấy đúng cái nó + * vừa có là lãng phí không cần thiết. Chỉ áp dụng cho `kind: "plugin"` — + * service khoá theo `bin` qua `ALLOWED_SERVICES`, không thuộc mô hình + * group/id này. + */ +export async function assertNoConflictingInstall(id: string, marketId: string | undefined): Promise { + const newGroup = installGroup(marketId); + let installed: InstalledPluginRecord[]; + try { + installed = await listInstalledPlugins(); + } catch { + // Đọc index lỗi — không phải việc của bước kiểm tra này; để lời gọi cài + // thật sự bên dưới tự báo lỗi đọc/ghi đĩa nếu có, rõ ràng hơn. + return; + } + const conflict = installed.find((r) => r.manifest.id === id && installGroup(r.marketId) !== newGroup); + if (conflict) { + const oldGroup = installGroup(conflict.marketId); + throw new Error( + `Plugin "${id}" đã được cài từ nguồn khác ("${oldGroup}"). ` + + `Gỡ bản đó trong Settings → Extensions trước khi cài từ nguồn "${newGroup}".`, + ); + } +} + // --------------------------------------------------------------------------- // Tầng tổng quát — dùng bởi SettingsExtensionInstaller (cả hai kind) // --------------------------------------------------------------------------- @@ -224,7 +279,14 @@ export async function fetchArtifactManifestPreview(url: string): Promise { const raw = await invokeCommand('artifact_installer_install', { sourceUrl: url, @@ -464,28 +526,25 @@ export async function installedPluginManifests(): Promise< } return records.map((record, i) => { - // Danh tính registry (id storage/route/quyền/audit đều khoá theo đây, xem - // registry.ts's `registerManifest`) PHẢI phân biệt được hai market khác - // nhau cùng phát hành một plugin trùng `manifest.id` — không có allowlist - // toàn cục nào ép id duy nhất giữa các market (khác `ALLOWED_SERVICES` - // của service_host.rs, một danh sách cố định phía Rust). Không có - // `marketId` (URL dán tay, hoặc bản cài từ trước khi market tồn tại) thì - // giữ NGUYÊN id/route gốc — không đổi hành vi của người dùng đã cài từ - // trước tính năng này. - const registryId = record.marketId ? `${record.marketId}-${record.manifest.id}` : record.manifest.id; + const group = installGroup(record.marketId); return { - source: `installed:${registryId}@${record.manifest.version}`, + source: `installed:${group}:${record.manifest.id}@${record.manifest.version}`, manifest: { - id: registryId, - // Chỉ khác `id` khi có tiền tố market — bundle của plugin gọi - // `usePluginSdkFor(record.manifest.id)` bằng đúng id GỐC này, không - // biết gì về tiền tố market (xem PluginManifest.baseId). - baseId: record.manifest.id, + // Bundle của plugin gọi `usePluginSdkFor(record.manifest.id)`/ + // `getPluginSdk(record.manifest.id)` bằng đúng chuỗi này — KHÔNG tiền + // tố market, xem `PluginManifest.group`. `installArtifact()` chặn cài + // nếu id này đã bị một group khác chiếm, nên tại registry nó vẫn + // nhất quán duy nhất toàn app dù không ép kiểu ở đây. + id: record.manifest.id, + group, label: record.manifest.label, icon: ICONS_BY_NAME[record.manifest.icon] ?? Puzzle, description: record.manifest.description, keywords: record.manifest.keywords, - route: record.marketId ? `/installed/${registryId}` : record.manifest.route, + // Thống nhất cho MỌI plugin cài lúc chạy (URL trần lẫn market) — route + // riêng plugin tự khai (`record.manifest.route`) không còn dùng nữa, + // route hiển thị luôn phản ánh đúng group đã cài. + route: `/installed/${group}/${record.manifest.id}`, order: INSTALLED_ORDER_BASE + i, defaultEnabled: true, permissions: record.manifest.permissions, diff --git a/src/platform/registry.installer.test.ts b/src/platform/registry.installer.test.ts index cd44c8e..e09a7ea 100644 --- a/src/platform/registry.installer.test.ts +++ b/src/platform/registry.installer.test.ts @@ -119,66 +119,52 @@ describe('initInstalledPlugins', () => { expect(registry.getPlugin('installed-demo')?.route).toBe('/a'); }); - it('plugin cài qua market: getPlugin() tra được cả bằng id đã tiền tố lẫn baseId gốc', async () => { + it('plugin cài qua market: id vẫn TRẦN (không tiền tố), group mang tên market — usePluginSdkFor/getPluginSdk gọi bằng id trần vẫn tra được', async () => { // Mô phỏng đúng những gì installedPluginManifests() tự sinh cho một bản - // cài có marketId (xem installer.ts): id đăng ký bị tiền tố - // `-`, baseId giữ nguyên id gốc plugin tự khai. + // cài có marketId (xem installer.ts): id KHÔNG bị tiền tố, group mới là + // chỗ mang thông tin market — khác hẳn cơ chế `-` cũ. const { installedPluginManifests } = await import('./installer'); vi.mocked(installedPluginManifests).mockResolvedValue([ { - manifest: installedManifest({ id: 'official-container-manager', baseId: 'container-manager', route: '/installed/official-container-manager' }), - source: 'installed:official-container-manager@1.0.0', + manifest: installedManifest({ id: 'container-manager', group: 'official', route: '/installed/official/container-manager' }), + source: 'installed:official:container-manager@1.0.0', }, ]); const registry = await freshRegistry(); await registry.initInstalledPlugins(); - // Đúng registry id (dùng cho routing/storage/audit). - expect(registry.getPlugin('official-container-manager')?.baseId).toBe('container-manager'); - // Đúng id GỐC — đây là cái bundle của chính plugin gọi lại chính mình - // bằng (usePluginSdkFor('container-manager')/getPluginSdk('container-manager')). - // Thiếu fallback này là đúng lỗi thật đã xảy ra: "Không có plugin - // 'container-manager' trong registry" dù plugin ĐÃ cài và ĐANG render. - expect(registry.getPlugin('container-manager')?.id).toBe('official-container-manager'); + // Đúng chuỗi bundle của chính plugin tự gọi lại mình + // (usePluginSdkFor('container-manager')/getPluginSdk('container-manager')) + // — không cần biết registry đã gán group nào cho nó. + const record = registry.getPlugin('container-manager'); + expect(record?.id).toBe('container-manager'); + expect(record?.group).toBe('official'); }); - it('một plugin cài qua URL trần + CÙNG plugin đó cài lại qua market (trùng baseId) — bản thứ hai bị từ chối, không được phép nhận nhầm SDK của bản kia', async () => { - // Kịch bản thật: người dùng cài "container-manager" qua URL manifest dán - // tay trước (không marketId, giữ nguyên id gốc) — rồi sau đó cài lại - // đúng plugin đó nhưng qua market "official". `id`/`route` của hai bản - // ghi này KHÁC NHAU HOÀN TOÀN (một bên không tiền tố, một bên có) nên ba - // kiểm tra seenIds/seenRoutes/seenOrders không bắt được gì — nếu không có - // seenBaseIds, cả hai đăng ký "thành công", và getPluginSdk('container-manager') - // gọi module-scope (không qua context) từ bên trong bản market sẽ ÂM THẦM - // nhận nhầm sdk của bản URL-install (khớp PLUGIN_MAP theo đúng chuỗi - // 'container-manager' trước khi kịp thử baseId) — sai storage/audit/quyền. + it('hai plugin cài trùng id (một qua URL trần, một qua market) — bản thứ hai vẫn bị registry từ chối như mọi trùng id khác', async () => { + // registry.ts KHÔNG tự phân biệt group khi so trùng id — đó là việc của + // installer.ts's assertNoConflictingInstall() (chặn NGAY LÚC CÀI, xem + // installer.test.ts). Ở tầng registry, hai bản ghi cùng id trần luôn là + // một collision phẳng, bất kể group — giống hệt "hai plugin cài trùng id + // với nhau thì chỉ cái đầu được đăng ký" phía trên, chỉ khác nguồn cài. const { installedPluginManifests } = await import('./installer'); vi.mocked(installedPluginManifests).mockResolvedValue([ { - manifest: installedManifest({ id: 'container-manager', baseId: 'container-manager', route: '/container-manager', order: 100_000 }), - source: 'installed:container-manager@1.0.0', + manifest: installedManifest({ id: 'container-manager', group: 'url', route: '/installed/url/container-manager' }), + source: 'installed:url:container-manager@1.0.0', }, { - // order khác bản trên: chỉ seenBaseIds mới có thể bắt cặp này — nếu - // vô tình để trùng order/route/id với bản kia, test sẽ pass vì một lý - // do khác hoàn toàn, không kiểm được đúng thứ cần kiểm. - manifest: installedManifest({ id: 'official-container-manager', baseId: 'container-manager', route: '/installed/official-container-manager', order: 100_001 }), - source: 'installed:official-container-manager@1.0.0', + manifest: installedManifest({ id: 'container-manager', group: 'official', route: '/installed/official/container-manager', order: 100_001 }), + source: 'installed:official:container-manager@1.0.0', }, ]); const registry = await freshRegistry(); await registry.initInstalledPlugins(); - // Bản đầu (URL trần) đăng ký bình thường. - expect(registry.getPlugin('container-manager')?.id).toBe('container-manager'); - // Bản thứ hai (market) bị TỪ CHỐI rõ ràng — không được phép âm thầm tồn - // tại song song rồi gây nhận nhầm danh tính ở getPluginSdk module-scope. - expect(registry.getPlugin('official-container-manager')).toBeUndefined(); - expect( - registry.PLUGIN_ERRORS.some((e) => e.id === 'official-container-manager' && e.reason.includes('container-manager')), - ).toBe(true); + expect(registry.getPlugin('container-manager')?.group).toBe('url'); + expect(registry.PLUGIN_ERRORS.some((e) => e.id === 'container-manager')).toBe(true); }); it('manifest hỏng (thiếu route) bị loại vào PLUGIN_ERRORS, không làm hỏng những manifest hợp lệ khác', async () => { diff --git a/src/platform/registry.ts b/src/platform/registry.ts index 9fb2b8e..82e9370 100644 --- a/src/platform/registry.ts +++ b/src/platform/registry.ts @@ -11,7 +11,7 @@ import type { PluginLoadError, PluginManifest, PluginRecord } from './types'; * compile-time), rồi sau đó — lúc app khởi động, xem `initInstalledPlugins()` * — nạp thêm plugin đã CÀI TỪ BÊN NGOÀI (`src/platform/installer.ts`). Cả hai * loại đi qua đúng một hàm đăng ký (`registerManifest`) nên chịu chung một bộ - * luật: hợp lệ, không đụng id/baseId/route/order của nhau, và có audit như nhau. + * luật: hợp lệ, không đụng id/route/order của nhau, và có audit như nhau. * * `eager: true` là cố ý và KHÔNG kéo tool vào bundle khởi động: file manifest * chỉ chứa metadata + một closure `load` chưa được gọi, nên code thật của tool @@ -33,23 +33,17 @@ const records: PluginRecord[] = []; * đây là bắt buộc, không phải trang trí. */ export const PLUGIN_MAP: Map = new Map(); +// `id` phải duy nhất TOÀN APP trên thực tế, không chỉ trong `group` của nó — +// dù `PluginManifest.group` tồn tại để phân biệt NGUỒN CÀI, `installer.ts` +// chặn cài một plugin nếu `id` đã bị một `group` khác chiếm (bắt gỡ bản cũ +// trước khi cài bản mới), nên tại registry chỉ CẦN VÀ CHỈ CÓ một bảng +// `seenIds` phẳng — y hệt trước khi khái niệm group ra đời. Xem +// `PluginManifest.group`'s doc comment cho lý do (usePluginSdkFor/ +// getPluginSdk gọi module-scope chỉ nhận được `id`, không có `group`, nên +// hai bản ghi cùng `id` coexist thật sẽ khiến lời gọi đó mập mờ). const seenIds = new Map(); const seenRoutes = new Map(); const seenOrders = new Map(); -/** - * Khoá theo `baseId` HIỆU LỰC (`m.baseId ?? m.id`) — id GỐC mà chính bundle - * của một plugin tự gọi lại chính mình qua `usePluginSdkFor`/`getPluginSdk` - * (xem `PluginManifest.baseId`). Cần THÊM một bảng riêng ngoài `seenIds`: - * hai bản ghi có thể có `id` (khoá registry) khác nhau hoàn toàn — một plugin - * cài từ URL trần giữ nguyên id gốc, một plugin CÙNG id gốc đó cài từ market - * lại bị tiền tố — nên không đụng `seenIds`/`seenRoutes` chút nào, ĐĂNG KÝ - * ĐƯỢC CẢ HAI. Không có bảng này, `getPlugin(baseId)`/`getPluginSdk(baseId)` - * gọi module-scope (không qua React context, không phân biệt được đang chạy - * trong bundle nào) sẽ ÂM THẦM khớp bản ghi SAI — storage/audit/permission - * của một plugin bị gán nhầm cho plugin kia. Từ chối bản đăng ký SAU thay vì - * để xảy ra chuyện đó, giống hệt cách seenIds/seenRoutes/seenOrders đã làm. - */ -const seenBaseIds = new Map(); /** * Đăng ký một manifest — dùng chung cho cả plugin compile-time (glob ở dưới) @@ -82,20 +76,10 @@ function registerManifest( } } - const baseId = m.baseId ?? m.id; const clash = (seenIds.has(m.id) && `id "${m.id}" đã dùng ở ${seenIds.get(m.id)}`) || (seenRoutes.has(m.route) && `route "${m.route}" đã dùng ở ${seenRoutes.get(m.route)}`) || - (seenOrders.has(m.order) && `order ${m.order} đã dùng ở ${seenOrders.get(m.order)}`) || - // Hai NGUỒN CÀI KHÁC NHAU (URL trần + market, hay hai market khác nhau) - // cùng phát hành một plugin trùng id gốc: `id`/`route` không đụng (một - // bên có thể bị tiền tố, bên kia thì không) nên ba kiểm tra trên không - // bắt được — nhưng usePluginSdkFor/getPluginSdk gọi module-scope bằng - // đúng chuỗi baseId này thì không phân biệt được đang chạy trong bundle - // nào, sẽ khớp nhầm bản đăng ký TRƯỚC. Từ chối rõ ràng ở đây thay vì để - // âm thầm gán sai storage/audit/permission. - (seenBaseIds.has(baseId) && - `id gốc "${baseId}" đã dùng ở ${seenBaseIds.get(baseId)} (một plugin khác cùng id, cài từ nguồn khác)`); + (seenOrders.has(m.order) && `order ${m.order} đã dùng ở ${seenOrders.get(m.order)}`); if (clash) { errors.push({ source, id: m.id, reason: clash }); return; @@ -103,11 +87,14 @@ function registerManifest( seenIds.set(m.id, source); seenRoutes.set(m.route, source); seenOrders.set(m.order, source); - seenBaseIds.set(baseId, source); const sdk = createPluginSdk(m); const record: PluginRecord = { ...m, + // `'core'` cho mọi plugin compile-time (26 file `src/plugins// + // plugin.ts` không khai field này) — `installer.ts` luôn khai rõ `group` + // cho plugin cài lúc chạy, nên `?? 'core'` không bao giờ chạm nhánh đó. + group: m.group ?? 'core', keywords: m.keywords ?? [], experimental: m.experimental ?? false, fullHeight: m.fullHeight ?? true, @@ -153,20 +140,8 @@ export const DEFAULT_PLUGIN_FEATURES: Readonly> = Object records.map((p) => [p.id, p.defaultEnabled]), ); -/** - * Tra theo `id` (khoá registry, có thể đã bị tiền tố `-`) trước; - * không thấy thì thử lại theo `baseId` (id GỐC không tiền tố mà chính plugin - * tự khai và tự gọi lại chính mình bằng đó — xem `PluginManifest.baseId`). - * Cần cho `getPluginSdk`/`usePluginSdkFor`: code BÊN TRONG bundle của một - * plugin cài qua market không biết (và không nên biết) registry đã tiền tố - * mình, nên luôn gọi bằng id gốc. - * - * Không còn nhập nhằng "hai bản ghi cùng baseId" ở đây nữa: `registerManifest` - * đã từ chối bản đăng ký THỨ HAI trùng `baseId` (`seenBaseIds`, phía trên) — - * `find` do đó chỉ có thể khớp ĐÚNG MỘT bản ghi, hoặc không bản ghi nào. - */ export function getPlugin(id: string): PluginRecord | undefined { - return PLUGIN_MAP.get(id) ?? records.find((p) => p.baseId === id); + return PLUGIN_MAP.get(id); } /** diff --git a/src/platform/sdk.test.ts b/src/platform/sdk.test.ts index 8cadb99..f2ae252 100644 --- a/src/platform/sdk.test.ts +++ b/src/platform/sdk.test.ts @@ -91,17 +91,17 @@ describe('secrets', () => { }); }); -describe('baseId', () => { - it('không khai baseId trong manifest thì mặc định bằng id', () => { +describe('group', () => { + it('không khai group trong manifest (mọi plugin compile-time) thì mặc định "core"', () => { const sdk = createPluginSdk(plugin([])); - expect(sdk.baseId).toBe('demo'); + expect(sdk.group).toBe('core'); }); - it('có baseId trong manifest (plugin cài qua market, id đã bị tiền tố) thì giữ nguyên baseId khác id', () => { - const manifest = { ...plugin([]), id: 'official-demo', baseId: 'demo' }; + it('có khai group (plugin cài lúc chạy) thì giữ nguyên, id KHÔNG bị đổi theo', () => { + const manifest = { ...plugin([]), group: 'official' }; const sdk = createPluginSdk(manifest); - expect(sdk.id).toBe('official-demo'); - expect(sdk.baseId).toBe('demo'); + expect(sdk.id).toBe('demo'); + expect(sdk.group).toBe('official'); }); }); diff --git a/src/platform/sdk.ts b/src/platform/sdk.ts index 78496a2..122db5b 100644 --- a/src/platform/sdk.ts +++ b/src/platform/sdk.ts @@ -130,10 +130,9 @@ export interface PluginFiles { export interface PluginSdk { readonly id: string; - /** Id gốc không tiền tố market — xem `PluginManifest.baseId`. Bằng `id` khi - * plugin không cài qua market. `usePluginSdkFor` so khớp bằng field này, - * không phải `id`, vì đó là cái plugin tự truyền vào lời gọi của chính nó. */ - readonly baseId: string; + /** Nguồn cài — xem `PluginManifest.group`. Metadata, không ảnh hưởng khoá + * storage/audit (vẫn theo `id` một mình). */ + readonly group: string; readonly sdkVersion: string; readonly permissions: readonly PluginPermission[]; readonly env: PluginEnv; @@ -220,7 +219,7 @@ export function createPluginSdk(manifest: PluginManifest): PluginSdk { return { id, - baseId: manifest.baseId ?? id, + group: manifest.group ?? 'core', sdkVersion: SDK_VERSION, permissions: manifest.permissions ?? [], env: { isTauri, isMac: IS_MAC, modKey: MOD_KEY }, diff --git a/src/platform/types.ts b/src/platform/types.ts index dd03c4b..bf28b66 100644 --- a/src/platform/types.ts +++ b/src/platform/types.ts @@ -44,18 +44,38 @@ export type PluginPermission = | 'service'; export interface PluginManifest { - /** kebab-case, duy nhất toàn app. Đồng thời là khoá bật/tắt và namespace storage. */ + /** + * kebab-case — chuỗi CHÍNH PLUGIN TỰ KHAI và tự dùng để gọi lại chính mình + * qua `usePluginSdkFor(id)`/`getPluginSdk(id)` (nó không biết, và không nên + * biết, mình thuộc `group` nào lúc build). Đồng thời là khoá bật/tắt và + * namespace storage — GIỮ NGUYÊN Ý NGHĨA CŨ, không đổi bởi `group`. + * + * Chỉ cần duy nhất TRONG `group` của nó theo MÔ HÌNH — nhưng trên thực tế, + * `installer.ts` chặn cài một plugin nếu `id` đã bị một `group` KHÁC chiếm + * (bắt người dùng gỡ bản cũ trước), nên tại một thời điểm chỉ có ĐÚNG MỘT + * bản ghi mang một `id` cho trước tồn tại — `id` vẫn nhất quán duy nhất + * toàn app trên thực tế. Lý do: `usePluginSdkFor`/`getPluginSdk` chỉ nhận + * được đúng chuỗi `id` này (không có `group`) khi plugin tự gọi mình ở + * module-scope (không qua React context, vd store nền của Kafka/RabbitMQ) + * — cho hai `group` cùng `id` coexist thật sẽ khiến lời gọi đó không thể + * biết nên trả bản ghi nào, âm thầm gán nhầm storage/audit/quyền. + */ id: string; /** - * Id GỐC như chính plugin tự biết về mình — khác `id` CHỈ khi một plugin cài - * từ market bị tiền tố `-` để tránh đụng một market khác cùng - * phát hành trùng id (xem `installer.ts`'s `installedPluginManifests()`). - * Bundle của plugin gọi `usePluginSdkFor('container-manager')` bằng đúng id - * này — nó không (và không nên) biết mình đang chạy dưới một registry id đã - * bị tiền tố. Không set thì coi như bằng `id` (mọi plugin compile-time và - * mọi plugin cài không qua market). + * Nguồn plugin được cài từ đâu — thuần METADATA, không tham gia tính khoá + * storage/audit (vẫn khoá theo `id` một mình, y hệt trước khi field này ra + * đời — không migrate gì). Dùng để: (1) sinh route cho plugin cài lúc chạy + * (`/installed//`), (2) hiển thị nguồn cài trong Settings, và + * (3) phân biệt "cài lại/cập nhật CÙNG nguồn" (hợp lệ) với "một `group` + * KHÁC cùng khai trùng `id`" (conflict, chặn cài — xem `installer.ts`). + * + * `"core"` cho 26 plugin compile-time (registry.ts TỰ GÁN khi thiếu — 26 + * file `src/plugins//plugin.ts` không cần sửa để khai field này), + * `"url"` cho plugin cài qua URL manifest dán tay (không market), hoặc + * chính `marketId` cho plugin cài qua market — `installer.ts` luôn khai rõ + * cho cả hai loại này, không dựa vào mặc định. */ - baseId?: string; + group?: string; label: string; icon: LucideIcon; description: string; @@ -108,6 +128,8 @@ export interface PluginManifest { /** Manifest đã qua kiểm tra + các giá trị mặc định đã điền. */ export interface PluginRecord extends PluginManifest { + /** Không còn optional — `registerManifest` đã điền `'core'` khi thiếu. */ + group: string; keywords: string[]; experimental: boolean; fullHeight: boolean; diff --git a/src/platform/usePluginSdkFor.ts b/src/platform/usePluginSdkFor.ts index 5b0ac58..78e7f38 100644 --- a/src/platform/usePluginSdkFor.ts +++ b/src/platform/usePluginSdkFor.ts @@ -15,6 +15,13 @@ const cache = new Map(); * Kết quả được nhớ theo id: SDK gắn danh tính plugin chứ không mang state gì, * nên một instance cho mỗi plugin là đủ — và giữ nguyên một tham chiếu giúp các * `useMemo([sdk])` phía trên không bị vô hiệu. + * + * Gọi ở MODULE SCOPE (không qua React context) nên chỉ nhận được đúng + * `pluginId` — không biết, và không cần biết, `group` nào đã cài nó. An toàn + * vì `installer.ts` chặn cài một plugin nếu `id` của nó đã bị một `group` + * KHÁC chiếm (bắt gỡ bản cũ trước) — tại một thời điểm chỉ có đúng một bản + * ghi mang `pluginId` này trong registry, nên `getPlugin(pluginId)` không + * mập mờ. Xem `PluginManifest.group` cho lý do đầy đủ. */ export function getPluginSdk(pluginId: string): PluginSdk { const existing = cache.get(pluginId); @@ -47,14 +54,7 @@ export function getPluginSdk(pluginId: string): PluginSdk { export function usePluginSdkFor(pluginId: string): PluginSdk { const fromContext = usePluginSdkOptional(); return useMemo(() => { - // So bằng `baseId`, không phải `id`: một plugin cài từ market chạy dưới - // một registry `id` bị tiền tố `-` (xem installer.ts), nhưng - // bundle của chính plugin đó gọi `usePluginSdkFor(...)` bằng đúng id GỐC - // nó tự khai trong manifest — nó không (và không nên) biết registry đã - // tiền tố mình. So bằng `id` ở đây khiến MỌI plugin cài qua market luôn - // rơi xuống nhánh "không có trong registry" bên dưới, dù đang render - // đúng bên trong cây của chính nó. - if (fromContext?.baseId === pluginId) return fromContext; + if (fromContext?.id === pluginId) return fromContext; const manifest = getPlugin(pluginId); if (!manifest) { throw new Error( From aff275c955fc9d3478916eb0699680777cc64fc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 05:32:45 +0000 Subject: [PATCH 5/8] SettingsExtensionInstaller.test.tsx: cover the kind=service install path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codecov flagged a partial branch: the new `preview?.kind === 'plugin'` check gating assertNoConflictingInstall had only its true side (plugin install) exercised by any test — no test ever clicked Install for a kind=service preview. Extended the existing service-preview test to also install, asserting it skips straight to artifact_installer_install with no list call in between (assertNoConflictingInstall only applies to plugins). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BCypCuViyQDKGWxWspXKs2 --- src/components/SettingsExtensionInstaller.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/components/SettingsExtensionInstaller.test.tsx b/src/components/SettingsExtensionInstaller.test.tsx index 0c9059c..f0516ae 100644 --- a/src/components/SettingsExtensionInstaller.test.tsx +++ b/src/components/SettingsExtensionInstaller.test.tsx @@ -210,6 +210,20 @@ describe('SettingsExtensionInstaller — xem trước (preview)', () => { expect( screen.getByText(/The manifest has a build for this platform\.|Manifest có bản cho nền tảng này\./), ).toBeTruthy(); + + // kind=service KHÔNG đi qua assertNoConflictingInstall (chỉ áp dụng cho + // plugin) — bấm Install ở đây phải đi thẳng tới artifact_installer_install, + // không có lệnh list nào chen vào trước. + invokeMock.mockResolvedValueOnce(installedServiceRaw()); + invokeMock.mockResolvedValueOnce([installedServiceRaw()]); // refresh sau khi cài + fireEvent.click(screen.getByRole('button', { name: /^Install$|^Cài đặt$/ })); + + await waitFor(() => { + expect(screen.getByText(/External extensions changed|Đã thay đổi tiện ích/)).toBeTruthy(); + }); + expect(invokeMock).toHaveBeenCalledWith('artifact_installer_install', { + sourceUrl: 'https://example.com/svc.json', + }); }); it('kind=service: nền tảng không khớp thì cảnh báo rõ, không đoán/thử triple khác', async () => { From c7946d24e2899d57b63b98696224445a42574612 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 05:45:25 +0000 Subject: [PATCH 6/8] Fix install-time conflict gate blind spot for built-in plugin ids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pullfrog review, PR #153: 1. assertNoConflictingInstall() only scanned listInstalledPlugins() (the on-disk index) — it had no visibility into the 26 compile-time plugins, which never appear there. Installing a plugin whose id collides with a built-in (e.g. "json") passed the gate, got written to disk, and was only THEN silently rejected by registerManifest's flat seenIds on the next launch — exactly the deferred-to-restart PLUGIN_ERRORS outcome this gate exists to front-load. Fixed by adding an optional `registeredGroup` parameter: the two UI call sites (ExtensionInstallDialog, SettingsExtensionInstaller) now pass `getPlugin(id)?.group` — already exported from '@/platform', no new registry->installer import needed (installer.ts can't import registry.ts; registry.ts already imports installer.ts). A `group: 'core'` conflict gets its own error message ("trùng id với một tool có sẵn") since "uninstall the old one" doesn't apply to a built-in tool. 2. Corrected the PR's "no data migration needed" claim into an actual justification rather than an assertion: storage/secrets namespacing for a market install did change from the old `-` prefix to a bare `id`, but no market-installed plugin ever successfully read/wrote sdk.storage/sdk.secrets before this PR — usePluginSdkFor(id) threw before the component could reach any such call. Documented inline at the exact line the namespace is set, instead of just in commit history. 3. Updated the two doc/comment sites Pullfrog found still describing the removed - registry-id scheme: 02-manifest.md's plugin-author-facing footnote, and market.ts's kebab-case rationale comment. New tests cover the registeredGroup parameter (core collision throws its own message, matching group is a no-op); confirmed by temporarily reverting the registeredGroup branch, which makes the new test fail for the right reason. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BCypCuViyQDKGWxWspXKs2 --- docs/plugin-sdk/02-manifest.md | 25 +++++++----- src/components/ExtensionInstallDialog.tsx | 3 +- src/components/SettingsExtensionInstaller.tsx | 3 +- src/lib/market.ts | 11 ++--- src/platform/installer.test.ts | 19 +++++++++ src/platform/installer.ts | 40 ++++++++++++++++++- 6 files changed, 84 insertions(+), 17 deletions(-) diff --git a/docs/plugin-sdk/02-manifest.md b/docs/plugin-sdk/02-manifest.md index 4b5ec8a..44c7c0e 100644 --- a/docs/plugin-sdk/02-manifest.md +++ b/docs/plugin-sdk/02-manifest.md @@ -44,15 +44,22 @@ export default definePlugin({ [^market-id]: Trong CHÍNH MỘT market (một `catalog.json`), `id` vẫn phải duy nhất — không có cơ chế nào ở đây gỡ khỏi tác giả trách nhiệm không đụng id - plugin khác trong cùng market mình phát hành. Nhưng khi cài từ Settings → - Marketplace, nếu người dùng đã thêm HAI market khác nhau cùng phát hành - một `id` trùng nhau (hai tác giả không hề biết nhau), host tự phân biệt - hai bản cài đó bằng `marketId + id` (registry id thật trở thành - `-`, route thành `/installed/-`) — cả hai - cùng tồn tại, không bản nào âm thầm bị ghi đè. Một plugin cài qua URL dán - tay (không qua market nào) vẫn dùng đúng `id`/`route` gốc như trước, không - đổi gì. Xem `src/platform/installer.ts`'s `installedPluginManifests()` và - `src-tauri/src/artifact_installer.rs`'s `InstalledPluginRecord::market_id`. + plugin khác trong cùng market mình phát hành. `id` khai ở đây LUÔN giữ + nguyên dạng trần khi đăng ký vào host — không bị host tiền tố gì thêm, + dù cài qua URL dán tay hay qua market: đây chính là chuỗi plugin của bạn + tự dùng để gọi lại chính mình qua `usePluginSdkFor(id)`/`getPluginSdk(id)`. + Nguồn cài (market nào, hay "url" cho URL dán tay) được host lưu riêng + dưới dạng `group` — chỉ ảnh hưởng route hiển thị + (`/installed//`), không đụng tới `id` bạn khai. + Vì `usePluginSdkFor`/`getPluginSdk` chỉ nhận được `id` (không có + `group`) khi plugin tự gọi mình ở phạm vi module (store nền, ngoài React + context), host CHẶN VIỆC CÀI nếu `id` bạn khai đã bị một nguồn khác + (market khác, URL dán tay, hay một tool có sẵn của chính app) chiếm — + người dùng phải gỡ bản cũ trước khi cài bản mới trùng id, hai bản không + được phép cùng tồn tại. Đặt `id` đủ cụ thể (không trùng tên phổ biến + kiểu `client`/`manager`) để tránh việc này. Xem + `src/platform/installer.ts`'s `installedPluginManifests()`/ + `assertNoConflictingInstall()`. ## Bảng quyền (`PluginPermission`) diff --git a/src/components/ExtensionInstallDialog.tsx b/src/components/ExtensionInstallDialog.tsx index 8b14e78..5d5c098 100644 --- a/src/components/ExtensionInstallDialog.tsx +++ b/src/components/ExtensionInstallDialog.tsx @@ -10,6 +10,7 @@ import { assertNoConflictingInstall, currentTargetTriple, fetchArtifactManifestPreview, + getPlugin, installArtifact, type RemoteArtifactManifest, } from '@/platform'; @@ -96,7 +97,7 @@ export function ExtensionInstallDialog({ urls, marketId, open, onOpenChange, onI try { for (const item of installable) { if (item.manifest.kind === 'plugin') { - await assertNoConflictingInstall(item.manifest.id, marketId); + await assertNoConflictingInstall(item.manifest.id, marketId, getPlugin(item.manifest.id)?.group); } await installArtifact(item.url, marketId); } diff --git a/src/components/SettingsExtensionInstaller.tsx b/src/components/SettingsExtensionInstaller.tsx index f936bac..dad9fc3 100644 --- a/src/components/SettingsExtensionInstaller.tsx +++ b/src/components/SettingsExtensionInstaller.tsx @@ -13,6 +13,7 @@ import { checkForUpdate, currentTargetTriple, fetchArtifactManifestPreview, + getPlugin, installArtifact, installPlugin, installService, @@ -148,7 +149,7 @@ export function SettingsExtensionInstaller() { setInstalling(true); try { if (preview?.kind === 'plugin') { - await assertNoConflictingInstall(preview.id, previewMarketId); + await assertNoConflictingInstall(preview.id, previewMarketId, getPlugin(preview.id)?.group); } await installArtifact(url.trim(), previewMarketId); setUrl(''); diff --git a/src/lib/market.ts b/src/lib/market.ts index fa0e6c9..52c83c1 100644 --- a/src/lib/market.ts +++ b/src/lib/market.ts @@ -93,11 +93,12 @@ function randomSuffix(): string { } // Kebab-case (chữ thường, số, dấu gạch nối) — CHỦ ĐÍCH, không chỉ để đẹp: -// `installedPluginManifests()` (installer.ts) ghép id này với id plugin -// thành một registry id duy nhất (`${marketId}-${pluginId}`), phải khớp -// `ID_PATTERN` của `validateManifest` (chỉ nhận kebab-case) — một market id -// chứa `:` hay ký tự hoa sẽ khiến MỌI plugin cài từ market đó bị registry từ -// chối thẳng, âm thầm biến mất khỏi sidebar. +// `installedPluginManifests()` (installer.ts) dùng market id này làm `group` +// của mọi plugin cài từ market đó, lộ ra trực tiếp trong route +// (`/installed//`) và trong `by-market//` phía đĩa +// (`artifact_installer.rs`) — một market id chứa `:`, khoảng trắng hay ký tự +// hoa sẽ làm hỏng route/đường dẫn file của MỌI plugin cài từ market đó. + export function addCustomMarket(label: string, catalogUrl: string): Market { const market: Market = { id: `custom-${Date.now()}-${randomSuffix()}`, diff --git a/src/platform/installer.test.ts b/src/platform/installer.test.ts index 4ae7ab3..edabcf7 100644 --- a/src/platform/installer.test.ts +++ b/src/platform/installer.test.ts @@ -271,6 +271,25 @@ describe('assertNoConflictingInstall — gọi TRƯỚC installArtifact/installP await expect(installer.assertNoConflictingInstall('container-manager', 'official')).resolves.toBeUndefined(); }); + + it('registeredGroup="core" (id trùng một plugin compile-time, lớp gọi tự tra bằng getPlugin) — ném lỗi riêng, không nhắc "gỡ bản đó" (không gỡ được tool có sẵn)', async () => { + const installer = await loadInTauri(); + // KHÔNG mock invoke ở đây: registeredGroup !== newGroup phải chặn NGAY, + // trước khi kịp gọi listInstalledPlugins() — nếu lỡ gọi, invokeMock + // (chưa mock resolved value nào) sẽ trả undefined, không phải mảng, và + // test sẽ thất bại vì lý do sai (TypeError .find trên undefined), không + // phải vì đúng lỗi mong đợi. + await expect(installer.assertNoConflictingInstall('json', 'official', 'core')).rejects.toThrow(/json/); + await expect(installer.assertNoConflictingInstall('json', 'official', 'core')).rejects.not.toThrow(/gỡ bản đó/i); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it('registeredGroup trùng đúng group đang cài (vd cài lại chính plugin compile-time đó — không hợp lệ thật, nhưng hàm không tự biết) — không chặn ở nhánh registeredGroup', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([]); // vẫn rơi xuống nhánh quét đĩa như bình thường + + await expect(installer.assertNoConflictingInstall('json', undefined, 'url')).resolves.toBeUndefined(); + }); }); describe('currentTargetTriple', () => { diff --git a/src/platform/installer.ts b/src/platform/installer.ts index 73baaae..d69eac9 100644 --- a/src/platform/installer.ts +++ b/src/platform/installer.ts @@ -243,9 +243,36 @@ function installGroup(marketId: string | null | undefined): string { * vừa có là lãng phí không cần thiết. Chỉ áp dụng cho `kind: "plugin"` — * service khoá theo `bin` qua `ALLOWED_SERVICES`, không thuộc mô hình * group/id này. + * + * `registeredGroup` — group của một bản ghi ĐANG TRONG REGISTRY (không phải + * trên đĩa) đã chiếm đúng `id` này, nếu có: 26 plugin compile-time (group + * `'core'`), hoặc một plugin đã cài+đăng ký từ lần khởi động TRƯỚC (chưa chắc + * còn khớp danh sách trên đĩa nếu người dùng vừa gỡ nó trong phiên này). + * Lớp gọi tự tra bằng `getPlugin(id)?.group` (đã có sẵn qua `@/platform`) rồi + * truyền vào — hàm này KHÔNG tự tra được, vì `registry.ts` đã import + * `installer.ts` (nạp ngược lại sẽ vòng lặp). Thiếu tham số này, cài một + * plugin trùng id với một tool có sẵn (vd đặt tên "json") sẽ qua được cửa + * này, ghi xuống đĩa, rồi mới bị `registerManifest`'s `seenIds` âm thầm từ + * chối ở lần khởi động sau — đúng kiểu lỗi hoãn-đến-sau-restart mà hàm này + * tồn tại để tránh. */ -export async function assertNoConflictingInstall(id: string, marketId: string | undefined): Promise { +export async function assertNoConflictingInstall( + id: string, + marketId: string | undefined, + registeredGroup?: string, +): Promise { const newGroup = installGroup(marketId); + + if (registeredGroup !== undefined && registeredGroup !== newGroup) { + if (registeredGroup === 'core') { + throw new Error(`Plugin "${id}" trùng id với một tool có sẵn trong app — đổi id khác trong manifest.`); + } + throw new Error( + `Plugin "${id}" đã được cài từ nguồn khác ("${registeredGroup}"). ` + + `Gỡ bản đó trong Settings → Extensions trước khi cài từ nguồn "${newGroup}".`, + ); + } + let installed: InstalledPluginRecord[]; try { installed = await listInstalledPlugins(); @@ -535,6 +562,17 @@ export async function installedPluginManifests(): Promise< // tố market, xem `PluginManifest.group`. `installArtifact()` chặn cài // nếu id này đã bị một group khác chiếm, nên tại registry nó vẫn // nhất quán duy nhất toàn app dù không ép kiểu ở đây. + // + // Namespace storage/secrets (sdk.ts's storageKey/secrets.ts's + // vaultKey) khoá theo ĐÚNG field này — với một bản cài qua market, + // trước bản sửa này nó là registry id đã tiền tố (`-`), + // giờ là `id` trần. KHÔNG coi là cần migrate: mọi bản cài qua market + // trước bản sửa này chưa từng đọc/ghi được sdk.storage/sdk.secrets + // dù chỉ một lần — chính `usePluginSdkFor(record.manifest.id)` ở trên + // đã ném lỗi ngay khi gọi (root cause của toàn bộ chuỗi PR này), nên + // component không bao giờ chạy tới bất kỳ lời gọi storage/secrets + // nào trước khi crash. Không có dữ liệu thật nào tồn tại dưới + // namespace cũ để mất. id: record.manifest.id, group, label: record.manifest.label, From cae6ebedc9d1443087e91c940ebd9cb80eded465 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 05:51:12 +0000 Subject: [PATCH 7/8] installer.test.ts: cover registeredGroup's non-"core" conflict branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codecov flagged a gap: the two new registeredGroup tests only covered the "core" collision message and the "matches, falls through to disk scan" case — never the plain "already installed under a different group" throw reached via the registeredGroup parameter itself (as opposed to the pre-existing disk-scan path that produces the same message text). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BCypCuViyQDKGWxWspXKs2 --- src/platform/installer.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/platform/installer.test.ts b/src/platform/installer.test.ts index edabcf7..5c0a5f4 100644 --- a/src/platform/installer.test.ts +++ b/src/platform/installer.test.ts @@ -290,6 +290,16 @@ describe('assertNoConflictingInstall — gọi TRƯỚC installArtifact/installP await expect(installer.assertNoConflictingInstall('json', undefined, 'url')).resolves.toBeUndefined(); }); + + it('registeredGroup khác "core" (plugin đã cài+đăng ký từ lần khởi động TRƯỚC, khác group đang cài) — ném đúng thông điệp "gỡ bản đó", không cần quét đĩa', async () => { + const installer = await loadInTauri(); + // KHÔNG mock invoke: nhánh registeredGroup phải chặn trước khi kịp gọi + // listInstalledPlugins() — cùng lý do với test "core" phía trên. + await expect(installer.assertNoConflictingInstall('container-manager', 'custom-fork', 'official')).rejects.toThrow( + /container-manager.*official/, + ); + expect(invokeMock).not.toHaveBeenCalled(); + }); }); describe('currentTargetTriple', () => { From 4629018866f3e317eb212c23f153501b13b93257 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 06:03:52 +0000 Subject: [PATCH 8/8] fix: don't false-block install on stale non-core registry group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assertNoConflictingInstall's early registeredGroup check threw for ANY registeredGroup !== newGroup, not just 'core'. But the in-memory registry (PLUGIN_MAP) only loads once at app bootstrap and never updates when a plugin is installed/uninstalled during a running session. So a user who uninstalls a market plugin and immediately reinstalls the same id from a different source (without restarting) would be incorrectly blocked by a stale registeredGroup — even though the disk scan below it would correctly show no conflict. Fix: restrict the early-throw to registeredGroup === 'core' only, since compile-time plugins never disappear from the registry mid-session and are safe to short-circuit on. Every other registeredGroup value now falls through unconditionally to the disk-scan-based check, which is always accurate for non-compile-time plugins. Verified by temporarily reverting the fix locally and confirming the new STALE test fails for exactly the right reason (throws the "uninstall the old one first" error when it should resolve cleanly), then restoring it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01BCypCuViyQDKGWxWspXKs2 --- src/platform/installer.test.ts | 28 ++++++++++++++++++++------- src/platform/installer.ts | 35 +++++++++++++++++----------------- 2 files changed, 38 insertions(+), 25 deletions(-) diff --git a/src/platform/installer.test.ts b/src/platform/installer.test.ts index 5c0a5f4..bea7b29 100644 --- a/src/platform/installer.test.ts +++ b/src/platform/installer.test.ts @@ -291,14 +291,28 @@ describe('assertNoConflictingInstall — gọi TRƯỚC installArtifact/installP await expect(installer.assertNoConflictingInstall('json', undefined, 'url')).resolves.toBeUndefined(); }); - it('registeredGroup khác "core" (plugin đã cài+đăng ký từ lần khởi động TRƯỚC, khác group đang cài) — ném đúng thông điệp "gỡ bản đó", không cần quét đĩa', async () => { + it('registeredGroup khác "core" (bản ghi registry, có thể đã STALE nếu người dùng vừa gỡ trong phiên này) — KHÔNG tự chặn, luôn rơi xuống quét đĩa', async () => { const installer = await loadInTauri(); - // KHÔNG mock invoke: nhánh registeredGroup phải chặn trước khi kịp gọi - // listInstalledPlugins() — cùng lý do với test "core" phía trên. - await expect(installer.assertNoConflictingInstall('container-manager', 'custom-fork', 'official')).rejects.toThrow( - /container-manager.*official/, - ); - expect(invokeMock).not.toHaveBeenCalled(); + // registry chỉ nạp một lần lúc bootstrap (registry.ts), không cập nhật + // khi cài/gỡ trong lúc app đang chạy — một registeredGroup non-core ở + // đây có thể là bản ghi CŨ của một plugin người dùng vừa gỡ, chưa + // restart. Tự chặn bằng registeredGroup ở trường hợp này sẽ chặn nhầm + // một lượt cài lại hợp lệ — phải luôn quét đĩa (nguồn xác thực đúng + // trạng thái hiện tại) để biết còn conflict thật hay không. + invokeMock.mockResolvedValueOnce([]); // đĩa: không còn bản nào — plugin cũ đã bị gỡ thật + await expect( + installer.assertNoConflictingInstall('container-manager', 'custom-fork', 'official'), + ).resolves.toBeUndefined(); + }); + + it('registeredGroup khác "core" NHƯNG đĩa xác nhận vẫn còn conflict thật — vẫn ném đúng thông điệp', async () => { + const installer = await loadInTauri(); + invokeMock.mockResolvedValueOnce([ + installedPluginRaw({ manifest: remoteManifest({ id: 'container-manager' }), market_id: 'official' }), + ]); + await expect( + installer.assertNoConflictingInstall('container-manager', 'custom-fork', 'official'), + ).rejects.toThrow(/container-manager.*official/); }); }); diff --git a/src/platform/installer.ts b/src/platform/installer.ts index d69eac9..3c7569d 100644 --- a/src/platform/installer.ts +++ b/src/platform/installer.ts @@ -245,16 +245,21 @@ function installGroup(marketId: string | null | undefined): string { * group/id này. * * `registeredGroup` — group của một bản ghi ĐANG TRONG REGISTRY (không phải - * trên đĩa) đã chiếm đúng `id` này, nếu có: 26 plugin compile-time (group - * `'core'`), hoặc một plugin đã cài+đăng ký từ lần khởi động TRƯỚC (chưa chắc - * còn khớp danh sách trên đĩa nếu người dùng vừa gỡ nó trong phiên này). - * Lớp gọi tự tra bằng `getPlugin(id)?.group` (đã có sẵn qua `@/platform`) rồi - * truyền vào — hàm này KHÔNG tự tra được, vì `registry.ts` đã import - * `installer.ts` (nạp ngược lại sẽ vòng lặp). Thiếu tham số này, cài một - * plugin trùng id với một tool có sẵn (vd đặt tên "json") sẽ qua được cửa - * này, ghi xuống đĩa, rồi mới bị `registerManifest`'s `seenIds` âm thầm từ - * chối ở lần khởi động sau — đúng kiểu lỗi hoãn-đến-sau-restart mà hàm này - * tồn tại để tránh. + * trên đĩa) đã chiếm đúng `id` này, nếu có. Lớp gọi tự tra bằng + * `getPlugin(id)?.group` (đã có sẵn qua `@/platform`) rồi truyền vào — hàm + * này KHÔNG tự tra được, vì `registry.ts` đã import `installer.ts` (nạp + * ngược lại sẽ vòng lặp). + * + * CHỈ chặn sớm ở đây khi `registeredGroup === 'core'` (một trong 26 plugin + * compile-time — không bao giờ "biến mất" khỏi registry trong lúc app đang + * chạy, nên registry luôn đúng cho trường hợp này). Mọi `registeredGroup` + * khác 'core' bị BỎ QUA ở đây, rơi xuống quét đĩa bên dưới thay vì tự chặn + * — registry chỉ nạp MỘT LẦN lúc bootstrap (`registry.ts`), không cập nhật + * khi cài/gỡ trong phiên đang chạy, nên một bản ghi non-core ở đây có thể + * đã STALE (người dùng vừa gỡ nó, chưa restart) và tự chặn nhầm một lượt + * cài lại hợp lệ. Đĩa (`listInstalledPlugins()`) luôn phản ánh đúng trạng + * thái hiện tại nên là nguồn xác thực duy nhất đáng tin cho mọi trường hợp + * không phải compile-time. */ export async function assertNoConflictingInstall( id: string, @@ -263,14 +268,8 @@ export async function assertNoConflictingInstall( ): Promise { const newGroup = installGroup(marketId); - if (registeredGroup !== undefined && registeredGroup !== newGroup) { - if (registeredGroup === 'core') { - throw new Error(`Plugin "${id}" trùng id với một tool có sẵn trong app — đổi id khác trong manifest.`); - } - throw new Error( - `Plugin "${id}" đã được cài từ nguồn khác ("${registeredGroup}"). ` + - `Gỡ bản đó trong Settings → Extensions trước khi cài từ nguồn "${newGroup}".`, - ); + if (registeredGroup === 'core' && registeredGroup !== newGroup) { + throw new Error(`Plugin "${id}" trùng id với một tool có sẵn trong app — đổi id khác trong manifest.`); } let installed: InstalledPluginRecord[];