feat(nix): ship the Webapp in the Nix package

The Nix package had no Webapp bundle, and `hermes webapp` could not build
one in the read-only store. nix/webapp.nix builds it in the npm workspace
the hook prepares (no native rebuild, no Electron), exposed as
`.#webapp`. The agent assembler takes it as a `webapp` frontend: the
wrapper sets HERMES_WEBAPP_DIST to it, as HERMES_WEB_DIST is set for the
dashboard.

hermes_cli/webapp.py serves a HERMES_WEBAPP_DIST bundle as shipped: no
freshness check, no build lock, never rebuilt or marked stale, and
`--force-build` says it cannot rebuild it.
This commit is contained in:
unsupportedpastels authored and Mark S. committed 2026-10-27 09:42:22 -04:00
1 parent 51c8cba024
commit 3f2b1722b1
12 files changed
+139 -10

No files matched your search

+22 -3
View File
@@ -10,6 +10,7 @@ receipt inside ``dist-webapp`` decides freshness.
from __future__ import annotations
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
@@ -21,21 +22,31 @@ _LOCK_WAIT_SECONDS = 30 * 60
# replaces the whole folder, and the receipt's output hash covers this file, so a marked bundle is
# never reused as current.
STALE_MARKER = ".hermes-stale"
# A packaged bundle (the Nix package), like HERMES_WEB_DIST for the dashboard: the packager
# builds it, so Hermes serves it as it is and never builds or marks it.
DIST_ENV = "HERMES_WEBAPP_DIST"
class WebappBuildError(RuntimeError):
"""The browser-hosted Desktop renderer could not be prepared."""
def packaged_webapp_dist() -> Path | None:
"""The bundle :data:`DIST_ENV` names, or None when Hermes builds its own."""
value = os.environ.get(DIST_ENV)
return Path(value).expanduser() if value else None
def webapp_dist_dir(project_root: Path) -> Path:
return project_root / "apps" / "desktop" / "dist-webapp"
return packaged_webapp_dist() or project_root / "apps" / "desktop" / "dist-webapp"
def mark_webapp_stale(project_root: Path) -> bool:
"""Mark the built bundle in *project_root* out of date, after a rebuild that failed. False when
no bundle is built: nothing serves one, and the build output folder stays the builder's."""
no bundle is built: nothing serves one, and the build output folder stays the builder's. Also
False for a packaged bundle, which is the packager's."""
dist = webapp_dist_dir(project_root)
if not (dist / "index.html").is_file():
if packaged_webapp_dist() is not None or not (dist / "index.html").is_file():
return False
(dist / STALE_MARKER).write_text("hermes update could not rebuild this bundle\n", encoding="utf-8")
return True
@@ -94,6 +105,14 @@ def prepare_webapp_renderer(
project_root = project_root.resolve()
dist = webapp_dist_dir(project_root)
if packaged_webapp_dist() is not None:
# The packager built it into a read-only store; there is nothing here to rebuild.
if not (dist / "index.html").is_file():
raise WebappBuildError(f"{DIST_ENV} is set but no Webapp renderer exists at {dist}")
if force:
raise WebappBuildError(f"The Webapp renderer at {dist} comes from {DIST_ENV} and cannot be rebuilt here")
print(f"→ Using Hermes Webapp renderer from {DIST_ENV}: {dist}")
return dist
if skip_build:
# Reuse takes no build lock, like `hermes dashboard --skip-build`: the
# lock lives in the install root, which a read-only install cannot write.
+5 -3
View File
@@ -352,7 +352,7 @@ def _explain_refused_launch(target: Path, response: dict) -> int:
def cmd_webapp(args) -> int:
from gateway.webapp_listener import webapp_settings
from hermes_cli.webapp import WebappBuildError
from hermes_cli.webapp import WebappBuildError, packaged_webapp_dist
from hermes_constants import get_hermes_home
home = get_hermes_home()
@@ -371,8 +371,10 @@ def cmd_webapp(args) -> int:
_build(args)
except WebappBuildError as exc:
print(f"✗ {exc}")
print(" Build it explicitly:")
print(" hermes webapp --build-only")
# A packaged bundle (HERMES_WEBAPP_DIST) is never built here, so no build can fix it.
if packaged_webapp_dist() is None:
print(" Build it explicitly:")
print(" hermes webapp --build-only")
return 1
if args.build_only:
print("Webapp bundle ready.")
+7
View File
@@ -128,6 +128,11 @@ let
inherit hermesNpmLib generatedIcons;
};
# The store is read-only, so the Webapp is built here and never by `hermes webapp`.
hermesWebapp = callPackage ./webapp.nix {
inherit hermesNpmLib;
};
bundledSkills = lib.cleanSourceWith {
src = ../skills;
filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
@@ -225,6 +230,7 @@ let
frontends = {
tui = "${hermesTui}/lib/hermes-tui";
web = toString hermesWeb;
webapp = toString hermesWebapp;
};
ref = if dirty then null else rev;
stamp = toString installStampFile;
@@ -387,6 +393,7 @@ stdenv.mkDerivation (finalAttrs: {
inherit
hermesTui
hermesWeb
hermesWebapp
hermesNpmLib
hermesVenv
agentBuilderSrc
+1 -1
View File
@@ -331,7 +331,7 @@ in
# importNpmLock reads hashes from the lockfile itself — rebuild every
# npm package to verify the new lockfile resolves offline.
nix build .#tui .#web .#desktop
nix build .#tui .#web .#webapp .#desktop
echo "Lockfile updated and all npm packages built."
'';
+1
View File
@@ -98,6 +98,7 @@
tui = full.hermesTui;
web = full.hermesWeb;
webapp = full.hermesWebapp;
desktop = full.hermesDesktop;
update-npm-lockfile = full.hermesNpmLib.updateNpmLockfile;
+1 -1
View File
@@ -20,7 +20,7 @@ for name, source in inputs["resources"].items():
resource = repo / name
assert resource.is_symlink(), resource
assert resource.samefile(source), (resource, source)
for name, key in (("tui", "HERMES_TUI_DIR"), ("web", "HERMES_WEB_DIST")):
for name, key in (("tui", "HERMES_TUI_DIR"), ("web", "HERMES_WEB_DIST"), ("webapp", "HERMES_WEBAPP_DIST")):
resource = Path(command_map["env"][key])
assert resource.is_symlink(), resource
assert resource.samefile(inputs["frontends"][name])
+37
View File
@@ -0,0 +1,37 @@
# The Webapp: the Desktop renderer built for a browser. The npm hook has
# already installed the locked workspace, so it compiles there, with no
# private workspace and no Electron.
{ hermesNpmLib, ... }:
hermesNpmLib.buildNpmPackage {
dirs = [
"apps/desktop"
"apps/shared"
"scripts/build/webapp.mjs"
"scripts/build/node-deps.mjs"
"scripts/build/freshness.mjs"
"scripts/build/frontend-common.mjs"
];
pname = "hermes-webapp";
# The browser bundle loads no native module (node-pty belongs to Desktop),
# so none is compiled.
npmRebuildFlags = [ "--ignore-scripts" ];
doCheck = false;
buildPhase = ''
runHook preBuild
node --input-type=module -e '
import { buildWebapp } from "./scripts/build/webapp.mjs"
await buildWebapp({ source: process.cwd(), workspace: process.cwd(), out: `''${process.env.TMPDIR}/webapp-product` })
'
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r "$TMPDIR/webapp-product/." $out/
runHook postInstall
'';
}
+4 -1
View File
@@ -38,6 +38,8 @@ def plant_surfaces(repo: Path, frontends: dict[str, Path]) -> None:
_copy(tui / "package.json", destination / "package.json")
if "web" in frontends:
_copy(frontends["web"], repo / "hermes_cli/web_dist", replace=True)
if "webapp" in frontends:
_copy(frontends["webapp"], repo / "apps/desktop/dist-webapp", replace=True)
def write_metadata(project_path: Path, repo: Path) -> None:
@@ -122,7 +124,8 @@ def assemble(inputs: AgentInputs, out: Path) -> dict:
links = {RESOURCE_ENV[name]: (path, repo / name) for name, path in inputs.resources.items()}
for name, path in inputs.frontends.items():
key, destination = {"tui": ("HERMES_TUI_DIR", out / "ui-tui"),
"web": ("HERMES_WEB_DIST", repo / "web_dist")}[name]
"web": ("HERMES_WEB_DIST", repo / "web_dist"),
"webapp": ("HERMES_WEBAPP_DIST", repo / "webapp_dist")}[name]
links[key] = (path, destination)
for key, (source, destination) in links.items():
destination.unlink(missing_ok=True)
+1 -1
View File
@@ -80,7 +80,7 @@ class AgentInputs:
raise ValueError("bin_dir must be one output-relative directory")
if self.resources.keys() - RESOURCE_ENV.keys():
raise ValueError("unknown resource names")
if self.frontends.keys() - {"tui", "web"}:
if self.frontends.keys() - {"tui", "web", "webapp"}:
raise ValueError("unknown frontend names")
paths = [self.project, self.code, self.python, self.site_packages, self.environment,
self.pm_runtime, *self.resources.values(), *self.frontends.values()]
@@ -76,6 +76,32 @@ def test_a_current_bundle_is_reused_without_writing_the_install_tree(tmp_path: P
assert sorted(tmp_path.rglob("*")) == install_tree
def test_a_packaged_bundle_is_served_as_shipped(tmp_path: Path, monkeypatch):
"""The Nix package points HERMES_WEBAPP_DIST at a bundle in the read-only store: it is used
without a freshness check or the build lock, never rebuilt and never marked stale."""
from hermes_cli import source_build
_workspace_tree(tmp_path)
packaged = tmp_path / "store" / "webapp"
packaged.mkdir(parents=True)
(packaged / "index.html").write_text("<!doctype html>", encoding="utf-8")
monkeypatch.setenv(webapp.DIST_ENV, str(packaged))
monkeypatch.setattr(source_build, "source_product_current", lambda *_a: pytest.fail("no freshness check"))
monkeypatch.setattr(source_build, "build_source_webapp", lambda *_a, **_k: pytest.fail("no build"))
assert webapp.webapp_dist_dir(tmp_path) == packaged
install_tree = sorted(tmp_path.rglob("*"))
assert webapp.prepare_webapp_renderer(tmp_path) == packaged
assert webapp.mark_webapp_stale(tmp_path) is False
assert sorted(tmp_path.rglob("*")) == install_tree
with pytest.raises(webapp.WebappBuildError, match="cannot be rebuilt"):
webapp.prepare_webapp_renderer(tmp_path, force=True)
(packaged / "index.html").unlink()
with pytest.raises(webapp.WebappBuildError, match="HERMES_WEBAPP_DIST is set"):
webapp.prepare_webapp_renderer(tmp_path)
def _assert_build_lock_excludes_second_open(tmp_path: Path):
from pm.filesystem import lock_fd, unlock_fd
+14
View File
@@ -54,6 +54,15 @@ def inputs_fixture(tmp_path):
}
def with_webapp(data, tmp_path):
"""Add a built Webapp renderer, the frontend only the Nix package passes today."""
webapp = tmp_path / "webapp"
webapp.mkdir()
(webapp / "index.html").write_text("built webapp", encoding="utf-8")
data["frontends"] = {**data["frontends"], "webapp": str(webapp)}
return data
def build_cli(data, out, tmp_path):
document = tmp_path / "inputs.json"
document.write_text(json.dumps(data), encoding="utf-8")
@@ -71,6 +80,7 @@ def build_cli(data, out, tmp_path):
@pytest.mark.platforms("posix")
def test_contained_cli_assembly_runs_after_move_and_preserves_prepared_state(tmp_path):
out, data = inputs_fixture(tmp_path)
with_webapp(data, tmp_path)
(out / "provider-state").write_text("retain", encoding="utf-8")
before = {p.relative_to(data["code"]): p.read_bytes() for p in Path(data["code"]).rglob("*") if p.is_file()}
result = build_cli(data, out, tmp_path)
@@ -84,6 +94,7 @@ def test_contained_cli_assembly_runs_after_move_and_preserves_prepared_state(tmp
assert (out / "app/hermes_cli/tui_dist/entry.js").is_file()
assert json.loads((out / "app/hermes_cli/tui_dist/package.json").read_text())["type"] == "module"
assert (out / "app/hermes_cli/web_dist/index.html").is_file()
assert (out / "app/apps/desktop/dist-webapp/index.html").read_text() == "built webapp"
assert not (tmp_path / "home/.hermes").exists()
moved = tmp_path / "relocated payload"
out.rename(moved)
@@ -102,6 +113,7 @@ def test_contained_cli_assembly_runs_after_move_and_preserves_prepared_state(tmp
@pytest.mark.platforms("posix")
def test_referenced_installed_code_uses_derived_command_map_without_copying(tmp_path):
_, data = inputs_fixture(tmp_path)
with_webapp(data, tmp_path)
out = tmp_path / "nix output"
source = Path(data["code"])
(source / "entry.py").write_text(
@@ -123,11 +135,13 @@ def test_referenced_installed_code_uses_derived_command_map_without_copying(tmp_
"source": str(command), "destination": "bin/probe", "entry": "entry:main"}}
assert Path(mapping["env"]["HERMES_TUI_DIR"]).resolve() == Path(data["frontends"]["tui"])
assert Path(mapping["env"]["HERMES_WEB_DIST"]).resolve() == Path(data["frontends"]["web"])
assert Path(mapping["env"]["HERMES_WEBAPP_DIST"]).resolve() == Path(data["frontends"]["webapp"])
assert mapping["env"]["HERMES_INSTALL_ROOT"] == str(out / "share/hermes-agent")
assert not list(out.rglob("entry.py"))
assert (out / "share/hermes-agent/skills").is_symlink()
assert (out / "ui-tui").is_symlink()
assert (out / "share/hermes-agent/web_dist").is_symlink()
assert (out / "share/hermes-agent/webapp_dist").is_symlink()
assert not (out / "bin/probe").exists(), "Nix owns native makeWrapper"
run = subprocess.run([mapping["commands"]["probe"]["source"]], cwd=tmp_path,
env={"PATH": os.environ["PATH"], "HOME": str(tmp_path / "home"),
+20
View File
@@ -696,6 +696,26 @@ macOS has no equivalent option. A `launchd` agent with `RunAtLoad` starts at log
The default address is `127.0.0.1`. Each other address starts the authentication gate of the dashboard. The server also refuses each request with a `Host` header that is different from the address that the server bound to. This is a defence against DNS rebinding. Bind to the name or the address that your client uses.
:::
### Running the Webapp
The package ships the [Webapp](../user-guide/desktop.md#use-the-desktop-workspace-from-a-browser) (Hermes Desktop in a browser) prebuilt, so nothing is built at run time; the store is read-only. The gateway serves it when its settings turn it on (see [Configuration → Webapp](../user-guide/configuration.md#webapp)):
```nix
{
services.hermes-agent = {
enable = true;
gateway.enable = true;
settings.webapp = {
enabled = true;
host = "127.0.0.1";
port = 9120;
};
};
}
```
Open it with `hermes webapp`. A non-loopback `host` or a `public_url` needs one of the [dashboard's sign-in providers](../user-guide/features/web-dashboard.md#authentication-gated-mode), as for the dashboard. `nix build .#webapp` builds the bundle on its own. The wrapper points `HERMES_WEBAPP_DIST` at it, so `hermes webapp --force-build` has nothing to rebuild and says so.
### Verify It Works
```bash