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