mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(webapp): install a desktop plugin on the host that serves the tab
In the browser-hosted app, installing a desktop plugin dead-ended: the install dialog could inspect a repository through the gateway (`plugins.manage inspect`, #145770) but had nothing to install with. A desktop-only package answered "This package has nothing to install here." and the desktop half of a mixed one was skipped with a toast, because the only clone lived in Electron (`installDesktopPluginFromGit`). Since #145072 the Webapp *loads* whatever sits in `<root>/desktop-plugins/`, so the last mile was missing: no way to put it there from the UI. This adds `plugins.manage` `install_desktop` / `remove_desktop` — the backend half of that install — and points the dialog at them when it is not running in Electron. - `hermes_cli/desktop_plugin_install.py`: clones a repository's desktop half into the app-level `desktop-plugins/` root. Same resolution, folder name and package-marker rules as Electron, and the same staged-copy-then-rename publication under the root's writer lock, so a reconcile pass cannot copy a folder underneath it. The clone is held to the commit the inspection read, so a repository that moves after review cannot install a tree nobody looked at; `force` re-inspects (a forced reinstall means the repository moved). `remove_desktop_plugin` refuses a package's own half unless forced, and refuses a linked folder it would only half-delete. - `plugins_cmd_inspect.forget_inspection`: drops the ten-minute answer cache, used by a forced reinstall. - `desktop_plugin_halves`: `ROOT_WRITER` and `publish_desktop_tree`/`read_package_marker`/`mtime_ms` go public (the installer shares the root's lock), a standalone plugin publishes with no marker, and the Webapp's read router imports the root from the one place that defines it. - `plugins.manage install_desktop|remove_desktop` + contract (`tools_mcp_plugins`), regenerated. - Renderer: `desktopInstallSupported()` is also true in a browser-hosted tab, `cloneDesktopHalf` falls back to the gateway, and a backend that predates the action keeps the old "unavailable in this environment" wording. Uninstalling a disk plugin uses the same fallback. - Docs: the Webapp install/remove paragraph now describes the working path. Tests: 10 installer (real local clones: standalone vs package marker, force, sha pinning, path-shaped names, linked folder, removal rules), 6 gateway RPC (incl. contract validation), 3 browser-modal cases (desktop-only, both halves, old backend) beside the 6 existing ones, and one route test that installs through the backend and then reads the plugin back over `/api/desktop-plugins/*` — the whole trip the browser makes.
This commit is contained in:
1 parent
0c74cc682d
commit
abfe2876f3
17 files changed
+860
-40
No files matched your search
@@ -17,8 +17,10 @@ import { useI18n } from '@/i18n'
|
||||
import { DESKTOP_PLUGIN_TOOLSETS } from '@/lib/desktop-toolsets'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { CheckCircle2, FolderOpen, Loader2, Monitor, Package, RefreshCw } from '@/lib/icons'
|
||||
import { isBrowserHostedDesktop } from '@/lib/platform'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { removeDesktopPluginHalf } from '@/store/agent-plugins'
|
||||
import {
|
||||
$agentPluginBusy,
|
||||
$agentPlugins,
|
||||
@@ -727,7 +729,13 @@ export const PluginsTab = memo(function PluginsTab({
|
||||
return
|
||||
}
|
||||
|
||||
const result = await uninstallDiskPlugin(record.id)
|
||||
// A browser-hosted tab has no Electron to delete the folder: the host that serves it does.
|
||||
const result = await uninstallDiskPlugin(
|
||||
record.id,
|
||||
isBrowserHostedDesktop()
|
||||
? ({ name }) => removeDesktopPluginHalf(requestGateway, { name, profile: scope })
|
||||
: undefined
|
||||
)
|
||||
|
||||
if (result.ok) {
|
||||
notify({ kind: 'success', message: p.uninstalledDesktop(record.name) })
|
||||
@@ -736,7 +744,7 @@ export const PluginsTab = memo(function PluginsTab({
|
||||
}
|
||||
})
|
||||
},
|
||||
[p]
|
||||
[p, requestGateway, scope]
|
||||
)
|
||||
|
||||
// The card switch turns the whole package on or off, like a skill's; per-half
|
||||
|
||||
@@ -179,3 +179,99 @@ describe('Plugin install in the browser-hosted app (no window.hermesDesktop)', (
|
||||
expect(callsFor('install')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// The browser-hosted app WITH a backend that can install a desktop half (`plugins.manage
|
||||
// install_desktop`, new in this change): the host serving the tab clones the plugin into the folder
|
||||
// the Webapp already loads, so a desktop package is installable from the dialog instead of refused.
|
||||
describe('Plugin install in the browser-hosted app with a backend that installs desktop halves', () => {
|
||||
beforeEach(() => {
|
||||
window.document.documentElement.dataset.hermesDesktopHost = 'browser'
|
||||
})
|
||||
afterEach(() => {
|
||||
delete window.document.documentElement.dataset.hermesDesktopHost
|
||||
})
|
||||
|
||||
const desktopBackend = (inspection: unknown, desktop: unknown, install: unknown = { ok: true, plugin_name: 'pkg' }) =>
|
||||
requestGateway.mockImplementation(async (method, params) => {
|
||||
if (method !== 'plugins.manage') {
|
||||
return { plugins: [] }
|
||||
}
|
||||
|
||||
if (params?.action === 'inspect') {
|
||||
return inspection
|
||||
}
|
||||
|
||||
if (params?.action === 'install_desktop') {
|
||||
return desktop
|
||||
}
|
||||
|
||||
return params?.action === 'install' ? install : { plugins: [] }
|
||||
})
|
||||
|
||||
it('installs a desktop-only package on the host through the backend', async () => {
|
||||
desktopBackend({ ok: true, agent: false, desktop: true, desktop_name: 'ui', warnings: [] },
|
||||
{ ok: true, plugin_name: 'ui', paired_with_agent: false })
|
||||
|
||||
renderModal()
|
||||
act(() => openPluginInstallRequest({ repo: 'https://github.com/example/ui' }))
|
||||
|
||||
await waitFor(() => expect(installButton()?.disabled).toBe(false))
|
||||
expect(screen.queryByText('This package has nothing to install here.')).toBeNull()
|
||||
fireEvent.click(installButton()!)
|
||||
|
||||
await waitFor(() => expect(callsFor('install_desktop')).toHaveLength(1))
|
||||
expect(callsFor('install_desktop')[0][1]).toMatchObject({
|
||||
action: 'install_desktop',
|
||||
identifier: 'https://github.com/example/ui',
|
||||
force: false
|
||||
})
|
||||
// No agent half exists in this repository: nothing is installed into the plugins folder.
|
||||
expect(callsFor('install')).toHaveLength(0)
|
||||
await waitFor(() => expect($notifications.get().some(n => n.message === 'Desktop plugin ui installed')).toBe(true))
|
||||
await waitFor(() => expect($pluginInstallRequest.get()).toBeNull())
|
||||
})
|
||||
|
||||
it('installs both halves of a mixed package', async () => {
|
||||
desktopBackend({ ok: true, agent: true, agent_name: 'pkg', desktop: true, desktop_name: 'pkg', warnings: [] },
|
||||
{ ok: true, plugin_name: 'pkg', paired_with_agent: true },
|
||||
{ ok: true, plugin_name: 'pkg', enabled: true })
|
||||
|
||||
renderModal()
|
||||
act(() => openPluginInstallRequest({ repo: 'https://github.com/example/pkg' }))
|
||||
|
||||
await waitFor(() => expect(installButton()?.disabled).toBe(false))
|
||||
fireEvent.click(installButton()!)
|
||||
|
||||
await waitFor(() => expect(callsFor('install')).toHaveLength(1))
|
||||
await waitFor(() => expect(callsFor('install_desktop')).toHaveLength(1))
|
||||
await waitFor(() => expect($notifications.get().some(n => n.message === 'Desktop plugin pkg installed')).toBe(true))
|
||||
expect($notifications.get().some(n => n.message === DESKTOP_UNAVAILABLE)).toBe(false)
|
||||
})
|
||||
|
||||
it('says the install is unavailable when the backend predates the action', async () => {
|
||||
requestGateway.mockImplementation(async (method, params) => {
|
||||
if (method !== 'plugins.manage') {
|
||||
return { plugins: [] }
|
||||
}
|
||||
|
||||
if (params?.action === 'inspect') {
|
||||
return { ok: true, agent: false, desktop: true, desktop_name: 'ui', warnings: [] }
|
||||
}
|
||||
|
||||
if (params?.action === 'install_desktop') {
|
||||
throw new JsonRpcGatewayError('unknown plugins action: install_desktop', { code: 4017 })
|
||||
}
|
||||
|
||||
return { plugins: [] }
|
||||
})
|
||||
|
||||
renderModal()
|
||||
act(() => openPluginInstallRequest({ repo: 'https://github.com/example/ui' }))
|
||||
|
||||
await waitFor(() => expect(installButton()?.disabled).toBe(false))
|
||||
fireEvent.click(installButton()!)
|
||||
|
||||
await waitFor(() => expect(screen.findByText(DESKTOP_UNAVAILABLE)).toBeTruthy())
|
||||
expect(callsFor('install')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,13 @@
|
||||
import { enablePackageDesktopHalf } from '@/contrib/plugins-store'
|
||||
import { discoverRuntimePlugins } from '@/contrib/runtime-loader'
|
||||
import type { useI18n } from '@/i18n'
|
||||
import { isBrowserHostedDesktop } from '@/lib/platform'
|
||||
import {
|
||||
type AgentPluginInstallResult,
|
||||
type AgentPluginLiveNow,
|
||||
type GatewayRequest,
|
||||
installAgentPlugin,
|
||||
installDesktopPluginHalf,
|
||||
loadAgentPlugins
|
||||
} from '@/store/agent-plugins'
|
||||
import { notify } from '@/store/notifications'
|
||||
@@ -26,11 +28,11 @@ const HINT_COMPONENTS = {
|
||||
desktop: probe => ({ agent: false, desktop: probe.desktop })
|
||||
} satisfies Record<'agent' | 'both' | 'desktop', (probe: ProbeResult) => InstallComponents>
|
||||
|
||||
/** Whether this app can put a desktop half down. Native Desktop clones or copies one; the browser-hosted
|
||||
* app has no disk of its own and its server materialises none, so there a package installs its agent
|
||||
* half alone. */
|
||||
/** Whether this app can put a desktop half down. Native Desktop clones or copies one in Electron; the
|
||||
* browser-hosted app has no disk of its own, so the host that serves it clones one there
|
||||
* (`plugins.manage install_desktop`) and the Webapp's loader picks it up. */
|
||||
export function desktopInstallSupported(): boolean {
|
||||
return Boolean(window.hermesDesktop?.installDesktopPlugin)
|
||||
return Boolean(window.hermesDesktop?.installDesktopPlugin) || isBrowserHostedDesktop()
|
||||
}
|
||||
|
||||
/** What an install puts down: every half the repository has, narrowed by a legacy deep link or by
|
||||
@@ -139,19 +141,24 @@ async function installAgentHalf(plan: InstallPlan, deps: InstallDeps, report: Re
|
||||
return result
|
||||
}
|
||||
|
||||
async function cloneDesktopHalf(plan: InstallPlan, copy: InstallModalCopy, report: Report): Promise<void> {
|
||||
async function cloneDesktopHalf(plan: InstallPlan, copy: InstallModalCopy, report: Report, deps: InstallDeps): Promise<void> {
|
||||
// Native Desktop clones it in Electron; a browser-hosted tab asks the host that serves it, and the
|
||||
// plugin lands in the folder this Webapp already loads.
|
||||
const installFn = window.hermesDesktop?.installDesktopPlugin
|
||||
|
||||
if (!installFn) {
|
||||
report.errors.push(copy.desktopUnavailable)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const result = await installFn({ identifier: plan.request.repo, force: plan.force })
|
||||
const result: { error?: string; ok: boolean; pluginName?: string; unsupported?: boolean } = installFn
|
||||
? await installFn({ identifier: plan.request.repo, force: plan.force })
|
||||
: await installDesktopPluginHalf(deps.requestGateway, {
|
||||
identifier: plan.request.repo,
|
||||
catalogName: plan.request.catalogName,
|
||||
force: plan.force,
|
||||
profile: plan.profile
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
report.errors.push(result.error || copy.desktopFailed)
|
||||
// A backend older than this build has no place to put the half: say what this environment can do,
|
||||
// which is what an install without Electron always said.
|
||||
report.errors.push(result.unsupported ? copy.desktopUnavailable : result.error || copy.desktopFailed)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -163,7 +170,7 @@ async function cloneDesktopHalf(plan: InstallPlan, copy: InstallModalCopy, repor
|
||||
async function installDesktopHalf(
|
||||
plan: InstallPlan,
|
||||
components: InstallComponents,
|
||||
copy: InstallModalCopy,
|
||||
deps: InstallDeps,
|
||||
report: Report,
|
||||
agentResult?: AgentPluginInstallResult
|
||||
): Promise<void> {
|
||||
@@ -175,14 +182,14 @@ async function installDesktopHalf(
|
||||
const touched = (await window.hermesDesktop?.reconcileDesktopPlugins?.()) ?? []
|
||||
|
||||
if (agentResult?.ok || touched.length > 0) {
|
||||
report.successes.push(copy.desktopSuccess(plan.probe.agentName ?? plan.request.repo))
|
||||
report.successes.push(deps.copy.desktopSuccess(plan.probe.agentName ?? plan.request.repo))
|
||||
}
|
||||
|
||||
if (touched.length > 0) {
|
||||
await discoverRuntimePlugins()
|
||||
}
|
||||
} else {
|
||||
await cloneDesktopHalf(plan, copy, report)
|
||||
await cloneDesktopHalf(plan, deps.copy, report, deps)
|
||||
}
|
||||
|
||||
// The desktop half of a unified package lands opt-in; an install that enabled the agent half turns it on too.
|
||||
@@ -210,7 +217,7 @@ export async function runPluginInstall(plan: InstallPlan, deps: InstallDeps): Pr
|
||||
}
|
||||
|
||||
if (components.desktop) {
|
||||
await installDesktopHalf(plan, components, deps.copy, report, agentResult)
|
||||
await installDesktopHalf(plan, components, deps, report, agentResult)
|
||||
}
|
||||
|
||||
await loadAgentPlugins(deps.requestGateway, plan.profile)
|
||||
|
||||
@@ -995,7 +995,12 @@ function retireDiskPlugin(file: string, record: DiskPlugin): void {
|
||||
* entry is retired here so the pane/commands vanish without waiting for the
|
||||
* next scan. Unified-package halves are not addressable this way — the agent
|
||||
* uninstall prunes them. Resolves with the failure reason instead of throwing. */
|
||||
export async function uninstallDiskPlugin(pluginId: string): Promise<{ ok: boolean; error?: string }> {
|
||||
export async function uninstallDiskPlugin(
|
||||
pluginId: string,
|
||||
/** How the folder goes away. Native Desktop deletes it in Electron; a browser-hosted tab has no disk
|
||||
* of its own and passes the backend that serves it (`plugins.manage remove_desktop`). */
|
||||
removeDesktop?: (payload: { name: string }) => Promise<{ ok: boolean; error?: string }>
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const found = [...disk.entries()].find(([, record]) => record.id === pluginId || record.origin === pluginId)
|
||||
|
||||
if (!found) {
|
||||
@@ -1003,7 +1008,7 @@ export async function uninstallDiskPlugin(pluginId: string): Promise<{ ok: boole
|
||||
}
|
||||
|
||||
const [file, record] = found
|
||||
const remove = window.hermesDesktop?.removeDesktopPlugin
|
||||
const remove = removeDesktop ?? window.hermesDesktop?.removeDesktopPlugin
|
||||
|
||||
if (!remove) {
|
||||
return { ok: false, error: 'this Hermes Desktop build cannot remove desktop plugins — delete the folder by hand' }
|
||||
|
||||
@@ -383,6 +383,8 @@ export interface AgentPluginInspection {
|
||||
|
||||
// `_run_action`'s unknown action, and JSON-RPC's unknown method.
|
||||
const INSPECT_UNSUPPORTED_CODES = new Set([4017, -32601])
|
||||
/** A backend that predates `install_desktop`: it answers the action as unknown (4017). */
|
||||
const INSTALL_DESKTOP_UNSUPPORTED_CODES = new Set([4017, -32601])
|
||||
|
||||
/** Inspect a source through the backend (`plugins.manage inspect`): it clones into a temp folder and
|
||||
* reports which halves ship, installing and running nothing. For a renderer with no Git of its own
|
||||
@@ -442,6 +444,82 @@ export async function inspectAgentPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
/** Install a repository's desktop half onto the host that serves this client (`plugins.manage
|
||||
* install_desktop`). Native Desktop clones one itself; a browser-hosted renderer has no disk of its
|
||||
* own, so it asks the backend that serves the tab and the plugin appears in the folder the Webapp
|
||||
* already loads. Never rejects. */
|
||||
export async function installDesktopPluginHalf(
|
||||
request: GatewayRequest,
|
||||
opts: { identifier: string; catalogName?: string; force?: boolean; profile?: string | null }
|
||||
): Promise<{
|
||||
ok: boolean
|
||||
pairedWithAgent?: boolean
|
||||
pluginName?: string
|
||||
/** The backend predates the action: no retry changes that (a build older than this one). */
|
||||
unsupported?: boolean
|
||||
warnings: string[]
|
||||
error?: string
|
||||
}> {
|
||||
try {
|
||||
const result = await request<{
|
||||
ok?: boolean
|
||||
plugin_name?: string
|
||||
paired_with_agent?: boolean
|
||||
warnings?: string[]
|
||||
error?: string
|
||||
}>(
|
||||
'plugins.manage',
|
||||
withProfile(
|
||||
{
|
||||
action: 'install_desktop',
|
||||
identifier: opts.identifier,
|
||||
force: Boolean(opts.force),
|
||||
...(opts.catalogName ? { catalog_name: opts.catalogName } : {})
|
||||
},
|
||||
opts.profile
|
||||
),
|
||||
PLUGIN_INSTALL_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
|
||||
if (!result?.ok) {
|
||||
return { ok: false, warnings: [], error: result?.error || 'Desktop plugin install failed' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
pluginName: result.plugin_name,
|
||||
pairedWithAgent: Boolean(result.paired_with_agent),
|
||||
warnings: result.warnings ?? []
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
unsupported: e instanceof JsonRpcGatewayError && INSTALL_DESKTOP_UNSUPPORTED_CODES.has(e.code ?? 0),
|
||||
warnings: [],
|
||||
error: e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove one installed desktop plugin folder from the host that serves this client
|
||||
* (`plugins.manage remove_desktop`); a package's own half is refused by the backend unless forced.
|
||||
* Never rejects. */
|
||||
export async function removeDesktopPluginHalf(
|
||||
request: GatewayRequest,
|
||||
opts: { force?: boolean; name: string; profile?: string | null }
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await request<{ ok?: boolean; error?: string }>(
|
||||
'plugins.manage',
|
||||
withProfile({ action: 'remove_desktop', name: opts.name, force: Boolean(opts.force) }, opts.profile)
|
||||
)
|
||||
|
||||
return result?.ok ? { ok: true } : { ok: false, error: result?.error || 'Desktop plugin removal failed' }
|
||||
} catch (e) {
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
/** One security-scan finding on an update the scan blocked. */
|
||||
export interface AgentPluginScanFinding {
|
||||
severity: string
|
||||
|
||||
@@ -4388,7 +4388,7 @@ export interface PluginToolsetRow {
|
||||
label: string
|
||||
description: string
|
||||
}
|
||||
/** ``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name`` (+ ``force``, ``enable``, ``ref``); ``inspect``: ``identifier``/``repo`` or ``catalog_name``; ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin that widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose security scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only); ``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only); ``presence``: ``names`` (catalog names, answered in that order). */
|
||||
/** ``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name`` (+ ``force``, ``enable``, ``ref``); ``inspect``: ``identifier``/``repo`` or ``catalog_name``; ``install_desktop``: ``identifier``/``repo`` or ``catalog_name`` (+ ``force``) — the desktop half onto the host that serves this client, held to the commit ``inspect`` read; ``remove_desktop``: ``name`` (+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin that widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose security scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only); ``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only); ``presence``: ``names`` (catalog names, answered in that order). */
|
||||
export interface PluginsManageParams {
|
||||
profile?: string | null
|
||||
action?: PluginsAction
|
||||
@@ -4404,7 +4404,7 @@ export interface PluginsManageParams {
|
||||
values?: Record<string, unknown> | null
|
||||
names?: string[] | null
|
||||
}
|
||||
export type PluginsAction = 'list' | 'toggle' | 'install' | 'inspect' | 'update' | 'remove' | 'settings' | 'onboarding' | 'presence'
|
||||
export type PluginsAction = 'list' | 'toggle' | 'install' | 'inspect' | 'install_desktop' | 'remove_desktop' | 'update' | 'remove' | 'settings' | 'onboarding' | 'presence'
|
||||
/** ``list`` → ``plugins`` + counts; ``toggle`` → ``ok``/``unchanged``/``restart_required``/``name`` (the canonical key written)/``plugin``; ``install`` → ``hermes_cli.plugins_cmd.dashboard_install_plugin``'s ok payload; ``inspect`` → ``ok``/``agent``/``desktop``/``agent_name``/``desktop_name``/``sha`` (the commit read)/``warnings``/``insecure`` or ``ok=false`` + ``error``; ``toggle``/``install``/``update`` that loaded a plugin also carry ``gateway_reloaded`` (the running gateway picked it up and re-wired its handlers) and ``activation`` — the honest split of what is live now vs deferred, so ``restart_required`` is True only when no gateway answered or the running gateway/backend cannot import the plugin's Python dependencies (``activation.missing_dependencies``); ``update`` → ``ok``/``unchanged``/``sha``, or ``ok=false`` + ``consent_required`` with the ``delta`` (``{surface: [added...]}``) / ``delta_lines`` a widened pin adds — nothing changed until the client retries with ``accept_capabilities`` — or ``ok=false`` + ``scan_blocked`` with ``scan_verdict`` / ``scan_findings`` (retry a caution verdict with ``force``); ``remove`` → ``ok``/``name`` plus ``cleared_memory_provider`` when the removed plugin was the live ``memory.provider``. */
|
||||
export interface PluginsManageResult {
|
||||
plugins?: AgentPluginRow[] | null
|
||||
@@ -4441,6 +4441,9 @@ export interface PluginsManageResult {
|
||||
agent_name?: string | null
|
||||
desktop_name?: string | null
|
||||
insecure?: boolean | null
|
||||
path?: string | null
|
||||
paired_with_agent?: boolean | null
|
||||
unpaired_from?: string | null
|
||||
}
|
||||
/** ``methods_tools._plugin_rows`` + ``plugins_cmd_catalog.catalog_row_fields`` provenance. */
|
||||
export interface AgentPluginRow {
|
||||
|
||||
@@ -27709,6 +27709,8 @@
|
||||
"toggle",
|
||||
"install",
|
||||
"inspect",
|
||||
"install_desktop",
|
||||
"remove_desktop",
|
||||
"update",
|
||||
"remove",
|
||||
"settings",
|
||||
@@ -27773,7 +27775,7 @@
|
||||
},
|
||||
"PluginsManageParams": {
|
||||
"additionalProperties": false,
|
||||
"description": "``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name``\n(+ ``force``, ``enable``, ``ref``); ``inspect``: ``identifier``/``repo`` or ``catalog_name``; ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin\nthat widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose\nsecurity scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only);\n``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only);\n``presence``: ``names`` (catalog names, answered in that order).",
|
||||
"description": "``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name``\n(+ ``force``, ``enable``, ``ref``); ``inspect``: ``identifier``/``repo`` or ``catalog_name``;\n``install_desktop``: ``identifier``/``repo`` or ``catalog_name`` (+ ``force``) \u2014 the desktop half onto\nthe host that serves this client, held to the commit ``inspect`` read; ``remove_desktop``: ``name``\n(+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin\nthat widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose\nsecurity scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only);\n``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only);\n``presence``: ``names`` (catalog names, answered in that order).",
|
||||
"properties": {
|
||||
"profile": {
|
||||
"anyOf": [
|
||||
@@ -28376,6 +28378,42 @@
|
||||
],
|
||||
"default": null,
|
||||
"title": "Insecure"
|
||||
},
|
||||
"path": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Path"
|
||||
},
|
||||
"paired_with_agent": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Paired With Agent"
|
||||
},
|
||||
"unpaired_from": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Unpaired From"
|
||||
}
|
||||
},
|
||||
"title": "PluginsManageResult",
|
||||
|
||||
@@ -31,7 +31,9 @@ logger = logging.getLogger(__name__)
|
||||
PACKAGE_MARKER = ".hermes-package.json"
|
||||
_GIT_ORIGIN = re.compile(r'\[remote "origin"\][^[]*?url\s*=\s*(\S+)')
|
||||
#: Electron serializes every writer of the root in its main process; the server's threads do here.
|
||||
_ROOT_WRITER = threading.Lock()
|
||||
#: Public: ``desktop_plugin_install`` takes it too, so a clone-publish never interleaves with a
|
||||
#: reconcile pass copying the same package's folder.
|
||||
ROOT_WRITER = threading.Lock()
|
||||
|
||||
|
||||
def _list_dirs(folder: Path, *, follow_links: bool = False) -> list[str]:
|
||||
@@ -102,16 +104,18 @@ def _same_file(a: Path, b: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _publish(source_dir: Path, target: Path, marker: dict) -> None:
|
||||
def _publish(source_dir: Path, target: Path, marker: dict | None) -> None:
|
||||
"""Copy through a staging sibling and rename the finished tree into place, marker included, so a
|
||||
failure leaves the old folder or none: never a partial, marker-less one that a later pass would
|
||||
take for a hand install."""
|
||||
take for a hand install. A standalone plugin is published with no marker (``None``): one would
|
||||
claim another package's agent half."""
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = Path(tempfile.mkdtemp(prefix=f".{target.name}.staging-", dir=target.parent))
|
||||
staged = staging / target.name
|
||||
try:
|
||||
shutil.copytree(source_dir, staged, symlinks=True)
|
||||
_write_marker(staged, marker)
|
||||
if marker is not None:
|
||||
_write_marker(staged, marker)
|
||||
if target.is_symlink():
|
||||
target.unlink()
|
||||
elif target.exists():
|
||||
@@ -121,6 +125,23 @@ def _publish(source_dir: Path, target: Path, marker: dict) -> None:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def publish_desktop_tree(source_dir: Path, target: Path, marker: dict | None) -> None:
|
||||
"""Publish a desktop plugin folder into the app root: staged copy, optional package marker, atomic
|
||||
rename. The caller holds :data:`ROOT_WRITER` around its existence check and this call, so a
|
||||
concurrent reconcile pass cannot copy the same folder underneath it."""
|
||||
_publish(source_dir, target, marker)
|
||||
|
||||
|
||||
def read_package_marker(folder: Path) -> dict | None:
|
||||
"""The package marker on an installed folder, if it is one this root's writers put down."""
|
||||
return _read_marker(folder)
|
||||
|
||||
|
||||
def mtime_ms(st: os.stat_result) -> float:
|
||||
"""``Stats.mtimeMs`` as Node computes it, for markers written outside :func:`materialize_desktop_half`."""
|
||||
return _mtime_ms(st)
|
||||
|
||||
|
||||
def materialize_desktop_half(package_dir: Path, app_root: Path, name: str) -> Path | None:
|
||||
"""Copy *package_dir*'s ``desktop/`` half to ``<app_root>/<name>/``. Returns the folder when it
|
||||
was copied or adopted, ``None`` when it is already current, absent, or a user's own plugin."""
|
||||
@@ -187,7 +208,7 @@ def reconcile_desktop_halves(hermes_root: Path, app_root: Path) -> list[str]:
|
||||
"""Materialize every local home's desktop halves into *app_root* (first home wins for a name in
|
||||
several profiles, the default home first) and drop copies whose package is gone. Returns the
|
||||
folders it wrote or removed."""
|
||||
with _ROOT_WRITER:
|
||||
with ROOT_WRITER:
|
||||
touched: list[str] = []
|
||||
seen: set[str] = set()
|
||||
homes = [hermes_root, *(hermes_root / "profiles" / name for name in _list_dirs(hermes_root / "profiles"))]
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Install a desktop plugin from Git onto the host that serves the Webapp.
|
||||
|
||||
Native Desktop clones one in Electron (``apps/desktop/electron/desktop-plugin-install.ts``,
|
||||
``installDesktopPluginFromGit``). A browser-hosted tab has no disk of its own, so until now the install
|
||||
dialog could only *read* a repository through the gateway (``plugins_cmd_inspect``) and then had
|
||||
nothing to install with: a desktop-only package answered "nothing to install here", and the desktop
|
||||
half of a mixed one was reported skipped. This is the missing half of the trip — the same inspection,
|
||||
held to the commit it read, the same folder name and package-marker rules as Electron, published
|
||||
atomically into the app-level root that ``/api/desktop-plugins`` serves.
|
||||
|
||||
The install is a clone, a staged copy and a rename: no code is imported or run here. The plugin loads
|
||||
the way any other installed plugin does, on the next scan of ``desktop-plugins/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_cli import desktop_plugin_halves as halves
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
DESKTOP_PLUGINS_DIR = "desktop-plugins"
|
||||
|
||||
|
||||
def desktop_plugins_root() -> Path:
|
||||
"""The app-level root ``desktop-plugins/``, created on demand and resolved, exactly as Electron's
|
||||
``desktopPluginsRoot`` and the Webapp's read routes resolve it: one folder whichever profile the
|
||||
caller names."""
|
||||
root = get_default_hermes_root() / DESKTOP_PLUGINS_DIR
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root.resolve()
|
||||
|
||||
|
||||
def _pc():
|
||||
"""The facade, read at call time: tests patch ``plugins_cmd.<name>`` and sibling calls must see it."""
|
||||
from hermes_cli import plugins_cmd
|
||||
return plugins_cmd
|
||||
|
||||
|
||||
def _failure(error: str) -> dict[str, Any]:
|
||||
return {"ok": False, "error": error}
|
||||
|
||||
|
||||
def _checked_name(name: Optional[str]) -> str:
|
||||
"""*name* when it can be one folder under the root, else raise: the name comes from a repository
|
||||
(a subdirectory's last segment, a manifest's ``name``) and must never reach a path unresolved. A
|
||||
leading dot is refused too — the root's staging folders use that prefix."""
|
||||
candidate = (name or "").strip()
|
||||
if (not candidate or candidate in (".", "..") or candidate.startswith(".")
|
||||
or "/" in candidate or "\\" in candidate or "\0" in candidate
|
||||
or any(ord(char) < 32 for char in candidate) or len(candidate) > 128):
|
||||
raise _pc().PluginOperationError(
|
||||
f"Cannot install a desktop plugin under the folder name '{name}': "
|
||||
"it must be a plain folder name, not a path."
|
||||
)
|
||||
return candidate
|
||||
|
||||
|
||||
def _source(identifier: str, catalog_name: Optional[str]) -> tuple[str, Optional[str], str, Optional[str]]:
|
||||
"""``(git_url, subdir, resolved_identifier, revision)`` — the installer's own resolution, with the
|
||||
catalog's reviewed pin when *catalog_name* is given (its repository replaces *identifier*)."""
|
||||
pc = _pc()
|
||||
resolved = identifier
|
||||
revision = None
|
||||
if catalog_name:
|
||||
from hermes_cli import plugins_cmd_catalog as catalog
|
||||
entry = catalog.get_live_catalog_entry(catalog_name)
|
||||
if entry is None:
|
||||
raise pc.PluginOperationError(f"'{catalog_name}' is not in the Hermes plugin catalog.")
|
||||
resolved = entry.install_identifier
|
||||
revision = entry.sha or None
|
||||
git_url, subdir = pc._resolve_git_url(resolved)
|
||||
from hermes_cli.plugins_cmd_catalog import raise_if_removed
|
||||
raise_if_removed(resolved, git_url, *((catalog_name,) if catalog_name else ()))
|
||||
return git_url, subdir, resolved, revision
|
||||
|
||||
|
||||
def install_desktop_plugin(identifier: str, *, catalog_name: Optional[str] = None,
|
||||
force: bool = False) -> dict[str, Any]:
|
||||
"""Clone a repository's desktop half into ``desktop-plugins/<name>`` on this host.
|
||||
|
||||
Returns ``{ok, plugin_name, path, sha, paired_with_agent, warnings}``, or ``ok: False`` with
|
||||
``error``. The clone is held to the commit the inspection read, so a repository that moves after
|
||||
review cannot install a tree nobody looked at. A package carrying both halves lands under its
|
||||
*agent* name with the package marker Electron writes, so the Plugins page pairs the two rows; a
|
||||
desktop-only repository keeps its Git-derived folder name and carries no marker (it would claim
|
||||
another package's agent half).
|
||||
"""
|
||||
from hermes_cli.plugins_cmd_inspect import forget_inspection, inspect_plugin_source
|
||||
|
||||
if force:
|
||||
# A forced reinstall means "the repository moved": answer from a fresh clone, not the check
|
||||
# that named the tree now being replaced.
|
||||
forget_inspection(identifier, catalog_name=catalog_name)
|
||||
probe = inspect_plugin_source(identifier, catalog_name=catalog_name)
|
||||
if not probe.get("ok"):
|
||||
return _failure(probe.get("error") or "Could not read this plugin repository.")
|
||||
if not probe.get("desktop"):
|
||||
return _failure("This package ships no desktop plugin to install here.")
|
||||
|
||||
agent_name = probe.get("agent_name") if probe.get("agent") else None
|
||||
try:
|
||||
package_name = _checked_name(agent_name) if agent_name else None
|
||||
folder = package_name or _checked_name(probe.get("desktop_name"))
|
||||
git_url, subdir, _resolved, revision = _source(identifier, catalog_name)
|
||||
except _pc().PluginOperationError as exc:
|
||||
return _failure(str(exc))
|
||||
|
||||
root = desktop_plugins_root()
|
||||
target = root / folder
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix="hermes-plugin-install-") as tmp:
|
||||
clone = Path(tmp) / "plugin"
|
||||
# The commit the inspection named, when it named one: the install must be the tree that
|
||||
# was reviewed, not whatever the branch moved to since.
|
||||
sha = _pc()._clone_plugin_repo(clone, git_url, probe.get("sha") or revision, subdir)
|
||||
plugin_root = _pc()._resolve_subdir_within(clone, subdir) if subdir else clone
|
||||
source_dir = _desktop_tree(plugin_root)
|
||||
|
||||
if source_dir is None:
|
||||
return _failure("No desktop plugin.js found in this repository.")
|
||||
|
||||
# A package's half keeps the marker Electron writes (the Plugins page pairs it to the
|
||||
# agent row); a standalone plugin carries none.
|
||||
marker = None
|
||||
if package_name:
|
||||
marker = {
|
||||
"package": package_name,
|
||||
"repo": git_url,
|
||||
"source": str(target),
|
||||
"sourceMtimeMs": halves.mtime_ms((source_dir / "plugin.js").stat()),
|
||||
}
|
||||
|
||||
# Under the root's writer lock: the exists check and the publish must not interleave with
|
||||
# a reconcile pass copying the same package's folder.
|
||||
with halves.ROOT_WRITER:
|
||||
if target.is_symlink() or target.exists():
|
||||
if not force:
|
||||
return _failure(
|
||||
f"Desktop plugin '{folder}' already exists. Enable force reinstall to replace it."
|
||||
)
|
||||
halves.publish_desktop_tree(source_dir, target, marker)
|
||||
except _pc().PluginOperationError as exc:
|
||||
return _failure(str(exc))
|
||||
except OSError as exc:
|
||||
return _failure(f"Could not install the desktop plugin: {exc}")
|
||||
|
||||
return {"ok": True, "plugin_name": folder, "path": str(target), "sha": sha,
|
||||
"paired_with_agent": bool(package_name), "warnings": list(probe.get("warnings") or [])}
|
||||
|
||||
|
||||
def _desktop_tree(plugin_root: Path) -> Optional[Path]:
|
||||
"""The folder that is the desktop plugin: the repository root when ``plugin.js`` sits there, else
|
||||
its ``desktop/`` subfolder — Electron's ``findDesktopEntry`` search, narrowed to those two."""
|
||||
if (plugin_root / "plugin.js").is_file():
|
||||
return plugin_root
|
||||
if (plugin_root / "desktop" / "plugin.js").is_file():
|
||||
return plugin_root / "desktop"
|
||||
return None
|
||||
|
||||
|
||||
def remove_desktop_plugin(name: str, *, force: bool = False) -> dict[str, Any]:
|
||||
"""Delete one installed desktop plugin folder from this host.
|
||||
|
||||
A folder carrying the package marker is the desktop half of an installed agent plugin: removing it
|
||||
alone would leave the Plugins page pairing a row with nothing behind it, and the next reconcile
|
||||
would copy it straight back, so it is refused unless *force* says the caller means it.
|
||||
"""
|
||||
try:
|
||||
folder = _checked_name(name)
|
||||
except _pc().PluginOperationError as exc:
|
||||
return _failure(str(exc))
|
||||
target = desktop_plugins_root() / folder
|
||||
if target.is_symlink():
|
||||
return _failure(
|
||||
f"'{folder}' is linked from somewhere else on this host; remove that link where it lives."
|
||||
)
|
||||
if not target.is_dir():
|
||||
return _failure(f"No desktop plugin named '{folder}' is installed.")
|
||||
|
||||
with halves.ROOT_WRITER:
|
||||
marker = halves.read_package_marker(target)
|
||||
if marker and not force:
|
||||
return _failure(
|
||||
f"'{folder}' is the desktop half of an agent plugin — uninstall that plugin instead."
|
||||
)
|
||||
try:
|
||||
shutil.rmtree(target)
|
||||
except OSError as exc:
|
||||
return _failure(f"Could not remove the desktop plugin: {exc}")
|
||||
|
||||
return {"ok": True, "plugin_name": folder, "path": str(target), "unpaired_from": (marker or {}).get("package")}
|
||||
@@ -140,6 +140,15 @@ def inspect_plugin_source(identifier: str, *, catalog_name: Optional[str] = None
|
||||
return _copy(result)
|
||||
|
||||
|
||||
def forget_inspection(identifier: str, *, catalog_name: Optional[str] = None) -> None:
|
||||
"""Drop any cached answer for this source, so the next inspection clones afresh. A forced reinstall
|
||||
must not install the tree a ten-minute-old check named: the point of forcing it is that the
|
||||
repository moved."""
|
||||
from hermes_constants import get_hermes_home
|
||||
with _inspections_lock:
|
||||
_inspections.pop((str(get_hermes_home()), catalog_name or "", identifier), None)
|
||||
|
||||
|
||||
def _copy(result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Each caller gets its own answer: one shared by joined requests must not be edited through another."""
|
||||
return {**result, "warnings": list(result["warnings"])}
|
||||
|
||||
@@ -23,11 +23,11 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from hermes_cli.desktop_plugin_halves import reconcile_desktop_halves
|
||||
from hermes_cli.desktop_plugin_install import DESKTOP_PLUGINS_DIR, desktop_plugins_root
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DESKTOP_PLUGINS_DIR = "desktop-plugins"
|
||||
#: The files the loader reads in a plugin folder: its entry and the unified-package marker.
|
||||
_READABLE_FILES = frozenset({"plugin.js", ".hermes-package.json"})
|
||||
#: Electron's ``PLUGIN_SOURCE_MAX_BYTES``: a plugin loads whole or not at all, never truncated.
|
||||
@@ -36,14 +36,6 @@ PLUGIN_SOURCE_MAX_BYTES = 16 * 1024 * 1024
|
||||
_OPEN_FLAGS = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_BINARY", 0)
|
||||
|
||||
|
||||
def desktop_plugins_root() -> Path:
|
||||
"""App-level, like Electron's: the Hermes root's folder, whichever profile the request names.
|
||||
Created on demand as Electron does, and resolved, so it prefixes every path the routes return."""
|
||||
root = get_default_hermes_root() / DESKTOP_PLUGINS_DIR
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
return root.resolve()
|
||||
|
||||
|
||||
def _inside_root(raw: str) -> tuple[Path, tuple[str, ...]]:
|
||||
"""*raw* and its parts below the root, judged as written: no ``..``, at most a folder and a file.
|
||||
Links are not resolved here; a linked plugin folder is followed like native Desktop follows it.
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""``hermes_cli.desktop_plugin_install``: cloning a desktop plugin onto the host that serves the Webapp.
|
||||
|
||||
Real local Git repositories, a throwaway ``HERMES_HOME``: the folder lands where the Webapp's read
|
||||
routes serve it, a package's half carries the marker that pairs it to its agent row, a standalone
|
||||
plugin carries none, and nothing installs twice without ``force``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import desktop_plugin_install as install
|
||||
|
||||
|
||||
def _git(path: Path, *args: str) -> str:
|
||||
return subprocess.run(["git", *args], cwd=path, check=True, capture_output=True, text=True,
|
||||
encoding="utf-8").stdout.strip()
|
||||
|
||||
|
||||
def _repo(path: Path, files: dict[str, str]) -> Path:
|
||||
path.mkdir()
|
||||
for rel, text in files.items():
|
||||
target = path / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(text, encoding="utf-8")
|
||||
_git(path, "init", "-q", "-b", "main")
|
||||
_git(path, "add", "-A")
|
||||
_git(path, "-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "-q", "-m", "c")
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def home(tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "home"
|
||||
hermes_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
import hermes_constants
|
||||
hermes_constants._default_hermes_root_memo = None
|
||||
return hermes_home
|
||||
|
||||
|
||||
def _root(home: Path) -> Path:
|
||||
return home / "desktop-plugins"
|
||||
|
||||
|
||||
def test_desktop_only_repo_lands_under_its_own_name_without_a_marker(tmp_path, home):
|
||||
repo = _repo(tmp_path / "gizmo", {"plugin.js": "export default { id: 'gizmo' }\n",
|
||||
"panel.js": "export const x = 1\n"})
|
||||
|
||||
result = install.install_desktop_plugin(repo.as_uri())
|
||||
|
||||
assert result["ok"] is True, result
|
||||
assert result["plugin_name"] == "gizmo"
|
||||
assert result["paired_with_agent"] is False
|
||||
folder = _root(home) / "gizmo"
|
||||
assert (folder / "plugin.js").read_text(encoding="utf-8").startswith("export default")
|
||||
assert (folder / "panel.js").is_file()
|
||||
# A standalone plugin carries no package marker: one would claim another package's agent half.
|
||||
assert not (folder / ".hermes-package.json").exists()
|
||||
|
||||
|
||||
def test_package_lands_its_desktop_half_under_the_agent_name_with_a_marker(tmp_path, home):
|
||||
repo = _repo(tmp_path / "pkg", {"plugin.yaml": "name: pkg\nversion: 1.0.0\n", "__init__.py": "",
|
||||
"desktop/plugin.js": "export default { id: 'pkg' }\n"})
|
||||
|
||||
result = install.install_desktop_plugin(repo.as_uri())
|
||||
|
||||
assert result["ok"] is True, result
|
||||
assert (result["plugin_name"], result["paired_with_agent"]) == ("pkg", True)
|
||||
folder = _root(home) / "pkg"
|
||||
assert (folder / "plugin.js").is_file()
|
||||
# Only the desktop half is copied: the agent payload stays in the repository.
|
||||
assert not (folder / "plugin.yaml").exists()
|
||||
marker = json.loads((folder / ".hermes-package.json").read_text(encoding="utf-8"))
|
||||
assert marker["package"] == "pkg"
|
||||
assert marker["repo"] == repo.as_uri()
|
||||
assert marker["source"] == str(folder)
|
||||
assert marker["sourceMtimeMs"] > 0
|
||||
|
||||
|
||||
def test_a_second_install_refuses_until_force_replaces_it(tmp_path, home):
|
||||
repo = _repo(tmp_path / "gizmo", {"plugin.js": "export default { v: 1 }\n"})
|
||||
assert install.install_desktop_plugin(repo.as_uri())["ok"] is True
|
||||
|
||||
again = install.install_desktop_plugin(repo.as_uri())
|
||||
assert again["ok"] is False
|
||||
assert "already exists" in again["error"] and "force" in again["error"]
|
||||
|
||||
(repo / "plugin.js").write_text("export default { v: 2 }\n", encoding="utf-8")
|
||||
_git(repo, "-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "-qam", "v2")
|
||||
forced = install.install_desktop_plugin(repo.as_uri(), force=True)
|
||||
assert forced["ok"] is True, forced
|
||||
assert (repo / "plugin.js").read_text(encoding="utf-8") in \
|
||||
(_root(home) / "gizmo" / "plugin.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_the_install_is_held_to_the_commit_the_inspection_read(tmp_path, home, monkeypatch):
|
||||
repo = _repo(tmp_path / "gizmo", {"plugin.js": "export default { v: 1 }\n"})
|
||||
reviewed = _git(repo, "rev-parse", "HEAD")
|
||||
(repo / "plugin.js").write_text("export default { v: 2 }\n", encoding="utf-8")
|
||||
_git(repo, "-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "-qam", "moved on")
|
||||
|
||||
from hermes_cli import plugins_cmd_inspect
|
||||
monkeypatch.setattr(plugins_cmd_inspect, "inspect_plugin_source", lambda *a, **k: {
|
||||
"ok": True, "agent": False, "desktop": True, "agent_name": None, "desktop_name": "gizmo",
|
||||
"sha": reviewed, "warnings": [], "insecure": True})
|
||||
|
||||
result = install.install_desktop_plugin(repo.as_uri())
|
||||
|
||||
assert result["ok"] is True, result
|
||||
assert result["sha"] == reviewed
|
||||
assert "v: 1" in (_root(home) / "gizmo" / "plugin.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_a_repository_with_no_desktop_half_is_refused(tmp_path, home):
|
||||
repo = _repo(tmp_path / "agently", {"plugin.yaml": "name: agently\n", "__init__.py": ""})
|
||||
|
||||
result = install.install_desktop_plugin(repo.as_uri())
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "no desktop plugin" in result["error"]
|
||||
assert not (_root(home) / "agently").exists()
|
||||
|
||||
|
||||
def test_a_name_that_is_a_path_is_refused(tmp_path, home, monkeypatch):
|
||||
from hermes_cli import plugins_cmd_inspect
|
||||
monkeypatch.setattr(plugins_cmd_inspect, "inspect_plugin_source", lambda *a, **k: {
|
||||
"ok": True, "agent": False, "desktop": True, "agent_name": None, "desktop_name": "../escape",
|
||||
"sha": None, "warnings": [], "insecure": False})
|
||||
|
||||
result = install.install_desktop_plugin("https://github.com/x/y.git")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "folder name" in result["error"]
|
||||
assert not (home / "escape").exists()
|
||||
|
||||
|
||||
def test_remove_takes_a_standalone_plugin_away(tmp_path, home):
|
||||
repo = _repo(tmp_path / "gizmo", {"plugin.js": "export default {}\n"})
|
||||
assert install.install_desktop_plugin(repo.as_uri())["ok"] is True
|
||||
|
||||
result = install.remove_desktop_plugin("gizmo")
|
||||
|
||||
assert result == {"ok": True, "plugin_name": "gizmo", "path": str(_root(home) / "gizmo"),
|
||||
"unpaired_from": None}
|
||||
assert not (_root(home) / "gizmo").exists()
|
||||
|
||||
|
||||
def test_remove_refuses_a_packages_own_half_until_forced(tmp_path, home):
|
||||
repo = _repo(tmp_path / "pkg", {"plugin.yaml": "name: pkg\n", "__init__.py": "",
|
||||
"desktop/plugin.js": "export default {}\n"})
|
||||
assert install.install_desktop_plugin(repo.as_uri())["ok"] is True
|
||||
|
||||
refused = install.remove_desktop_plugin("pkg")
|
||||
assert refused["ok"] is False
|
||||
assert "uninstall that plugin instead" in refused["error"]
|
||||
assert (_root(home) / "pkg" / "plugin.js").is_file()
|
||||
|
||||
forced = install.remove_desktop_plugin("pkg", force=True)
|
||||
assert forced["ok"] is True
|
||||
assert forced["unpaired_from"] == "pkg"
|
||||
assert not (_root(home) / "pkg").exists()
|
||||
|
||||
|
||||
def test_remove_leaves_a_linked_plugin_alone(tmp_path, home):
|
||||
checkout = _repo(tmp_path / "dev", {"plugin.js": "export default {}\n"})
|
||||
root = _root(home)
|
||||
root.mkdir(parents=True)
|
||||
(root / "dev").symlink_to(checkout, target_is_directory=True)
|
||||
|
||||
result = install.remove_desktop_plugin("dev")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "linked" in result["error"]
|
||||
assert (root / "dev" / "plugin.js").is_file()
|
||||
|
||||
|
||||
def test_remove_of_something_not_installed_says_so(tmp_path, home):
|
||||
result = install.remove_desktop_plugin("ghost")
|
||||
|
||||
assert result["ok"] is False
|
||||
assert "No desktop plugin named 'ghost'" in result["error"]
|
||||
@@ -188,3 +188,44 @@ def test_only_the_webapp_serves_desktop_plugins(monkeypatch, hermes_root, surfac
|
||||
assert client.get("/api/desktop-plugins/root").status_code == 404
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
def _git_plugin_repo(path, files):
|
||||
"""A real repository holding a desktop plugin, as the install dialog's clone would find it."""
|
||||
import subprocess
|
||||
|
||||
path.mkdir()
|
||||
for rel, text in files.items():
|
||||
target = path / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(text, encoding="utf-8")
|
||||
for args in (("init", "-q", "-b", "main"), ("add", "-A"),
|
||||
("-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "-q", "-m", "c")):
|
||||
subprocess.run(["git", *args], cwd=path, check=True, capture_output=True)
|
||||
return path
|
||||
|
||||
|
||||
def test_a_plugin_installed_through_the_backend_is_served_to_the_browser(webapp, hermes_root, tmp_path):
|
||||
# The whole trip the dialog makes: `plugins.manage install_desktop` clones onto this host, then the
|
||||
# browser's loader reads exactly what the Webapp serves.
|
||||
from hermes_cli.desktop_plugin_install import install_desktop_plugin
|
||||
|
||||
repo = _git_plugin_repo(tmp_path / "gizmo", {"plugin.js": "export default { id: 'gizmo' }\n",
|
||||
"panel.js": "export const x = 1\n"})
|
||||
|
||||
result = install_desktop_plugin(repo.as_uri())
|
||||
|
||||
assert result["ok"] is True, result
|
||||
folders = webapp.get("/api/desktop-plugins/root").json()["root"]
|
||||
assert folders == str((hermes_root / "desktop-plugins").resolve())
|
||||
listing = webapp.get("/api/desktop-plugins/list", params={"path": result["path"]}).json()
|
||||
# `.git` comes along, as Electron's own clone leaves it: an install keeps its provenance so a later
|
||||
# update can pull. The routes still refuse to read anything but the entry and the package marker.
|
||||
names = sorted(entry["name"] for entry in listing["entries"])
|
||||
assert [name for name in names if name != ".git"] == ["panel.js", "plugin.js"]
|
||||
assert webapp.get("/api/desktop-plugins/read",
|
||||
params={"path": result["path"] + "/.git/config"}).status_code == 404
|
||||
entry = webapp.get("/api/desktop-plugins/read",
|
||||
params={"path": result["path"] + "/plugin.js"}).json()
|
||||
assert entry["text"].startswith("export default { id: 'gizmo' }")
|
||||
assert entry["truncated"] is False
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Gateway ``plugins.manage install_desktop`` / ``remove_desktop``: the browser-hosted app's install.
|
||||
|
||||
Drives the real RPC dispatch and a real local clone: a desktop plugin lands in the host's
|
||||
``desktop-plugins/`` (where the Webapp's read routes serve it), a second install is refused until
|
||||
``force``, and removal round-trips — all through the same ``plugins.manage`` action the dialog calls.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
from tui_gateway import server
|
||||
|
||||
|
||||
def _manage(**params):
|
||||
return server.handle_request({"id": "1", "method": "plugins.manage", "params": params})
|
||||
|
||||
|
||||
def _repo(path, files):
|
||||
path.mkdir()
|
||||
for rel, text in files.items():
|
||||
target = path / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(text, encoding="utf-8")
|
||||
for args in (("init", "-q", "-b", "main"), ("add", "-A"),
|
||||
("-c", "user.email=t@example.invalid", "-c", "user.name=t", "commit", "-q", "-m", "c")):
|
||||
subprocess.run(["git", *args], cwd=path, check=True, capture_output=True)
|
||||
return path
|
||||
|
||||
|
||||
def test_install_desktop_lands_the_folder_the_webapp_serves(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
repo = _repo(tmp_path / "gizmo", {"plugin.js": "export default { id: 'gizmo' }\n"})
|
||||
|
||||
result = _manage(action="install_desktop", identifier=repo.as_uri())["result"]
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["plugin_name"] == "gizmo"
|
||||
assert result["paired_with_agent"] is False
|
||||
folder = home / "desktop-plugins" / "gizmo"
|
||||
assert (folder / "plugin.js").is_file()
|
||||
assert not (folder / ".hermes-package.json").exists()
|
||||
|
||||
|
||||
def test_a_package_installs_its_desktop_half_under_the_agent_name(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
repo = _repo(tmp_path / "pkg", {"plugin.yaml": "name: pkg\n", "__init__.py": "",
|
||||
"desktop/plugin.js": "export default {}\n"})
|
||||
|
||||
result = _manage(action="install_desktop", identifier=repo.as_uri())["result"]
|
||||
|
||||
assert (result["plugin_name"], result["paired_with_agent"]) == ("pkg", True)
|
||||
assert (home / "desktop-plugins" / "pkg" / ".hermes-package.json").is_file()
|
||||
|
||||
|
||||
def test_a_second_install_is_an_rpc_error_until_force(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
repo = _repo(tmp_path / "gizmo", {"plugin.js": "export default {}\n"})
|
||||
assert _manage(action="install_desktop", identifier=repo.as_uri())["result"]["ok"] is True
|
||||
|
||||
refused = _manage(action="install_desktop", identifier=repo.as_uri())
|
||||
|
||||
assert "error" in refused and refused["error"]["code"] == 5026
|
||||
assert "already exists" in refused["error"]["message"]
|
||||
assert _manage(action="install_desktop", identifier=repo.as_uri(), force=True)["result"]["ok"] is True
|
||||
|
||||
|
||||
def test_remove_desktop_round_trips(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
repo = _repo(tmp_path / "gizmo", {"plugin.js": "export default {}\n"})
|
||||
_manage(action="install_desktop", identifier=repo.as_uri())
|
||||
|
||||
result = _manage(action="remove_desktop", name="gizmo")["result"]
|
||||
|
||||
assert result["ok"] is True
|
||||
assert not (home / "desktop-plugins" / "gizmo").exists()
|
||||
|
||||
|
||||
def test_install_desktop_requires_a_source():
|
||||
response = _manage(action="install_desktop")
|
||||
|
||||
assert response["error"]["code"] == 4019
|
||||
|
||||
|
||||
def test_a_failed_clone_is_an_rpc_error_with_reason(tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
|
||||
response = _manage(action="install_desktop", identifier=(tmp_path / "missing").as_uri())
|
||||
|
||||
assert response["error"]["code"] == 5026
|
||||
assert response["error"]["message"]
|
||||
@@ -617,6 +617,8 @@ class PluginsAction(WireEnum):
|
||||
toggle = "toggle"
|
||||
install = "install"
|
||||
inspect = "inspect"
|
||||
install_desktop = "install_desktop"
|
||||
remove_desktop = "remove_desktop"
|
||||
update = "update"
|
||||
remove = "remove"
|
||||
settings = "settings"
|
||||
@@ -626,7 +628,10 @@ class PluginsAction(WireEnum):
|
||||
|
||||
class PluginsManageParams(ProfileParams):
|
||||
"""``toggle``: ``key``/``name`` + ``enable``; ``install``: ``identifier``/``repo`` or ``catalog_name``
|
||||
(+ ``force``, ``enable``, ``ref``); ``inspect``: ``identifier``/``repo`` or ``catalog_name``; ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin
|
||||
(+ ``force``, ``enable``, ``ref``); ``inspect``: ``identifier``/``repo`` or ``catalog_name``;
|
||||
``install_desktop``: ``identifier``/``repo`` or ``catalog_name`` (+ ``force``) — the desktop half onto
|
||||
the host that serves this client, held to the commit ``inspect`` read; ``remove_desktop``: ``name``
|
||||
(+ ``force`` for a package's own half); ``update``: ``name`` (+ ``accept_capabilities`` to apply a re-pin
|
||||
that widened the plugin after the user confirmed the ``delta``, + ``force`` to apply an update whose
|
||||
security scan returned caution after the user saw the findings); ``remove``: ``name`` (user installs only);
|
||||
``settings``: ``key`` + ``values`` (``{setting_key: value}``, non-secret schema keys only);
|
||||
@@ -859,6 +864,13 @@ class PluginsManageResult(Result):
|
||||
agent_name: str | None = None
|
||||
desktop_name: str | None = None
|
||||
insecure: bool | None = None
|
||||
# ``install_desktop`` → the folder it published on the host that serves this client (``plugin_name``,
|
||||
# ``path``, the cloned ``sha``) and whether that folder is the desktop half of a package
|
||||
# (``paired_with_agent``); ``remove_desktop`` → the same ``plugin_name``/``path`` gone, with
|
||||
# ``unpaired_from`` naming the package whose half it was.
|
||||
path: str | None = None
|
||||
paired_with_agent: bool | None = None
|
||||
unpaired_from: str | None = None
|
||||
|
||||
|
||||
method("plugins.manage", params=PluginsManageParams, result=PluginsManageResult,
|
||||
|
||||
@@ -1781,6 +1781,31 @@ def _plugins_inspect(rid, params):
|
||||
return _ok(rid, inspect(ident, catalog_name=catalog_name or None))
|
||||
|
||||
|
||||
def _plugins_install_desktop(rid, params):
|
||||
"""Install a repository's desktop half onto this host (``desktop-plugins/<name>``) — what Electron's
|
||||
``installDesktopPlugin`` does, for a browser-hosted client with no disk of its own. Held to the
|
||||
commit the inspection read; the folder lands optionally enabled because the loader picks it up on
|
||||
its next scan."""
|
||||
ident = (params.get("identifier") or params.get("repo") or "").strip()
|
||||
catalog_name = str(params.get("catalog_name") or "").strip()
|
||||
if not ident and not catalog_name:
|
||||
return _err(rid, 4019, "plugins.manage install_desktop requires 'identifier', 'repo', or 'catalog_name'")
|
||||
result = _tools_mod("hermes_cli.desktop_plugin_install").install_desktop_plugin(
|
||||
ident, catalog_name=catalog_name or None, force=bool(params.get("force")))
|
||||
return _ok(rid, result) if result.get("ok") else _err(rid, 5026, result.get("error") or "desktop install failed")
|
||||
|
||||
|
||||
def _plugins_remove_desktop(rid, params):
|
||||
"""Delete one installed desktop plugin folder from this host. The desktop half of a package is
|
||||
refused: that package's own removal takes it away."""
|
||||
name = (params.get("name") or params.get("key") or "").strip()
|
||||
if not name:
|
||||
return _err(rid, 4019, "plugins.manage remove_desktop requires a 'name'")
|
||||
result = _tools_mod("hermes_cli.desktop_plugin_install").remove_desktop_plugin(
|
||||
name, force=bool(params.get("force")))
|
||||
return _ok(rid, result) if result.get("ok") else _err(rid, 5026, result.get("error") or "desktop remove failed")
|
||||
|
||||
|
||||
def _plugins_update(rid, params):
|
||||
"""The same update as the dashboard and ``hermes plugins update``: a catalog install re-pins to the
|
||||
current catalog SHA, a git install pulls. Every update is scanned. A refusal changes nothing and
|
||||
@@ -1870,6 +1895,7 @@ def _plugins_presence(rid, params):
|
||||
|
||||
_PLUGINS_ACTIONS = {"list": _plugins_list, "onboarding": _plugins_onboarding, "presence": _plugins_presence,
|
||||
"toggle": _plugins_toggle, "install": _plugins_install, "inspect": _plugins_inspect,
|
||||
"install_desktop": _plugins_install_desktop, "remove_desktop": _plugins_remove_desktop,
|
||||
"update": _plugins_update, "remove": _plugins_remove, "settings": _plugins_settings}
|
||||
|
||||
|
||||
|
||||
@@ -720,7 +720,13 @@ the tab is on. A new or removed plugin folder shows up within seconds; an edited
|
||||
**⌘K → Reload desktop plugins**, since a browser cannot watch the host's files. The desktop half of
|
||||
an agent+desktop package is copied into the folder by the host itself, so it also appears on a
|
||||
headless host that native Desktop never runs on.
|
||||
Install and remove desktop plugins from native Desktop or in the folder itself; a plugin's folder
|
||||
|
||||
**Install** works from the Webapp too: **Capabilities → Plugins → Install** inspects the repository
|
||||
first, then has the host that serves the tab clone the plugin into that folder (a desktop-only
|
||||
repository, or the desktop half of a package) — the same install native Desktop makes in Electron, and
|
||||
the same folder name and package marker. A plugin installed this way loads on the next scan; nothing
|
||||
else has to be done on the server by hand. Removing one from **Capabilities → Plugins** deletes the
|
||||
folder on that host; the desktop half of a package is removed with the package. A plugin's folder
|
||||
access (`ctx.files`) is not available in a browser.
|
||||
|
||||
**Capabilities → Plugins** is the one place for everything that extends
|
||||
|
||||
Reference in new issue
Block a user