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 <sha>`); 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 @ <sha8>` badge and `hermes plugins list` prints `git pinned@<sha8>`
  in Source, so a team can eyeball that everyone runs the same commit.
This commit is contained in:
Teknium
2026-09-10 04:31:58 -07:00
parent 49ef015ca3
commit 6d89c1afde
16 changed files with 168 additions and 12 deletions
@@ -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() })
)
)
})
})
@@ -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<string | null>(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 (
<Dialog
@@ -433,7 +438,9 @@ export function PluginInstallModal() {
className="mt-0.5 size-3.5 shrink-0 text-amber-600 dark:text-amber-400"
/>
<span>
{[...(probe.warnings ?? []), probe.insecure ? m.insecureWarning : ''].filter(Boolean).join(' ')}
{[...new Set([...(probe.warnings ?? []), probe.insecure ? m.insecureWarning : ''])]
.filter(Boolean)
.join(' ')}
</span>
</div>
)}
@@ -455,6 +462,28 @@ export function PluginInstallModal() {
<Switch checked={forceReinstall} disabled={busy} onCheckedChange={setForceReinstall} />
</label>
)}
{!request.catalogName && probe.agent && (
<label className="block space-y-1">
<span className="text-[length:var(--conversation-caption-font-size)] text-foreground">
{m.pinToCommit}
</span>
<Input
aria-invalid={pinRefInvalid || undefined}
aria-label={m.pinToCommit}
disabled={busy || !installAgent}
onChange={event => setPinRef(event.target.value)}
placeholder={m.pinToCommitPlaceholder}
spellCheck={false}
value={pinRef}
/>
<span
className={`block text-[length:var(--conversation-caption-font-size)] ${pinRefInvalid ? 'text-destructive' : 'text-(--ui-text-tertiary)'}`}
>
{pinRefInvalid ? m.pinToCommitInvalid : m.pinToCommitHint}
</span>
</label>
)}
</div>
)}
@@ -475,7 +504,10 @@ export function PluginInstallModal() {
{m.reviewRepository}
</Button>
) : (
<Button disabled={busy || phase !== 'ready' || !probe?.ok} onClick={() => void handleInstall()}>
<Button
disabled={busy || phase !== 'ready' || !probe?.ok || pinRefInvalid}
onClick={() => void handleInstall()}
>
{installing ? m.installing : m.install}
</Button>
)}
@@ -114,6 +114,13 @@ function AgentPluginListRow({
</span>
</Tip>
)}
{row.pinned_sha && (
<Tip label={t.skills.plugins.pinnedProvenance(row.pinned_sha.slice(0, 8))}>
<span className="rounded border border-(--ui-stroke-tertiary) px-1 font-mono text-[0.65rem] text-(--ui-text-tertiary)">
{t.skills.plugins.pinnedBadge(row.pinned_sha.slice(0, 8))}
</span>
</Tip>
)}
{row.update_available && onUpdate && (
<Button
className="h-5 px-1.5 text-[0.65rem]"
+8
View File
@@ -490,6 +490,11 @@ export const en: Translations = {
gitCloneLabel: 'Git clone URL',
enableAgent: 'Enable agent plugin after install',
forceReinstall: 'Force reinstall (replace if already installed)',
pinToCommit: 'Pin to commit (optional)',
pinToCommitPlaceholder: 'Full 40-character commit SHA',
pinToCommitHint:
'Everyone installing this SHA gets the same code; the plugin then refuses updates until re-pinned. Leave empty for the latest commit.',
pinToCommitInvalid: 'Must be a full 40-character commit SHA (branches and tags are not accepted).',
install: 'Install',
installing: 'Installing…',
probing: 'Inspecting repository…',
@@ -1520,6 +1525,9 @@ export const en: Translations = {
'Hit "+ Add to this Agent" on any plugin — reviewed entries install at their pinned commit into the selected profile. Bundled agent+desktop plugins offer both halves.',
alreadyInstalled: (name: string) => `${name} is already installed in this profile.`,
catalogProvenance: (sha: string) => `Installed from the Hermes catalog${sha ? ` at pin ${sha}` : ''}.`,
pinnedProvenance: (sha: string) =>
`Pinned to commit ${sha}. Updates are refused until it is reinstalled with a new pin.`,
pinnedBadge: (sha: string) => `pinned @ ${sha}`,
tierOfficial: 'official',
tierCommunity: 'community',
updateToPin: (sha: string) => `Update to ${sha}`,
+5
View File
@@ -458,6 +458,11 @@ export const ru = defineLocale({
gitCloneLabel: 'URL для git clone',
enableAgent: 'Включить плагин агента после установки',
forceReinstall: 'Принудительная переустановка (заменить, если уже установлен)',
pinToCommit: 'Закрепить на коммите (необязательно)',
pinToCommitPlaceholder: 'Полный SHA коммита (40 символов)',
pinToCommitHint:
'Все, кто установит этот SHA, получат одинаковый код; плагин перестанет обновляться до смены пина. Оставьте пустым для последнего коммита.',
pinToCommitInvalid: 'Нужен полный SHA коммита из 40 символов (ветки и теги не принимаются).',
install: 'Установить',
installing: 'Установка…',
probing: 'Осмотр репозитория…',
+6
View File
@@ -427,6 +427,10 @@ export interface Translations {
gitCloneLabel: string
enableAgent: string
forceReinstall: string
pinToCommit: string
pinToCommitPlaceholder: string
pinToCommitHint: string
pinToCommitInvalid: string
install: string
installing: string
probing: string
@@ -1331,6 +1335,8 @@ export interface Translations {
catalogHint: string
alreadyInstalled: (name: string) => string
catalogProvenance: (sha: string) => string
pinnedProvenance: (sha: string) => string
pinnedBadge: (sha: string) => string
tierOfficial: string
tierCommunity: string
updateToPin: (sha: string) => string
+7
View File
@@ -474,6 +474,11 @@ export const zh: Translations = {
gitCloneLabel: 'Git 克隆地址',
enableAgent: '安装后启用智能体插件',
forceReinstall: '强制重装(替换已存在的安装)',
pinToCommit: '固定到提交(可选)',
pinToCommitPlaceholder: '完整的 40 位提交 SHA',
pinToCommitHint:
'安装同一 SHA 的所有人都会得到相同的代码;固定后插件将拒绝更新,直到重新固定。留空则安装最新提交。',
pinToCommitInvalid: '必须是完整的 40 位提交 SHA(不接受分支和标签)。',
install: '安装',
installing: '正在安装…',
probing: '正在检查仓库…',
@@ -1690,6 +1695,8 @@ export const zh: Translations = {
'点击任意插件上的「+ Add to this Agent」— 经过审核的条目会以其固定提交安装到所选配置。捆绑的 agent+桌面插件会同时提供两部分。',
alreadyInstalled: (name: string) => `${name} 已安装在此配置中。`,
catalogProvenance: (sha: string) => `从 Hermes 目录安装${sha ? `,固定提交 ${sha}` : ''}。`,
pinnedProvenance: (sha: string) => `已固定到提交 ${sha}。重新固定前将拒绝更新。`,
pinnedBadge: (sha: string) => `固定 @ ${sha}`,
tierOfficial: '官方',
tierCommunity: '社区',
updateToPin: (sha: string) => `更新到 ${sha}`,
+9 -1
View File
@@ -34,8 +34,13 @@ export interface AgentPluginRow {
catalog_sha?: string
/** Installed SHA differs from the catalog pin — an update is available. */
update_available?: boolean
/** Full commit SHA a `--ref` install is pinned to (custom sources; refuses `update`). */
pinned_sha?: string
}
/** A `--ref` pin is a full 40-hex commit SHA; branches and tags are refused server-side. */
export const COMMIT_SHA_RE = /^[0-9a-f]{40}$/i
export type AgentPluginsStatus = 'idle' | 'loading' | 'ready' | 'error'
/** The recovering `requestGateway` from `useGatewayRequest`. */
@@ -194,6 +199,8 @@ export async function installAgentPlugin(
/** Curated-catalog install: the backend resolves repo + pinned SHA from
* its own plugin-catalog and records provenance in the sidecar. */
catalogName?: string
/** Pin a custom source to one full commit SHA (team-wide reproducible install). */
ref?: string
/** Target profile's HERMES_HOME (null/undefined = backend launch profile). */
profile?: string | null
}
@@ -213,7 +220,8 @@ export async function installAgentPlugin(
identifier: opts.identifier,
force: Boolean(opts.force),
enable: opts.enable ?? true,
...(opts.catalogName ? { catalog_name: opts.catalogName } : {})
...(opts.catalogName ? { catalog_name: opts.catalogName } : {}),
...(opts.ref ? { ref: opts.ref } : {})
},
opts.profile
)
+23 -4
View File
@@ -446,6 +446,19 @@ def _write_install_metadata(metadata: dict[str, dict[str, object]]) -> None:
path, json.dumps(metadata, indent=2, sort_keys=True) + "\n", tmp_prefix=f"{path.name}.tmp-")
def pinned_revision(name: str, metadata: Optional[dict] = None) -> Optional[str]:
"""Full SHA a ``--ref`` install of *name* is pinned to, else ``None``."""
entry = (metadata if metadata is not None else _read_install_metadata()).get(name)
if isinstance(entry, dict) and entry.get("pinned") is True and isinstance(entry.get("revision"), str):
return entry["revision"]
return None
def _pin_annotation(name: str, metadata: dict) -> Optional[str]:
sha = pinned_revision(name, metadata)
return f"git pinned@{sha[:8]}" if sha else None
def _normalize_exact_revision(ref: str) -> str:
"""Lowercase a full 40-hex commit SHA; anything else is a PluginOperationError."""
if not isinstance(ref, str) or not _EXACT_COMMIT_RE.fullmatch(ref):
@@ -1313,10 +1326,13 @@ def cmd_list(args: Any | None = None) -> None:
disabled = _get_disabled_set()
entries = _filter_plugin_entries(entries, args, enabled, disabled)
from hermes_cli import plugins_cmd_catalog as catalog
# Source shows catalog provenance (``catalog:<tier>@<sha8>``); a kill-listed install is flagged.
# Source shows catalog provenance (``catalog:<tier>@<sha8>``) or a ``--ref`` pin
# (``git pinned@<sha8>``) so a team can eyeball that everyone runs the same commit.
pins = _read_install_metadata()
rows = [
(name, _plugin_status(name, enabled, disabled, key=key), str(version), description,
catalog.catalog_annotation(_dir) or source, catalog.removed_annotation(name, _dir))
catalog.catalog_annotation(_dir) or _pin_annotation(name, pins) or source,
catalog.removed_annotation(name, _dir))
for name, version, description, source, _dir, key in entries
]
@@ -1686,9 +1702,11 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disable
def dashboard_install_plugin(
identifier: str, *, force: bool, enable: bool, catalog_name: Optional[str] = None,
ref: Optional[str] = None,
) -> dict[str, Any]:
"""Non-interactive install for the dashboard/TUI. *catalog_name* installs a curated entry at its
pinned SHA (identifier may be empty); every path enforces the kill list (no GUI bypass)."""
pinned SHA (identifier may be empty); *ref* pins a custom source to one full commit SHA (same
contract as ``--ref``); every path enforces the kill list (no GUI bypass)."""
from hermes_cli import plugins_cmd_catalog as catalog
warnings: list[str] = []
entry = None
@@ -1713,7 +1731,8 @@ def dashboard_install_plugin(
target, installed_manifest, installed_name = catalog.install_catalog_entry(
entry, force=force, allow_removed=True)
else:
target, installed_manifest, installed_name = _install_plugin_core(identifier, force=force)
target, installed_manifest, installed_name = _install_plugin_core(
identifier, force=force, ref=(ref or "").strip() or None)
except PluginScanBlocked as exc:
fields = ("pattern_id", "severity", "category", "file", "line", "description")
return {
+2
View File
@@ -498,6 +498,8 @@ class _AgentPluginInstallBody(BaseModel):
enable: bool = True
# Install by curated-catalog name (resolves repo + pinned SHA server-side).
catalog_name: Optional[str] = None
# Pin a custom source to one full 40-hex commit SHA (same contract as ``--ref``).
ref: Optional[str] = None
class _PluginProvidersPutBody(BaseModel):
memory_provider: Optional[str] = None
+1 -1
View File
@@ -204,7 +204,7 @@ async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallB
if not identifier and not catalog_name:
raise HTTPException(status_code=400, detail="Provide an identifier or a catalog_name.")
result = dashboard_install_plugin(
identifier, force=body.force, enable=body.enable, catalog_name=catalog_name or None)
identifier, force=body.force, enable=body.enable, catalog_name=catalog_name or None, ref=body.ref)
result = _plugin_action(result, "Install failed.", rescan=True)
# Strip internal paths from the response
result.pop("after_install_path", None)
@@ -38,6 +38,7 @@ def test_plugins_manage_install_success():
force=True,
enable=False,
catalog_name=None,
ref=None,
)
@@ -99,6 +100,7 @@ def test_plugins_manage_install_catalog_name_only():
force=False,
enable=False,
catalog_name="weather-plugin",
ref=None,
)
@@ -0,0 +1,24 @@
"""Desktop/TUI ``plugins.manage`` honours a ``ref`` pin exactly like ``hermes plugins install --ref``."""
from unittest.mock import patch
from tui_gateway import methods_tools, server
def test_plugins_manage_install_threads_ref_to_headless_install():
sha = "a" * 40
with patch("hermes_cli.plugins_cmd.dashboard_install_plugin", return_value={"ok": True}) as install:
server.handle_request({"id": "1", "method": "plugins.manage",
"params": {"action": "install", "identifier": "org/private-plugin", "ref": sha}})
assert install.call_args.kwargs["ref"] == sha
def test_plugins_manage_list_reports_ref_pin(monkeypatch, tmp_path):
sha = "b" * 40
pc = methods_tools._tools_mod("hermes_cli.plugins_cmd")
monkeypatch.setattr(pc, "_discover_all_plugins", lambda: [("team-plugin", "1.0", "", "git", tmp_path, "team-plugin")])
monkeypatch.setattr(pc, "_read_install_metadata", lambda: {"team-plugin": {"pinned": True, "revision": sha, "source": "x"}})
monkeypatch.setattr(pc, "_get_enabled_set", set)
monkeypatch.setattr(pc, "_get_disabled_set", set)
monkeypatch.setattr(methods_tools._tools_mod("hermes_cli.plugins_cmd_catalog"), "catalog_pins", dict)
(row,) = methods_tools._plugin_rows()
assert row["pinned_sha"] == sha
+5 -2
View File
@@ -1331,6 +1331,7 @@ def _plugin_rows() -> list[dict]:
cat = _tools_mod("hermes_cli.plugins_cmd_catalog")
enabled, disabled = pc._get_enabled_set(), pc._get_disabled_set()
pins = cat.catalog_pins() # powers the desktop's "Update to <pin>" affordance
ref_pins = pc._read_install_metadata() # ``--ref`` installs: pinned_sha so the desktop can show the pin
out = []
for name, version, desc, source, _dir, key in sorted(pc._discover_all_plugins()):
status = pc._plugin_status(name, enabled, disabled, key=key)
@@ -1342,7 +1343,8 @@ def _plugin_rows() -> list[dict]:
out.append({
"name": name, "key": key, "version": str(version or ""), "description": desc or "",
"source": source, "status": status, "portable": pc._is_portable_plugin_dir(_dir),
**cat.catalog_row_fields(_dir, pins)})
**cat.catalog_row_fields(_dir, pins),
**({"pinned_sha": sha} if (sha := pc.pinned_revision(name, ref_pins)) else {})})
return out
@@ -1373,7 +1375,8 @@ def _plugins_install(rid, params):
if not ident and not catalog_name:
return _err(rid, 4019, "plugins.install requires 'identifier', 'repo', or 'catalog_name'")
result = _tools_mod("hermes_cli.plugins_cmd").dashboard_install_plugin(
ident, force=bool(params.get("force")), enable=params.get("enable", True), catalog_name=catalog_name or None)
ident, force=bool(params.get("force")), enable=params.get("enable", True), catalog_name=catalog_name or None,
ref=str(params.get("ref") or "").strip() or None)
return _ok(rid, result) if result.get("ok") else _err(rid, 5026, result.get("error") or "install failed")
+4 -1
View File
@@ -468,7 +468,10 @@ Hermes, in two sections on one page:
Discovery sits underneath: the live [Plugin Catalog](./features/plugin-catalog.md)
picker installs reviewed entries at their pinned commit into the selected
profile, and **Install from Git** takes any other repository through the same
review-then-install dialog. Old `Settings → Plugins` links redirect here.
review-then-install dialog; its optional **Pin to commit** field installs one
exact 40-character commit SHA (private repos included), and pinned plugins
carry a `pinned @ <sha8>` badge in the list. Old `Settings → Plugins` links
redirect here.
## Troubleshooting
@@ -191,6 +191,13 @@ plugin; choose a new exact commit explicitly with
profile-local install metadata contains no config values, environment values,
secrets, or capability grants.
The same pin is available in Hermes Desktop: **Skills → Plugins → Install from
Git** has a *Pin to commit* field that takes the full 40-character SHA, and the
plugins list shows a `pinned @ <sha8>` badge on every pinned install so a team
can confirm everyone is running the same commit. `hermes plugins list` prints
the pin in its Source column (`git pinned@<sha8>`). Pins work for private
repositories too, through the same stored credentials described below.
### Installing from a private repository
`hermes plugins install` clones non-interactively (it never prompts for a