From 6d89c1afdeec8667480c02dc5e23492a044d13ba Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 10 Sep 2026 04:31:58 -0700 Subject: [PATCH] feat(plugins): pin a plugin to a commit from Desktop, and show pins in every list Only the CLI could install a plugin at an exact commit (`--ref `); the Desktop "Install from Git" dialog, the TUI gateway `plugins.manage install` method and the dashboard `/agent-plugins/install` endpoint all called `_install_plugin_core` without a ref, so a team that wanted everyone on the same private-plugin commit had to leave the app for a terminal. - `dashboard_install_plugin(ref=)` threads the pin to `_install_plugin_core`; the 40-hex validation and HEAD verification are unchanged. The gateway method and dashboard body accept `ref`. - Desktop dialog gains an optional "Pin to commit" field (custom sources only, client-side 40-hex check disables Install on anything shorter). - `plugins.manage list` rows carry `pinned_sha`; the Desktop plugins tab shows a `pinned @ ` badge and `hermes plugins list` prints `git pinned@` in Source, so a team can eyeball that everyone runs the same commit. --- .../settings/plugin-install-modal.test.tsx | 23 +++++++++++ .../src/app/settings/plugin-install-modal.tsx | 38 +++++++++++++++++-- apps/desktop/src/app/skills/plugins-tab.tsx | 7 ++++ apps/desktop/src/i18n/en.ts | 8 ++++ apps/desktop/src/i18n/ru.ts | 5 +++ apps/desktop/src/i18n/types.ts | 6 +++ apps/desktop/src/i18n/zh.ts | 7 ++++ apps/desktop/src/store/agent-plugins.ts | 10 ++++- hermes_cli/plugins_cmd.py | 27 +++++++++++-- hermes_cli/web_models.py | 2 + hermes_cli/web_routers/dashboard_ui.py | 2 +- .../test_plugins_manage_install.py | 2 + .../test_plugins_manage_install_ref.py | 24 ++++++++++++ tui_gateway/methods_tools.py | 7 +++- website/docs/user-guide/desktop.md | 5 ++- website/docs/user-guide/features/plugins.md | 7 ++++ 16 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 tests/tui_gateway/test_plugins_manage_install_ref.py 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 3db67fe2f1..6b8ca1a0c5 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.test.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.test.tsx @@ -111,4 +111,27 @@ describe('Install from Git entry flow', () => { expect(requestGateway).not.toHaveBeenCalledWith('plugins.manage', expect.objectContaining({ action: 'install' })) expect(installDesktopPlugin).not.toHaveBeenCalled() }) + + it('pins a custom install to a full commit SHA and refuses anything shorter', async () => { + probePluginRepo.mockResolvedValue({ ok: true, agent: true, desktop: false, warnings: [] }) + requestGateway.mockImplementation(async (method: string) => + method === 'plugins.manage' ? { ok: true, plugin_name: 'plugin', plugins: [] } : { plugins: [] } + ) + renderFlow() + act(() => openPluginInstallRequest({ repo: 'https://github.com/example/plugin' })) + const pin = await screen.findByRole('textbox', { name: 'Pin to commit (optional)' }) + const install = screen.getByRole('button', { name: 'Install' }) as HTMLButtonElement + fireEvent.change(pin, { target: { value: 'main' } }) + expect(install.disabled).toBe(true) + const sha = 'ABCDEF0123456789abcdef0123456789abcdef01' + fireEvent.change(pin, { target: { value: ` ${sha} ` } }) + expect(install.disabled).toBe(false) + fireEvent.click(install) + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith( + 'plugins.manage', + expect.objectContaining({ action: 'install', ref: sha.toLowerCase() }) + ) + ) + }) }) diff --git a/apps/desktop/src/app/settings/plugin-install-modal.tsx b/apps/desktop/src/app/settings/plugin-install-modal.tsx index 83fb8ee080..92cc4b8066 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.tsx @@ -22,7 +22,7 @@ import { useI18n } from '@/i18n' import { ExternalLink } from '@/lib/external-link' import { AlertTriangle } from '@/lib/icons' import { resolvePluginSourceLinks } from '@/lib/plugin-source-urls' -import { installAgentPlugin, loadAgentPlugins } from '@/store/agent-plugins' +import { COMMIT_SHA_RE, installAgentPlugin, loadAgentPlugins } from '@/store/agent-plugins' import { notify } from '@/store/notifications' import { $pluginInstallRequest, @@ -57,6 +57,7 @@ export function PluginInstallModal() { const [installDesktop, setInstallDesktop] = useState(true) const [enableAgent, setEnableAgent] = useState(true) const [forceReinstall, setForceReinstall] = useState(false) + const [pinRef, setPinRef] = useState('') const [installing, setInstalling] = useState(false) const [installError, setInstallError] = useState(null) const probeToken = useRef(0) @@ -69,6 +70,7 @@ export function PluginInstallModal() { setInstallDesktop(true) setEnableAgent(true) setForceReinstall(false) + setPinRef('') setInstalling(false) setInstallError(null) }, []) @@ -195,6 +197,7 @@ export function PluginInstallModal() { force: forceReinstall, enable: enableAgent, catalogName: request.catalogName, + ref: pinRefTrimmed || undefined, profile: request.profile }) @@ -280,6 +283,8 @@ export function PluginInstallModal() { const open = request !== null && !onSettings const busy = phase === 'probing' || installing + const pinRefTrimmed = pinRef.trim().toLowerCase() + const pinRefInvalid = pinRefTrimmed !== '' && !COMMIT_SHA_RE.test(pinRefTrimmed) return ( - {[...(probe.warnings ?? []), probe.insecure ? m.insecureWarning : ''].filter(Boolean).join(' ')} + {[...new Set([...(probe.warnings ?? []), probe.insecure ? m.insecureWarning : ''])] + .filter(Boolean) + .join(' ')} )} @@ -455,6 +462,28 @@ export function PluginInstallModal() { )} + + {!request.catalogName && probe.agent && ( + + )} )} @@ -475,7 +504,10 @@ export function PluginInstallModal() { {m.reviewRepository} ) : ( - )} diff --git a/apps/desktop/src/app/skills/plugins-tab.tsx b/apps/desktop/src/app/skills/plugins-tab.tsx index 2b743f6e83..f0f119ed54 100644 --- a/apps/desktop/src/app/skills/plugins-tab.tsx +++ b/apps/desktop/src/app/skills/plugins-tab.tsx @@ -114,6 +114,13 @@ function AgentPluginListRow({ )} + {row.pinned_sha && ( + + + {t.skills.plugins.pinnedBadge(row.pinned_sha.slice(0, 8))} + + + )} {row.update_available && onUpdate && (