Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 46 additions & 19 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,19 +74,21 @@ jobs:
strategy:
fail-fast: false
matrix:
# Two images per arch. `slim` is what :latest has always been; `desktop`
# adds the Bot Screen packages and a headed Chromium (+1.4 GB) for the
# tier that offers a screen. Both are built on a PR so a change that only
# breaks the gated layers cannot reach publish.
arch: [amd64, arm64]
variant: [slim, desktop]
include:
- arch: amd64
runner: ubuntu-latest-32-core
platform: linux/amd64
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
# arm64 builds on the native arm64 larger runner. A build of
# linux/arm64 on an x64 host uses emulation.
- arch: arm64
runner: ubuntu-latest-32-arm-core
platform: linux/arm64
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64

runs-on: ${{ matrix.runner }}
timeout-minutes: 45
Expand Down Expand Up @@ -124,8 +126,11 @@ jobs:
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
HERMES_BOT_DESKTOP=${{ matrix.variant == 'desktop' && '1' || '0' }}
# Slim owns the scope; desktop is slim plus two RUN steps, so it reads
# and writes nothing — a 5.5 GB mode=max scope would blow the 10 GB cap.
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: ${{ (github.event_name != 'pull_request' && matrix.variant == 'slim') && format('type=gha,mode=max,scope=docker-{0}', matrix.arch) || '' }}


# Run the docker-integration test suite against the freshly-built
Expand Down Expand Up @@ -191,18 +196,16 @@ jobs:
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
variant: [slim, desktop]
include:
- arch: amd64
runner: ubuntu-latest-32-core
platform: linux/amd64
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
# Native arm64 for the same reason as the build matrix above.
- arch: arm64
runner: ubuntu-latest-32-arm-core
platform: linux/arm64
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
Expand Down Expand Up @@ -242,9 +245,10 @@ jobs:
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
HERMES_BOT_DESKTOP=${{ matrix.variant == 'desktop' && '1' || '0' }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ matrix.cache-to }}
cache-from: type=gha,scope=docker-${{ matrix.arch }}
cache-to: ${{ matrix.variant == 'slim' && format('type=gha,mode=max,scope=docker-{0}', matrix.arch) || '' }}

- name: Export digest
run: |
Expand All @@ -255,7 +259,7 @@ jobs:
- name: Upload digest artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-${{ matrix.arch }}
name: digest-${{ matrix.variant }}-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
Expand All @@ -269,17 +273,30 @@ jobs:
# On releases: tags :<release_tag_name>.
# ---------------------------------------------------------------------------
merge:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
# `needs` is the whole 4-leg matrix and a failed need skips the job, so a
# transient desktop push would take slim's :latest with it. Guard below.
if: ${{ !cancelled() && github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') }}
runs-on: ubuntu-latest
needs: [publish]
timeout-minutes: 10
environment: container-publish
strategy:
fail-fast: false
matrix:
# One manifest list per variant. `slim` keeps the unsuffixed tags it has
# always had, so nothing that pulls :latest today changes; `desktop`
# publishes the same digests under a -desktop suffix.
include:
- variant: slim
suffix: ""
- variant: desktop
suffix: "-desktop"
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: /tmp/digests
pattern: digest-*
pattern: digest-${{ matrix.variant }}-*
merge-multiple: true

# Retry once on transient Docker Hub / buildkit pull failures.
Expand All @@ -304,16 +321,25 @@ jobs:
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
SUFFIX: ${{ matrix.suffix }}
run: |
set -euo pipefail
# Without nullglob an empty dir yields the literal `*`: one bogus entry.
shopt -s nullglob
args=()
for digest_file in *; do
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
# One per arch in the publish matrix; a short set means a leg failed, and
# stitching it would publish a single-arch :latest. Keep in step with it.
if [ "${#args[@]}" -ne 2 ]; then
echo "::error::variant ${{ matrix.variant }}: want 2 digests, found ${#args[@]} (a publish leg failed)"
exit 1
fi
if [ "${{ github.event_name }}" = "release" ]; then
tags=(-t "${IMAGE_NAME}:${RELEASE_TAG}")
tags=(-t "${IMAGE_NAME}:${RELEASE_TAG}${SUFFIX}")
else
tags=(-t "${IMAGE_NAME}:main" -t "${IMAGE_NAME}:latest")
tags=(-t "${IMAGE_NAME}:main${SUFFIX}" -t "${IMAGE_NAME}:latest${SUFFIX}")
fi
# Retry: Docker Hub API + just-pushed digest eventual consistency
# can transiently fail the create; the operation is idempotent.
Expand All @@ -333,9 +359,10 @@ jobs:
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
SUFFIX: ${{ matrix.suffix }}
run: |
if [ "${{ github.event_name }}" = "release" ]; then
docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}"
docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}${SUFFIX}"
else
docker buildx imagetools inspect "${IMAGE_NAME}:main"
docker buildx imagetools inspect "${IMAGE_NAME}:main${SUFFIX}"
fi
42 changes: 36 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,14 @@
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev libatomic1 procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*

