From ec5b9273e5f3018de97a31f3072a2ca80cc4aae0 Mon Sep 17 00:00:00 2001 From: OcuClaw <270795074+ocuclaw@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:54:01 +0800 Subject: [PATCH] fix(desktop): never clone a unified package's desktop half standalone on a local backend The install dialog gated the package path on the agent install succeeding in the same click (`agentInstalled && desktopHalfFromPackage`). A package already on disk answers "Plugin '' already exists. Use force reinstall" without Force, so the retry fell through to installDesktopPlugin and cloned desktop-plugins// beside the package copy the app had already materialised as desktop-plugins//. Two folders, one plugin id: the on-disk duplicate from #100412 (since e1a6679399 an error row instead of a competing live instance, but still created by the install flow). The package path now applies whenever the repo is a unified package, the backend is local and the Agent box is ticked, whatever the agent install returned. reconcileDesktopPlugins() is idempotent, so a retry touches nothing; the desktop success toast is raised only when the agent half landed or a copy was actually materialised, so a refused install shows the agent error alone. A failed fresh install no longer leaves a standalone desktop clone behind for the next successful install to duplicate. Remote backends keep the separate clone (their plugins/ folder is not readable from this machine); the Desktop-UI-only tick is unchanged. Fixes #100412 (install-time half; runtime half landed in #118902). Tests: two invariants in plugin-install-modal.test.tsx, the first red on main (installDesktopPlugin called on the refused retry), the second guarding the remote-backend clone. (cherry picked from commit 9fbd3a9d8328ea452a4149252a35a0e6f43982e6) --- .../settings/plugin-install-modal.test.tsx | 43 +++++++++++++++++++ .../src/app/settings/plugin-install-modal.tsx | 17 +++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/settings/plugin-install-modal.test.tsx b/apps/desktop/src/app/settings/plugin-install-modal.test.tsx index 82e898374d53..1545c406f3ff 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.test.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.test.tsx @@ -176,3 +176,46 @@ describe('Install from Git entry flow', () => { ) }) }) + +describe('Unified package desktop half on a local backend', () => { + const alreadyExists = "Plugin 'pkg' already exists. Use force reinstall to replace it." + const reconcileDesktopPlugins = vi.fn(async (): Promise => []) + + const installHybrid = async (mode: 'local' | 'remote') => { + $connection.set({ mode } as NonNullable>) + probePluginRepo.mockResolvedValue({ ok: true, agent: true, agentName: 'pkg', desktop: true, warnings: [] }) + requestGateway.mockImplementation(async (method, params) => + method === 'plugins.manage' && params?.action === 'install' ? { ok: false, error: alreadyExists } : { plugins: [] } + ) + installDesktopPlugin.mockResolvedValue({ ok: true, pluginName: 'pkg' }) + vi.stubGlobal('hermesDesktop', { installDesktopPlugin, probePluginRepo, reconcileDesktopPlugins }) + renderFlow() + act(() => openPluginInstallRequest({ repo: 'https://github.com/example/pkg' })) + expect(await screen.findByText('This package includes')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Install' })) + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'install' })) + ) + expect(await screen.findByText(alreadyExists)).toBeTruthy() + } + + it('never clones the desktop half standalone when the agent install is refused', async () => { + // A no-Force retry of a package already on disk: the backend refuses the + // agent half, and the desktop half is still served from that package. + // Cloning it separately here is what left desktop-plugins// + // beside the package copy (#100412). + await installHybrid('local') + + expect(reconcileDesktopPlugins).toHaveBeenCalled() + expect(installDesktopPlugin).not.toHaveBeenCalled() + }) + + it('still clones the desktop half for a remote backend', async () => { + // A remote backend's plugins/ folder is unreadable from this machine, so + // the separate clone remains the only door for its desktop half. + await installHybrid('remote') + + expect(installDesktopPlugin).toHaveBeenCalledWith({ identifier: 'https://github.com/example/pkg', force: false }) + expect(reconcileDesktopPlugins).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/settings/plugin-install-modal.tsx b/apps/desktop/src/app/settings/plugin-install-modal.tsx index ddaf757a93ab..5637323a4b37 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.tsx @@ -266,14 +266,21 @@ export function PluginInstallModal() { } if (installDesktop && probe.desktop) { - if (agentInstalled && desktopHalfFromPackage) { + if (desktopHalfFromPackage) { // Unified package into a LOCAL backend: the desktop half ships inside - // the package folder Electron just watched land. Materialise it from - // there (one source of truth, follows updates/uninstall) instead of - // cloning a second, standalone copy under another folder name. + // the package folder. Materialise it from there (one source of truth, + // follows updates/uninstall) instead of cloning a second, standalone + // copy under another folder name. This holds whether or not the agent + // install above succeeded: a package already on disk answers "already + // exists" without Force, and falling through to the clone would land + // desktop-plugins// beside the package copy (#100412). When + // there is nothing to materialise, nothing was installed. The agent + // error already says so. const touched = (await window.hermesDesktop?.reconcileDesktopPlugins?.()) ?? [] - successes.push(m.desktopSuccess(probe.agentName ?? request.repo)) + if (agentInstalled || touched.length > 0) { + successes.push(m.desktopSuccess(probe.agentName ?? request.repo)) + } if (touched.length > 0) { await discoverRuntimePlugins()