The desktop plugin installer asked only is_symlink() about the folder it
publishes to and the folder it removes. A junction answers False to that,
and a dangling one answers False to exists() too, so install replaced a
dangling junction without force, and remove skipped the "linked from
somewhere else" refusal and fell through to a generic rmtree error.
Both checks now use the reconcile module's _is_link (is_symlink() or
pm.filesystem.is_junction), the predicate the host pass already uses.
install_desktop and remove_desktop now refuse folder names that Windows reads
as something other than one folder: any colon (C:, C:.., D:outside), device
names such as CON, nul.txt, COM1 or CONIN$ with any extension, and a trailing
dot.
Root cause: the name check only refused slashes, dots and control characters,
so on Windows root / "C:" joined to the root itself and root / "C:.." to the
Hermes home, and a forced remove_desktop would then delete that whole tree.
Bug in new code from abfe2876f3 (#146178, salvaged from #146132, 2026-10-28);
not a regression, the RPCs did not exist before.
Private plugins live behind ssh keys or in local folders. The Webapp's
host-side desktop install accepted https only; it now takes git@/ssh://,
file:// and http:// like the agent installer and native Desktop, per the
maintainer ruling on #146189.
Two follow-ups to the install dialog holding an install to the commit it
inspected.
- An install is resent without `inspected_sha` only for an older backend's
admission refusal: a JsonRpcGatewayError with code 4000 whose message
opens with `validate_params`' prefix. Matching the text anywhere also
matched a 5026 install failure that forwards a git server's clone error,
so that server could get the reviewed commit dropped.
- A refusal because the source moved past the reviewed commit now carries a
structured marker: the backend raises PluginSourceMoved and
`plugins.manage` answers 5026 with `data.reason == "source_moved"` (agent
install and `install_desktop`); Electron's desktop install returns
`sourceMoved: true` when the reviewed commit cannot be checked out. The
dialog answers it by dropping its inspection, reading the source again
and naming the new commit; Install waits for it. Retry used to resend the
old commit and fail the same way every time. A pin the user typed is not
re-reviewed, and nothing from the stale review installs (the desktop half
is skipped when the agent half was refused).
A mixed package with plugin.yaml + __init__.py and a ROOT plugin.js is
reported by the install probe as carrying both halves, so a browser tab
(and native Desktop on a local backend) installs the agent half alone and
leaves the desktop half to the reconcile pass. Both reconcilers (the host's
desktop_plugin_halves.py and Electron's desktop-plugins-root.ts) only read
desktop/plugin.js, so nothing was copied while the dialog still said
"Desktop plugin <name> installed".
- One entry rule for every reader: desktop_source / desktopHalfSource (root
plugin.js wins, else desktop/plugin.js), used by both reconcilers, the
Webapp installer, findDesktopEntry, plugins.manage list's has_desktop_half
and the plugin doctor's stale-copy check.
- A root-layout half publishes the package root as the installers do,
without .git and __pycache__, held to the installer's plain-tree rule
(no links, 64 MB cap); a refused tree is logged and not copied. The
marker's source is the package root, so the prune follows it.
- The install dialog reports the desktop half only when the reconcile
copied it or <root>/<name>/plugin.js is already there; an agent install
that left no desktop half reports "Desktop plugin install failed".
Review follow-ups on the Webapp desktop-plugin install:
- A package with an agent half was cloned twice from a browser tab: the agent
install, then a second install_desktop clone into the folder the host's
5-second scan also copies that package's desktop half into, so the clone
could answer "already exists" or the two copies could be different commits.
A tab's backend is the host that serves it, so the package lands where the
tab's plugins load from: the desktop half now comes out of that one checkout
through the host's reconcile, as native Desktop does with a local backend.
install_desktop stays for desktop-only repositories.
- A catalog pin naming an annotated tag never matched the commit inspect
reports. The clone now fetches the catalog's own pin (as the agent installer
does) and requires HEAD to be the commit the dialog reviewed.
- The published folder no longer carries the clone's .git (nothing reads it),
and removal / force replacement use rmtree_readonly, so a folder holding Git's
read-only objects (a native or hand install) can be deleted on Windows.
Hardening on top of the contributor's Webapp install path (salvage #146132). A browser tab asks the
serving host to clone code onto it, so the host now decides what lands, not the tab:
- `ref` is required: the 40-hex commit the dialog showed (or the user's pin). The clone is checked
out at exactly that commit and the halves are read from that checkout, instead of re-using a
ten-minute inspection cache and installing whatever it named; a catalog install whose pin moved is
refused. `forget_inspection` goes away with the cache dependency.
- https only: `file://` would copy any folder of the host, `ssh`/`git@` spends the host's keys,
`http://` can be rewritten in transit. Native Desktop keeps those for a user at their own machine.
- The published tree may hold no symbolic link (a `desktop -> /anywhere` link in a repository
published that host folder) and is capped at 64 MB.
- A standalone plugin's in-tree `.hermes-package.json` is dropped as Electron's installer drops it:
only an installer writes the marker that pairs a folder with an agent package.
- `desktop_plugin_halves`: the helpers the installer shares become public names instead of
wrapper functions around private ones.
- Renderer sends `ref: plan.ref ?? plan.probe.sha`.
- Tests: the contributor's installer/RPC/route cases now run through an https identifier at a real
commit (only the clone's network leg points at the local repository); RPC cases folded into one
round trip; two added invariants (https only, link escape), each proven red with its guard removed.
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.