# Bot Screen (opt-in): TigerVNC + the Xfce components + a headed chromium, so a
# container that cannot run apt at run time (unprivileged user, no sudo — every
# hosted instance) can still stream a desktop. ~550 MB. Nothing here starts at
# boot; the layer costs no memory until a screen is started. Same package list
# as tools/bot_desktop/runtime.py::PACKAGES["apt"].
# Bot Screen (opt-in): PACKAGES["apt"] from tools/bot_desktop/runtime.py plus apt
# `chromium` for the dock's Browser icon. ~930 MB apt on debian:13.4 (~1.4 GB of
# image once the gated headed Chromium below is counted); nothing starts
# at boot. docker.yml builds both variants and publishes these packages under
# the `-desktop` tags: hosted sandboxes pull a prebuilt image and never run a
# build, and cannot apt at run time either (unprivileged, no sudo). Only this
# build step needs root —
# Xvnc is a userspace X server, so the runtime user can drive it.
# docker build --build-arg HERMES_BOT_DESKTOP=1 .
ARG HERMES_BOT_DESKTOP=0
RUN if [ "$HERMES_BOT_DESKTOP" = "1" ]; then \
Expand Down Expand Up @@ -211,13 +214,25 @@
# guards against a future regression if the source npm version changes.
ENV npm_config_install_links=false

# chrome-headless-shell: what the browser tool has always driven headlessly.
# Smaller, no window code paths. --with-deps pulls the shared system libraries.
RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
for i in 1 2 3; do \
npx playwright install --with-deps chromium --only-shell && break || \
{ [ "$i" = 3 ] && exit 1; echo "playwright install failed (attempt $i); retrying in 10s"; sleep 10; }; \
{ [ "$i" = 3 ] && exit 1; echo "playwright headless-shell install failed (attempt $i); retrying in 10s"; sleep 10; }; \
done && \
npm cache clean --force

# chrome-headless-shell cannot open a window, so the dock's Browser icon needs the
# full build. Same Chromium family as the shell, so agent and human share one
# --user-data-dir. Gated: a build with no desktop has nothing to show it on.
RUN if [ "$HERMES_BOT_DESKTOP" = "1" ]; then \

Check failure on line 229 in Dockerfile

View workflow job for this annotation

GitHub Actions / Lint Docker scripts / Lint Dockerfile (hadolint)

SC2015 info: Note that A && B || C is not if-then-else. C may run when A is true.
for i in 1 2 3; do \
npx playwright install chromium && break || \
{ [ "$i" = 3 ] && exit 1; echo "playwright chromium install failed (attempt $i); retrying in 10s"; sleep 10; }; \
done; \
fi

# ---------- Photon iMessage sidecar deps (baked, NS-606) ----------
# The photon plugin's Node sidecar needs its own node_modules
# (spectrum-ts). The install tree is immutable at runtime, so a lazy
Expand Down Expand Up @@ -293,6 +308,15 @@
RUN cd web && npm run build && \
cd ../ui-tui && npm run build

# ---------- Bot Screen X socket directory ----------
# Xvnc would create this itself (/tmp is 1777); pre-creating it keeps ownership
# deterministic when HERMES_UID is remapped between boots.
RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix

# XDG_RUNTIME_DIR (set below) sits under a predictable name in world-writable /tmp.
# Shipping it root-owned means stage2 finds a directory it trusts and chowns it.
RUN mkdir -p /tmp/hermes-runtime && chmod 0700 /tmp/hermes-runtime

# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
# --link decouples this layer from parents for cache purposes; --chmod bakes
Expand Down Expand Up @@ -418,6 +442,12 @@
# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter).
ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages

# Xfce, dbus and the display-allocation lock need one; containers have no logind
# to create /run/user/<uid>. The default fallback ($HOME/.cache) is the /opt/data
# volume, which a host-side install may share — two instances would then contend
# for one lock. Container-scoped instead; seeded 0700 by docker/stage2-hook.sh.
ENV XDG_RUNTIME_DIR=/tmp/hermes-runtime

# `docker exec` privilege-drop shim. When operators run
# `docker exec <c> hermes ...` they default to root, and any file the
# command writes under $HERMES_HOME (auth.json, .env, config.yaml) ends
Expand Down
47 changes: 40 additions & 7 deletions docker/stage2-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,31 @@ as_hermes mkdir -p \
"$HERMES_HOME/platforms/pairing" \
"$HERMES_HOME/lazy-packages"

