mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user