8 Commits
Author SHA1 Message Date
kshitijk4poor 016b76da3a fix(desktop-plugins): install and remove treat a Windows junction as a link
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.
2026-11-01 07:28:44 +05:30
kshitijk4poor 792ebd2df4 fix(plugins): desktop plugin names cannot reach outside the plugins folder on Windows
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.
2026-10-31 14:32:46 +05:30
Teknium 01dfee9a41 fix(webapp): a Webapp tab installs a desktop plugin from any source the CLI takes
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.
2026-10-28 19:53:28 -07:00
Teknium e4b0ab2365 fix(plugins): retry only a real admission refusal; re-review a moved source
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).
2026-10-28 17:21:32 -07:00
Teknium ca8e43aea7 fix(desktop-plugins): copy root-layout desktop halves; no false desktop success
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".
2026-10-28 17:21:32 -07:00
Teknium c95dfcb3ae fix(webapp): a tab installs a mixed package once; tag pins and read-only trees install
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.
2026-10-28 17:21:32 -07:00
Teknium 8c5895cec1 fix(webapp): a tab's desktop-plugin install takes only the reviewed https commit
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.
2026-10-28 17:21:32 -07:00
Decrux abfe2876f3 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.
2026-10-28 17:21:32 -07:00