# --- XDG_RUNTIME_DIR ---
# 0700 as dbus requires. It lives in world-writable /tmp under a predictable name
# and holds the display-allocation lock, so it is a security boundary: refuse a
# symlink or a directory someone else owns (chowning that one would hand hermes a
# directory whose creator keeps an fd into it), and chown rather than assume —
# `usermod -u` above does not chown outside the home dir, so a HERMES_UID remap
# would leave it owned by the old uid and every Xfce/dbus/lock open would EACCES.
if [ -n "${XDG_RUNTIME_DIR:-}" ]; then
xdg_owner=""
if [ -e "$XDG_RUNTIME_DIR" ]; then xdg_owner=$(stat -c %u "$XDG_RUNTIME_DIR" 2>/dev/null || echo unknown); fi
if refuse_symlinked_path "create" "$XDG_RUNTIME_DIR"; then
:
elif [ -n "$xdg_owner" ] && [ "$xdg_owner" != "0" ] && [ "$xdg_owner" != "$actual_hermes_uid" ]; then
echo "[stage2] Warning: $XDG_RUNTIME_DIR is owned by uid $xdg_owner (not root or hermes) — refusing to adopt it"
else
mkdir -p "$XDG_RUNTIME_DIR" 2>/dev/null || \
echo "[stage2] Warning: could not create XDG_RUNTIME_DIR $XDG_RUNTIME_DIR (continuing)"
if [ -d "$XDG_RUNTIME_DIR" ]; then
chown hermes:hermes "$XDG_RUNTIME_DIR" 2>/dev/null || \
echo "[stage2] Warning: could not chown XDG_RUNTIME_DIR $XDG_RUNTIME_DIR (rootless?)"
chmod 0700 "$XDG_RUNTIME_DIR" 2>/dev/null || true
fi
fi
fi

# --- Install-method stamp ---
# The 'docker' stamp is baked into the immutable install tree at
# /opt/hermes/.install_method (see Dockerfile), NOT written here into
Expand Down Expand Up @@ -718,10 +743,10 @@ if [ -d "$INSTALL_DIR/skills" ]; then
fi

