Files
hermes-agent/apps/desktop/src/store/agent-plugins.ts
T
seref 359e09fd65 feat(desktop): one-click plugin install via hermes:// deeplinks
Adds a reviewable in-app install path for Hermes plugins:
hermes://plugin/install?repo=owner/repo (and Settings -> Plugins ->
Install from Git) opens a confirmation modal showing the repo identity
and source links, shallow-clones to probe for agent and/or desktop
plugin artifacts, lets the user pick components, then installs — agent
side through the gateway's new plugins.manage `install` action (wrapping
the existing dashboard_install_plugin), desktop side through a new
Electron git-install module with subdir-escape guards, a 60s clone
timeout, non-interactive git env, and insecure-scheme warnings. Never
auto-installs; hybrid repos get one dialog. Legacy plugin-agent /
plugin-desktop deeplinks route into the same modal.

Salvaged from PR #82735 by @serefyarar (net diff applied onto current
main as a single authored commit; the branch carried merge commits).
The preview screenshot PNG from the original branch was intentionally
not carried over — images live in PR bodies, not the repo.
2026-08-18 14:45:23 -07:00

212 lines
6.6 KiB
TypeScript

import { atom } from 'nanostores'
import { notifyError } from '@/store/notifications'
/**
* Feature store for backend (agent) plugins — the native Hermes plugins plus
* portable Agent Plugins v1 packages the backend discovers on disk. Settings
* renders this next to the desktop (renderer) plugin inventory so every plugin
* the user has is discoverable and toggleable from one page, whatever process
* it runs in.
*
* Backed by the gateway's `plugins.manage` RPC — the same list/toggle
* primitives `hermes plugins` and the dashboard use, so all surfaces agree on
* what's installed and what's enabled. Works against every backend topology
* (local spawn, SSH, URL+token) because it rides the session's own transport.
*/
export interface AgentPluginRow {
name: string
/** Canonical registry key (e.g. `image_gen/fal`) — absent on legacy backends. */
key?: string
version: string
description: string
/** 'bundled' | 'user' | 'git' | 'project' | 'entrypoint' */
source: string
status: 'enabled' | 'disabled' | 'not enabled'
/** Agent Plugins v1 package (portable skills/MCP format) vs native Hermes. */
portable?: boolean
}
export type AgentPluginsStatus = 'idle' | 'loading' | 'ready' | 'error'
/** The recovering `requestGateway` from `useGatewayRequest`. */
export type GatewayRequest = <T>(method: string, params?: Record<string, unknown>) => Promise<T>
export const $agentPlugins = atom<AgentPluginRow[]>([])
export const $agentPluginsStatus = atom<AgentPluginsStatus>('idle')
export const $agentPluginsError = atom<string | null>(null)
/** Best available address of the row whose toggle RPC is in flight. */
export const $agentPluginBusy = atom<string | null>(null)
// Rows the Plugins page actually lists (and search should surface): plugins
// the USER installed. Repo-bundled built-ins ship enabled-by-default and are
// configured from their own surfaces, so they're pure noise here. The prefix
// list is the fallback for older backends whose rows predate a reliable
// `source` field — same curation stance as desktop-slash-commands.ts.
const HIDDEN_KEY_PREFIXES = ['dashboard_auth/', 'model-providers/', 'platforms/']
export const isDesktopRelevantPlugin = (row: AgentPluginRow): boolean => {
if (row.source === 'bundled') {
return false
}
const key = row.key
return !key || !HIDDEN_KEY_PREFIXES.some(prefix => key.startsWith(prefix))
}
let inflight: Promise<void> | null = null
let inflightProfile: string | null = null
// Bumped per load so a slow response from a previous profile scope can't
// overwrite the newer scope's list (async results can land out of order).
let loadGeneration = 0
/** Scope a `plugins.manage` payload to a profile. Omitted (null) = the
* backend's launch profile — older backends ignore the extra param. */
const withProfile = (params: Record<string, unknown>, profile?: string | null) =>
profile ? { ...params, profile } : params
/** Fetch the backend plugin list, optionally scoped to another profile's
* HERMES_HOME. Always refetches (it's a cheap local disk scan on the
* backend); concurrent callers for the SAME profile share one in-flight
* request — a different profile starts fresh so a scope switch can't get a
* stale list. */
export function loadAgentPlugins(request: GatewayRequest, profile?: string | null): Promise<void> {
const scope = profile ?? null
if (inflight && inflightProfile === scope) {
return inflight
}
const generation = ++loadGeneration
inflightProfile = scope
inflight = (async () => {
if ($agentPluginsStatus.get() !== 'ready') {
$agentPluginsStatus.set('loading')
}
try {
const result = await request<{ plugins?: AgentPluginRow[] }>(
'plugins.manage',
withProfile({ action: 'list' }, scope)
)
if (generation !== loadGeneration) {
return
}
$agentPlugins.set(result?.plugins ?? [])
$agentPluginsStatus.set('ready')
$agentPluginsError.set(null)
} catch (e) {
if (generation !== loadGeneration) {
return
}
$agentPluginsError.set(e instanceof Error ? e.message : String(e))
$agentPluginsStatus.set('error')
} finally {
if (generation === loadGeneration) {
inflight = null
inflightProfile = null
}
}
})()
return inflight
}
/** Flip a backend plugin on/off and patch the row from the RPC's refreshed
* copy. Addressed by canonical key ONLY — bare names collide across category
* dirs (image_gen/fal vs video_gen/fal), which is exactly why the backend
* moved to key-addressed toggles. Rows without a key (pre-contract-v6
* backends) render read-only instead of falling back to the collision-prone
* name protocol; the backend-contract skew toast points the user at the
* update. Returns whether the toggle stuck. */
export async function toggleAgentPlugin(
request: GatewayRequest,
key: string,
enable: boolean,
failMessage: string,
profile?: string | null
): Promise<boolean> {
$agentPluginBusy.set(key)
try {
const result = await request<{ ok?: boolean; plugin?: AgentPluginRow | null }>(
'plugins.manage',
withProfile(
{
action: 'toggle',
key,
enable
},
profile
)
)
if (!result?.ok) {
throw new Error(failMessage)
}
const refreshed = result.plugin
if (refreshed) {
$agentPlugins.set($agentPlugins.get().map(row => (row.key === key ? { ...row, ...refreshed } : row)))
} else {
await loadAgentPlugins(request, profile)
}
return true
} catch (e) {
notifyError(e, failMessage)
return false
} finally {
$agentPluginBusy.set(null)
}
}
export interface AgentPluginInstallResult {
ok: boolean
pluginName?: string
warnings?: string[]
missingEnv?: string[]
error?: string
}
export async function installAgentPlugin(
request: GatewayRequest,
opts: { identifier: string; force?: boolean; enable?: boolean }
): Promise<AgentPluginInstallResult> {
try {
const result = await request<{
ok?: boolean
plugin_name?: string
warnings?: string[]
missing_env?: string[]
error?: string
}>('plugins.manage', {
action: 'install',
identifier: opts.identifier,
force: Boolean(opts.force),
enable: opts.enable ?? true
})
if (!result?.ok) {
return { ok: false, error: result?.error || 'Install failed' }
}
return {
ok: true,
pluginName: result.plugin_name,
warnings: result.warnings,
missingEnv: result.missing_env
}
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) }
}
}