feat(webapp): copy agent+desktop packages' desktop halves on the serving host

The desktop half of a unified package only reached desktop-plugins/ when
native Desktop copied it there (reconcileUnifiedDesktopHalves in Electron).
A host that Desktop only reaches over SSH never ran that copy, so the
Webapp never showed those halves.

hermes_cli/desktop_plugin_halves.py ports the reconcile with Electron's
on-disk contract: the same marker fields, first home wins, adoption of a
byte-equal marker-less copy, a byte compare for linked checkouts, staged
publication, and a prune only when the source is really gone. The Webapp
runs it where Electron does: when the loader asks for the root, and on
demand through POST /api/desktop-plugins/reconcile, which the browser
bridge's reconcileDesktopPlugins now calls after an install.

Closes #145540
This commit is contained in:
Adolanium authored and Teknium committed 2026-10-27 21:25:51 -07:00
1 parent dc39239614
commit f9e923f190
5 files changed
+327 -5

No files matched your search

@@ -2,7 +2,7 @@ import type { HermesApiRequest, HermesReadFileTextResult } from '@/global'
type DesktopPluginsBridge = Pick<
Window['hermesDesktop'],
'desktopPluginsRoot' | 'readDir' | 'readFileText' | 'readPluginSource'
'desktopPluginsRoot' | 'readDir' | 'readFileText' | 'readPluginSource' | 'reconcileDesktopPlugins'
>
interface BrowserDesktopPluginsOptions {
@@ -35,6 +35,9 @@ export function createBrowserDesktopPluginsBridge({ api, files }: BrowserDesktop
},
readDir: path => (inside(path) ? get('list', path) : files.readDir(path)),
readFileText: path => (inside(path) ? readFile(path) : files.readFileText(path)),
readPluginSource: readFile
readPluginSource: readFile,
// The server copies agent+desktop packages' desktop halves into the folder, as Electron does.
reconcileDesktopPlugins: async () =>
(await api<{ touched: string[] }>({ method: 'POST', path: '/api/desktop-plugins/reconcile' })).touched
}
}
+201
View File
@@ -0,0 +1,201 @@
"""Copy each unified agent+desktop package's ``desktop/`` half into the app-level desktop-plugins folder.
A unified package keeps its agent half in ``<home>/plugins/<name>/`` (the default home and every
profile). Its desktop half is loaded from ``<root>/desktop-plugins/<name>/``, with a
``.hermes-package.json`` marker that pairs it back to the agent row. Native Desktop does this copy
in Electron (``reconcileUnifiedDesktopHalves`` in ``apps/desktop/electron/desktop-plugins-root.ts``),
so a host that Desktop only reaches over SSH never gets it. The Webapp serves that host's folder,
so its routes (``web_routers/desktop_plugins.py``) run this pass at the same points Electron does:
whenever the loader asks for the root, and on demand after an install.
Both writers share the folder on a host that runs native Desktop too, so this keeps Electron's
on-disk contract exactly: the marker's fields, the first-home-wins order, adoption of a byte-equal
marker-less copy, a byte compare for linked dev checkouts, staged publication, and a prune that
only fires when the source is really gone.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import stat
import tempfile
import threading
from pathlib import Path
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()
def _list_dirs(folder: Path, *, follow_links: bool = False) -> list[str]:
"""Folder names in *folder*. A package root follows links to folders (``ln -s ~/src/pkg
~/.hermes/plugins/`` is how a package is developed in place), the app root does not."""
try:
entries = list(os.scandir(folder))
except OSError:
return []
names = []
for entry in entries:
try:
if entry.is_dir(follow_symlinks=False) or (follow_links and entry.is_symlink() and entry.is_dir()):
names.append(entry.name)
except OSError:
continue
return names
def _package_origin(package_dir: Path) -> dict:
"""Where the package came from (catalog sidecar first, then the git remote), for "Install here"."""
try:
sidecar = json.loads((package_dir / ".hermes-catalog.json").read_text(encoding="utf-8-sig"))
if sidecar.get("repo"):
origin = {"catalogName": sidecar.get("catalog_name"), "repo": sidecar["repo"], "sha": sidecar.get("sha")}
return {key: value for key, value in origin.items() if value is not None}
except (OSError, ValueError, AttributeError):
pass
try:
match = _GIT_ORIGIN.search((package_dir / ".git" / "config").read_text(encoding="utf-8-sig"))
except OSError:
return {}
return {"repo": match.group(1)} if match else {}
def _read_marker(folder: Path) -> dict | None:
try:
marker = json.loads((folder / PACKAGE_MARKER).read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return None
return marker if isinstance(marker, dict) and marker.get("package") and marker.get("source") else None
def _write_marker(folder: Path, marker: dict) -> None:
(folder / PACKAGE_MARKER).write_text(json.dumps(marker, indent=2) + "\n", encoding="utf-8")
def _same_file(a: Path, b: Path) -> bool:
"""Same bytes on both paths. A comparison that cannot be made is never a match."""
try:
return a.read_bytes() == b.read_bytes()
except OSError:
return False
def _publish(source_dir: Path, target: Path, marker: dict) -> 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."""
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 target.is_symlink():
target.unlink()
elif target.exists():
shutil.rmtree(target)
os.rename(staged, target)
finally:
shutil.rmtree(staging, ignore_errors=True)
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."""
source_dir = package_dir / "desktop"
entry = source_dir / "plugin.js"
try:
entry_stat = entry.stat()
except (FileNotFoundError, NotADirectoryError):
return None
except OSError as exc:
logger.warning("desktop-plugins: cannot read %s: %s", name, exc)
return None
if not stat.S_ISREG(entry_stat.st_mode):
return None
target = app_root / name
existing = _read_marker(target)
linked = package_dir.is_symlink()
marker = {
"package": name,
"source": str(source_dir),
"sourceMtimeMs": entry_stat.st_mtime_ns / 1_000_000,
**({"linked": True} if linked else {}),
**_package_origin(package_dir),
}
if target.exists():
if existing is None and (target / "plugin.js").exists():
# Our own pre-marker copy still matches byte for byte: adopt it. Anything else is a
# standalone plugin the user put there, and stays exactly as it is (#112450).
if _same_file(target / "plugin.js", entry):
_write_marker(target, marker)
return target
return None
if (
existing is not None
and existing["source"] == str(source_dir)
and bool(existing.get("linked")) == linked
and (
_same_file(target / "plugin.js", entry)
if linked
else existing.get("sourceMtimeMs", 0) >= marker["sourceMtimeMs"]
)
):
return None
_publish(source_dir, target, marker)
return target
def _source_gone(name: str, entry: Path) -> bool:
"""True only when the source ``plugin.js`` is really gone. A source we may not stat is not an
uninstall: pruning its copy would silently drop the pane."""
try:
entry.stat()
except (FileNotFoundError, NotADirectoryError):
return True
except OSError as exc:
logger.warning("desktop-plugins: keeping desktop half of unreadable package %s: %s", name, exc)
return False
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:
touched: list[str] = []
seen: set[str] = set()
homes = [hermes_root, *(hermes_root / "profiles" / name for name in _list_dirs(hermes_root / "profiles"))]
for home in homes:
plugins_root = home / "plugins"
for name in _list_dirs(plugins_root, follow_links=True):
if name in seen:
continue
try:
result = materialize_desktop_half(plugins_root / name, app_root, name)
except OSError as exc:
# One unreadable package must not stop every other half from loading.
logger.warning("desktop-plugins: skipping unreadable package %s: %s", name, exc)
continue
if result is not None or (plugins_root / name / "desktop" / "plugin.js").exists():
seen.add(name)
if result is not None:
touched.append(str(result))
for name in _list_dirs(app_root):
folder = app_root / name
marker = _read_marker(folder)
if marker and _source_gone(name, Path(marker["source"]) / "plugin.js"):
shutil.rmtree(folder, ignore_errors=True)
touched.append(str(folder))
return touched
+20 -2
View File
@@ -8,7 +8,9 @@ The routes answer only what the loader reads: the root, its plugin folders, and
``plugin.js`` and ``.hermes-package.json``. Containment is checked on the path as written, then
symlinks are followed as Electron's ``readDir`` does, so ``desktop-plugins/foo -> ~/src/foo`` loads
here too. They never go through ``/api/fs``, which follows the profile's SSH workspace and truncates
at the preview cap. Webapp only (``SurfacePolicy.desktop_plugins``; 404 elsewhere).
at the preview cap. Like Electron, resolving the root first copies agent+desktop packages' desktop
halves into it (``desktop_plugin_halves.py``), the one write these routes make. Webapp only
(``SurfacePolicy.desktop_plugins``; 404 elsewhere).
"""
from __future__ import annotations
@@ -20,6 +22,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from hermes_cli.desktop_plugin_halves import reconcile_desktop_halves
from hermes_constants import get_default_hermes_root
router = APIRouter()
@@ -102,9 +105,24 @@ def _read(raw: str) -> dict:
return {"byteSize": st.st_size, "path": str(target), "text": text, "truncated": False}
def _root_reconciled() -> str:
"""The root, after copying unified packages' desktop halves into it: what Electron's
``desktopPluginsRoot`` does before every scan. On a host native Desktop only reaches over SSH,
nothing else ever makes that copy."""
root = desktop_plugins_root()
reconcile_desktop_halves(get_default_hermes_root(), root)
return str(root)
@router.get("/api/desktop-plugins/root")
async def desktop_plugins_root_route() -> dict:
return {"root": str(await asyncio.to_thread(desktop_plugins_root))}
return {"root": await asyncio.to_thread(_root_reconciled)}
@router.post("/api/desktop-plugins/reconcile")
async def reconcile_desktop_plugins() -> dict:
"""Electron's ``reconcileDesktopPlugins``: re-run the copy after an install, update or removal."""
return {"touched": await asyncio.to_thread(reconcile_desktop_halves, get_default_hermes_root(), desktop_plugins_root())}
@router.get("/api/desktop-plugins/list")
@@ -0,0 +1,99 @@
"""On a headless host the Webapp itself copies agent+desktop packages' desktop halves into
``<root>/desktop-plugins``, the copy native Desktop makes in Electron
(``reconcileUnifiedDesktopHalves``) and nothing else makes when Desktop never runs there.
"""
import json
import os
import pytest
from starlette.testclient import TestClient
from hermes_cli import web_server
@pytest.fixture
def hermes_root(monkeypatch, tmp_path):
root = tmp_path / "hermes"
(root / "profiles" / "work").mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "work"))
return root
@pytest.fixture
def webapp(monkeypatch, hermes_root):
for key, value in (("auth_required", False), ("bound_host", None), ("ui_surface", "webapp")):
monkeypatch.setattr(web_server.app.state, key, value, raising=False)
client = TestClient(web_server.app)
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
yield client
client.close()
def _package(home, name, source):
desktop = home / "plugins" / name / "desktop"
desktop.mkdir(parents=True)
(desktop / "plugin.js").write_text(source, encoding="utf-8")
return desktop
def _loaded(webapp):
"""What the browser's loader sees: each folder's plugin.js text and package marker."""
root = webapp.get("/api/desktop-plugins/root").json()["root"]
seen = {}
for folder in webapp.get("/api/desktop-plugins/list", params={"path": root}).json()["entries"]:
files = {e["name"]: e["path"] for e in webapp.get("/api/desktop-plugins/list", params={"path": folder["path"]}).json()["entries"]}
read = lambda name: webapp.get("/api/desktop-plugins/read", params={"path": files[name]}).json()["text"]
marker = json.loads(read(".hermes-package.json")) if ".hermes-package.json" in files else None
seen[folder["name"]] = (read("plugin.js"), marker)
return seen
def test_a_package_installed_on_a_headless_host_brings_its_desktop_half(webapp, hermes_root):
source = _package(hermes_root / "profiles" / "work", "radar", "export default { id: 'radar' }")
text, marker = _loaded(webapp)["radar"]
assert text == "export default { id: 'radar' }"
# The marker is what pairs the half with its agent row and keeps it off until enabled.
assert (marker["package"], marker["source"]) == ("radar", str(source))
def test_the_copy_follows_its_package_until_it_is_uninstalled(webapp, hermes_root):
source = _package(hermes_root, "radar", "export default { v: 1 }")
assert _loaded(webapp)["radar"][0] == "export default { v: 1 }"
(source / "plugin.js").write_text("export default { v: 2 }", encoding="utf-8")
later = os.stat(source / "plugin.js").st_mtime + 5
os.utime(source / "plugin.js", (later, later))
touched = webapp.post("/api/desktop-plugins/reconcile").json()["touched"]
assert touched == [str((hermes_root / "desktop-plugins" / "radar").resolve())]
assert _loaded(webapp)["radar"][0] == "export default { v: 2 }"
(source / "plugin.js").unlink()
webapp.post("/api/desktop-plugins/reconcile")
assert _loaded(webapp) == {}
def test_a_users_own_plugin_is_never_overwritten_and_the_default_home_wins(webapp, hermes_root):
mine = hermes_root / "desktop-plugins" / "notes"
mine.mkdir(parents=True)
(mine / "plugin.js").write_text("mine", encoding="utf-8")
_package(hermes_root, "notes", "a package that happens to share the name")
_package(hermes_root, "radar", "from the default home")
_package(hermes_root / "profiles" / "work", "radar", "from a profile")
loaded = _loaded(webapp)
assert loaded["notes"] == ("mine", None)
assert loaded["radar"][0] == "from the default home"
def test_a_copy_made_before_markers_existed_is_adopted_in_place(webapp, hermes_root):
_package(hermes_root, "radar", "export default { id: 'radar' }")
old_copy = hermes_root / "desktop-plugins" / "radar"
old_copy.mkdir(parents=True)
(old_copy / "plugin.js").write_text("export default { id: 'radar' }", encoding="utf-8")
_, marker = _loaded(webapp)["radar"]
assert marker["package"] == "radar"
+2 -1
View File
@@ -718,7 +718,8 @@ The [Webapp](#use-the-desktop-workspace-from-a-browser) loads the desktop plugin
host that serves it, from the `desktop-plugins` folder of that host's Hermes root, whichever profile
the tab is on. A new or removed plugin folder shows up within seconds; an edited `plugin.js` loads on
**⌘K → Reload desktop plugins**, since a browser cannot watch the host's files. The desktop half of
an agent+desktop package appears once native Desktop on that host has copied it into the folder.
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
access (`ctx.files`) is not available in a browser.