# --- Discover agent-browser's Chromium binary ---
# The image's Dockerfile runs `npx playwright install chromium`, which
# populates ``$PLAYWRIGHT_BROWSERS_PATH`` (=/opt/hermes/.playwright) with
# a ``chromium_headless_shell-<build>/chrome-headless-shell-linux64/``
# directory. agent-browser (the runtime CLI Hermes spawns for the
# The image populates ``$PLAYWRIGHT_BROWSERS_PATH`` (=/opt/hermes/.playwright)
# with ``chromium_headless_shell-<build>/chrome-headless-shell-linux64/``, plus
# ``chromium-<build>/chrome-linux64/`` on a HERMES_BOT_DESKTOP build.
# agent-browser (the runtime CLI Hermes spawns for the
# browser tool) doesn't recognise this layout in its own cache scan and
# fails with "Auto-launch failed: Chrome not found" — even though the
# binary is right there (#15697).
Expand All @@ -746,11 +771,19 @@ fi
if [ -z "${AGENT_BROWSER_EXECUTABLE_PATH:-}" ] && \
[ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ] && \
[ -d "$PLAYWRIGHT_BROWSERS_PATH" ]; then
# Two ordered finds, not one with alternated -name predicates: that returns
# them in directory order, i.e. whichever Playwright unpacked first. Shell
# first, because this is what agent-browser launches for ordinary headless
# browsing everywhere and it is the lighter build; browser.py::env_for_agent
# swaps in the headed one for the agent while a screen is up.
browser_bin=$(find "$PLAYWRIGHT_BROWSERS_PATH" -type f -executable \
\( -name 'chrome' -o -name 'chromium' \
-o -name 'chrome-headless-shell' -o -name 'headless_shell' \
-o -name 'chromium-browser' \) \
\( -name 'chrome-headless-shell' -o -name 'headless_shell' \) \
2>/dev/null | head -n 1)
if [ -z "$browser_bin" ]; then
browser_bin=$(find "$PLAYWRIGHT_BROWSERS_PATH" -type f -executable \
\( -name 'chrome' -o -name 'chromium' -o -name 'chromium-browser' \) \
2>/dev/null | head -n 1)
fi
if [ -n "$browser_bin" ]; then
echo "[stage2] Found agent-browser Chromium binary: $browser_bin"
# Write to s6's container_environment so with-contenv picks it
Expand Down
3 changes: 3 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,9 @@ def recommended_update_command() -> str:
won't move your container — pull the newer tag you actually want, or
switch to ``:latest`` / ``:main`` for rolling updates. See available
tags at https://hub.docker.com/r/nousresearch/hermes-agent/tags
• On a ``-desktop`` tag (the one carrying Bot Screen)? Keep the suffix:
the unsuffixed image has no Xvnc/Xfce and no sudo to add them, so
pulling it stops the bots' screens from starting.
• Your config and session history live under ``$HERMES_HOME`` (``/opt/data``
in the container, typically bind-mounted from the host) and persist
across image upgrades — re-pulling doesn't lose any state.
Expand Down
8 changes: 5 additions & 3 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -2470,9 +2470,11 @@ def _aux(timeout, *, reasoning_effort=True, **extra):
# host. Off by default so installing TigerVNC for other reasons never yields a screen nobody asked
# for; Hermes Desktop's Screen pane offers Start and this toggle.
"auto_start": False,
# Refuse to start the screen while the host (or its container cgroup) has less than this free.
# Xvnc + Xfce idle at ~220 MB and a takeover's browser adds 0.5-1 GB; on a small instance the
# loser is Chromium mid-login or the gateway itself. 0 disables the check.
# Refuse to start below this much free memory (MB), measured on the host or its container cgroup,
# whichever is tighter. Xvnc + Xfce idle at ~220 MB and a takeover's browser adds 0.5-1 GB, so a
# screen with one page runs past 1 GB; the kernel OOM killer picks its victim by score, so on a
# small instance the loser is the dashboard or the gateway rather than the desktop. 0 disables the
# check.
"min_free_memory_mb": 1536,
# Stop a screen nobody has used (no computer_use action, browser spawn, viewer or takeover) for this
# long; it restarts on the next use. Idle Xvnc + Xfce hold ~220 MB, an abandoned browser far more.
Expand Down
5 changes: 5 additions & 0 deletions tests/fixtures/resolution_allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -723,5 +723,10 @@
"path": "tools/bot_desktop/runtime.py",
"symbol": "package_manager",
"kind": "bare_which"
},
{
"path": "tools/bot_desktop/runtime.py",
"symbol": "installable",
"kind": "bare_which"
}
]
37 changes: 37 additions & 0 deletions tests/tools/test_bot_desktop_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,3 +315,40 @@ def test_daemon_idle_timer_defers_to_the_janitor_only_for_the_shared_headed_brow
monkeypatch.setattr(session._cloud, "_is_headed_mode", lambda: True)
monkeypatch.setattr(runtime, "published_env", lambda: {})
assert session._daemon_idle_timeout_seconds() == 120


def test_a_headless_shell_pin_is_replaced_while_a_screen_is_up(tmp_path, monkeypatch):
"""The boot hook exports a chrome-headless-shell path; leaving it would put the agent and the dock on
two binaries over one --user-data-dir, where the singleton swallows the dock's launch."""
shell = tmp_path / "chrome-headless-shell"
shell.write_text("#!/bin/sh\n", encoding="utf-8")
shell.chmod(0o755)
headed = tmp_path / "chrome"
headed.write_text("#!/bin/sh\n", encoding="utf-8")
headed.chmod(0o755)
monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
monkeypatch.setattr(browser, "_playwright_executable", lambda: str(headed))
monkeypatch.delenv("AGENT_BROWSER_PROFILE", raising=False)
# Unpinned, the ubuntu runner (non-root, userns-restricted) flips executable() to
# its own /usr/bin/google-chrome; host policy is not the subject here.
monkeypatch.setattr(browser, "_userns_restricted", lambda: False)

agent_env = browser.env_for_agent({"AGENT_BROWSER_EXECUTABLE_PATH": str(shell)})
dock_exe, _ = browser.dock_launch()
assert agent_env["AGENT_BROWSER_EXECUTABLE_PATH"] == dock_exe == str(headed), \
"the agent and the dock must share one binary once a screen is up"


def test_a_real_user_pin_is_still_honoured(tmp_path, monkeypatch):
"""Only a headless-shell pin is overridden; a human's own headed browser stays put."""
mine = tmp_path / "my-chrome"
mine.write_text("#!/bin/sh\n", encoding="utf-8")
mine.chmod(0o755)
other = tmp_path / "chrome"
other.write_text("#!/bin/sh\n", encoding="utf-8")
other.chmod(0o755)
monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
monkeypatch.setattr(browser, "_playwright_executable", lambda: str(other))

env = browser.env_for_agent({"AGENT_BROWSER_EXECUTABLE_PATH": str(mine)})
assert env["AGENT_BROWSER_EXECUTABLE_PATH"] == str(mine)
Loading
Loading