From e66e0e7caa38aae9a032168d1327ea60066df5ef Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 9 Sep 2026 18:20:43 -0400 Subject: [PATCH] fix(packaging): restore nix desktop and docker builds Generate frontend icons from the shared source artwork during each build. Keep the locked icon dependencies out of the application runtime. Nix needs setuptools and CFFI to build the locked python-olm source. Its offline npm manifest must omit semver overrides because direct package specs become local tarball paths. Preserve the repository manifest and lockfile. Docker must use the PM interpreter rather than a second Python under /root. Install the application into its venv explicitly, use the image compiler, and make package facts readable by the runtime user. Verified: - nix build .#desktop and frontend-icons/package-contents/cli-commands - Packaged Electron PTY spawn and libolm encryption roundtrip - ARM Linux and macOS desktop evaluation, not native builds - Docker linux/amd64 build and 8 focused runtime tests The full test suite was not run. Leave llama integration unchanged. --- .dockerignore | 10 ++++++- Dockerfile | 20 +++++++++++-- nix/checks.nix | 26 +++++++++++++++++ nix/desktop.nix | 4 ++- nix/hermes-agent.nix | 8 +++-- nix/icons.nix | 20 +++++++++++++ nix/lib.nix | 5 ++++ nix/python.nix | 17 ++++++++++- nix/web.nix | 3 +- tests/docker/test_image_payload.py | 47 ++++++++++++++++++++++++++++++ 10 files changed, 151 insertions(+), 9 deletions(-) create mode 100644 nix/icons.nix create mode 100644 tests/docker/test_image_payload.py diff --git a/.dockerignore b/.dockerignore index ae00a18bb9..660964ab91 100644 --- a/.dockerignore +++ b/.dockerignore @@ -84,8 +84,16 @@ tests/ website/ docs/ -# Assets only used by the GitHub README +# Keep the source artwork needed by web's icon-generation prebuild. assets/ +!assets/ +assets/* +!assets/nous-girl-black.svg +!assets/nous-girl-white.svg +!assets/backgrounds/ +assets/backgrounds/* +!assets/backgrounds/squircle-light.svg +!assets/backgrounds/squircle-dark.svg infographic/ # Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them) diff --git a/Dockerfile b/Dockerfile index 39efc3a841..e0d62381d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -209,6 +209,7 @@ COPY hermes_cli/__init__.py hermes_cli/runtime_paths.py hermes_cli/runtime_state RUN set -eu; \ python3 -m pm.cli install uv chromium chromium-headless-shell; \ ln -sf /opt/hermes/tools/uv-*/uv /usr/local/bin/uv; \ + python3 -c 'from pathlib import Path; from pm.lock import Facts; from pm.registry import get_package; from pm.store import current_target; root = Path("/opt/hermes/tools"); fact = Facts(root / "facts.json").get("python"); binary = get_package("python").binary(root / fact["entry"], current_target()); Path("/usr/local/bin/python3").symlink_to(binary)'; \ uv --version; \ browser_bin="$(find /opt/hermes/tools/chromium-* -type f \( -name chrome -o -name chromium \) -print -quit)"; \ test -n "$browser_bin"; \ @@ -216,6 +217,14 @@ RUN set -eu; \ mkdir -p /etc/hermes; \ printf '%s' "$browser_bin" > /etc/hermes/agent-browser-executable-path +# Raw uv commands must use PM's staged interpreter, not download another +# under /root where the unprivileged runtime user cannot traverse it. +ENV UV_PYTHON=/usr/local/bin/python3 +ENV UV_PYTHON_DOWNLOADS=never +# The standalone interpreter records its builder's clang toolchain; +# native extensions must use the compiler installed in this image. +ENV CC=gcc CXX=g++ + # ---------- Layer-cached dependency install ---------- # Copy only package manifests first so npm install is cached unless the # lockfiles themselves change. @@ -316,8 +325,11 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra COPY web/ web/ COPY ui-tui/ ui-tui/ COPY apps/shared/ apps/shared/ +COPY scripts/generate-icons.mjs scripts/generate_icons.py scripts/ +COPY assets/ assets/ RUN cd web && npm run build && \ - cd ../ui-tui && npm run build + cd ../ui-tui && npm run build && \ + rm -rf /opt/hermes/.cache/icon-build # ---------- Source code ---------- # .dockerignore excludes node_modules, so the installs above survive. @@ -333,7 +345,7 @@ COPY --link --chmod=a+rX,go-w . . # Link hermes-agent itself (editable). Deps are already installed in the # cached layer above; `--no-deps` makes this a fast egg-link creation with no # resolution or downloads. -RUN uv pip install --no-cache-dir --no-deps -e "." +RUN uv pip install --python /opt/hermes/.venv/bin/python --no-cache-dir --no-deps -e "." # Wire the exec shim and install-method stamp. Files under /opt/hermes are # already root-owned (COPY, uv sync, npm install all run as root) and @@ -458,7 +470,9 @@ COPY --chmod=0755 docker/entrypoint-dispatch.sh /opt/hermes/docker/entrypoint-di # binary by absolute path, so this PATH ordering is transparent to # every other consumer. ENV PATH="/opt/hermes/bin:/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}" -RUN mkdir -p /opt/data +# PM's atomic writer creates private facts for source installs. In the +# image these are shared, non-secret package metadata, read by UID 10000. +RUN mkdir -p /opt/data && chmod 0644 /opt/hermes/tools/facts.json VOLUME [ "/opt/data" ] # The image ENTRYPOINT is a tiny dispatcher rather than `/init` directly. diff --git a/nix/checks.nix b/nix/checks.nix index 54ea644c10..a4d3c6d71f 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -164,6 +164,32 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) echo "ok" > $out/result ''; + # Inspect the shipped assets: successful JS compilation alone does + # not prove Vite copied the generated public files into the package. + frontend-icons = pkgs.runCommand "hermes-frontend-icons" { + nativeBuildInputs = [ (pkgs.python3.withPackages (ps: [ ps.pillow ])) ]; + } '' + python3 - <<'PY' + from pathlib import Path + from PIL import Image + + desktop = Path('${self'.packages.desktop}/share') + dist = desktop / 'hermes-desktop/dist' + launcher = desktop / 'icons/hicolor/1024x1024/apps/hermes.png' + for path in [launcher, dist / 'apple-touch-icon.png', + dist / 'nous-girl.png', dist / 'nous-girl-dark.png', + Path('${self'.packages.web}/favicon.ico')]: + with Image.open(path) as image: + image.load() + assert image.width > 0 and image.height > 0, path + with Image.open(launcher) as image, Image.open(dist / 'apple-touch-icon.png') as window_icon: + assert image.size == window_icon.size + assert image.convert('RGBA').tobytes() == window_icon.convert('RGBA').tobytes() + print('PASS: desktop and web ship decodable generated icons; launcher matches window icon') + PY + mkdir -p $out + ''; + # Verify the devShell builds successfully (cross-platform). build-devshell = pkgs.runCommand "hermes-build-devshell" { } '' echo "PASS: devShell built at ${self'.devShells.default}" diff --git a/nix/desktop.nix b/nix/desktop.nix index 169b3242ee..3353138ee4 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -15,6 +15,7 @@ electron, hermesAgent, installStampFile, + generatedIcons, python3, # Environment to bake into the launcher. A GUI launcher reads none of the # shell profile, so a variable that an interactive shell exports does not @@ -79,6 +80,7 @@ let mkdir -p apps/desktop/build cp ${installStampFile} apps/desktop/build/install-stamp.json + cp -r ${generatedIcons}/apps/desktop/public/. apps/desktop/public/ patchShebangs . @@ -195,7 +197,7 @@ stdenv.mkDerivation { # XDG launcher entry mkdir -p $out/share/applications $out/share/icons/hicolor/1024x1024/apps - install -m 0644 ${../apps/desktop/assets/icon.png} \ + install -m 0644 ${generatedIcons}/apps/desktop/assets/icon.png \ $out/share/icons/hicolor/1024x1024/apps/hermes.png export PYTHONPATH=$(mktemp -d) cp ${../hermes_cli/linux_desktop_entry.py} "$PYTHONPATH/linux_desktop_entry.py" diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index 139082075a..a406d56733 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -98,6 +98,10 @@ let hermesVenv = (mkHermesVenv extraDependencyGroups).venv; + generatedIcons = callPackage ./icons.nix { + inherit (mkHermesVenv [ ]) iconBuildVenv; + }; + hermesNpmLib = callPackage ./lib.nix { inherit npm-lockfile-fix; }; @@ -107,7 +111,7 @@ let }; hermesWeb = callPackage ./web.nix { - inherit hermesNpmLib; + inherit hermesNpmLib generatedIcons; }; bundledSkills = lib.cleanSourceWith { @@ -300,7 +304,7 @@ stdenv.mkDerivation (finalAttrs: { # runtime PATH (ripgrep/git/ffmpeg/etc). No re-implementation # of the agent resolution in the desktop wrapper. hermesDesktop = callPackage ./desktop.nix { - inherit hermesNpmLib electron installStampFile; + inherit hermesNpmLib electron installStampFile generatedIcons; python3 = python; hermesAgent = finalAttrs.finalPackage; }; diff --git a/nix/icons.nix b/nix/icons.nix new file mode 100644 index 0000000000..e57c203824 --- /dev/null +++ b/nix/icons.nix @@ -0,0 +1,20 @@ +# Run the shared generator offline; generated assets are not tracked in git. +{ lib, runCommand, iconBuildVenv }: +let + src = lib.fileset.toSource { + root = ./..; + fileset = lib.fileset.unions [ + ../scripts/generate_icons.py + (lib.fileset.fileFilter (file: file.hasExt "svg") ../assets) + ]; + }; +in +runCommand "hermes-icons" { nativeBuildInputs = [ iconBuildVenv ]; } '' + cp -r ${src} source + chmod -R u+w source + cd source + python scripts/generate_icons.py + python scripts/generate_icons.py --check + mkdir -p $out + cp -r apps web website $out/ +'' diff --git a/nix/lib.nix b/nix/lib.nix index 51f530db53..67ee92e8e0 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -209,6 +209,11 @@ let # keep in sync with it. npmDeps = importNpmLock.importNpmLock { npmRoot = npmDepsSrc; + # The lock already records override resolutions. importNpmLock changes + # direct specs to file:/nix/store tarballs, which conflict with the + # original semver overrides (EOVERRIDE). Drop them only from this offline + # build manifest; the hook restores the repository manifest afterwards. + package = builtins.removeAttrs rootPackageJson [ "overrides" ]; }; # Build a per-package npm source: workspace resolution files + the diff --git a/nix/python.nix b/nix/python.nix index 2ba803465d..48ed4de579 100644 --- a/nix/python.nix +++ b/nix/python.nix @@ -64,7 +64,17 @@ let "alibabacloud-gateway-spi" "alibabacloud-tea" ] (_: null) - ); + ) + // { + # The locked sdist has no build-system metadata; setup.py imports + # setuptools and uses CFFI to compile the bundled libolm. + python-olm = prev.python-olm.overrideAttrs (old: { + nativeBuildInputs = (old.nativeBuildInputs or [ ]) ++ final.resolveBuildSystem { + setuptools = [ ]; + cffi = [ ]; + }; + }); + }; pythonPackageOverrides = final: _prev: @@ -155,6 +165,11 @@ in { inherit python; + # Equivalent to uv's --only-group: use the lock-derived dependency spec, + # without installing Hermes or its runtime dependencies in the build env. + iconBuildVenv = pythonSet.mkVirtualEnv "hermes-icon-build-env" + pythonSet.hermes-agent.dependency-groups.icon-build; + venv = pythonSet.mkVirtualEnv "hermes-agent-env" { hermes-agent = dependency-groups; }; diff --git a/nix/web.nix b/nix/web.nix index c0cce7fe04..14520295b6 100644 --- a/nix/web.nix +++ b/nix/web.nix @@ -1,5 +1,5 @@ # nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build -{ hermesNpmLib, ... }: +{ hermesNpmLib, generatedIcons, ... }: hermesNpmLib.buildNpmPackage { dirs = [ "web" @@ -12,6 +12,7 @@ hermesNpmLib.buildNpmPackage { doCheck = false; buildPhase = '' + cp -r ${generatedIcons}/web/public/. web/public/ # Build from web/ so vite.config.ts and tsconfig resolve correctly. # The workspace root's node_modules/ is at ../node_modules/. cd web diff --git a/tests/docker/test_image_payload.py b/tests/docker/test_image_payload.py new file mode 100644 index 0000000000..d876f6dd88 --- /dev/null +++ b/tests/docker/test_image_payload.py @@ -0,0 +1,47 @@ +"""The image's non-root runtime uses its staged tools and generated assets.""" +from __future__ import annotations + +import subprocess + + +def test_python_uses_pm_interpreter_as_runtime_user(built_image: str) -> None: + probe = """ +from pathlib import Path +import sys +from pm.lock import Facts +from pm.registry import get_package +from pm.store import current_target + +store = Path('/opt/hermes/tools') +fact = Facts(store / 'facts.json').get('python') +expected = get_package('python').binary(store / fact['entry'], current_target()) +assert Path(sys._base_executable).resolve() == expected.resolve() +import yaml +print('PM interpreter and application dependencies load as hermes') +""" + result = subprocess.run( + ["docker", "run", "--rm", "--network", "none", "--user", "hermes", + "--entrypoint", "/opt/hermes/.venv/bin/python", built_image, "-c", probe], + capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_dashboard_ships_generated_icon_without_build_environment(built_image: str) -> None: + probe = """ +from pathlib import Path +import importlib.util +from PIL import Image + +with Image.open('/opt/hermes/hermes_cli/web_dist/favicon.ico') as image: + image.load() + assert image.width > 0 and image.height > 0 +assert importlib.util.find_spec('resvg_py') is None +assert not Path('/opt/hermes/.cache/icon-build').exists() +""" + result = subprocess.run( + ["docker", "run", "--rm", "--network", "none", "--user", "hermes", + "--entrypoint", "/opt/hermes/.venv/bin/python", built_image, "-c", probe], + capture_output=True, text=True, timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr