Compare commits

...

13 Commits

Author SHA1 Message Date
Ben
7452d5b77e fix(docker): add libolm-dev so matrix lazy-install can build python-olm
Closes #25495 (matrix/synapse broken in the official docker image).

`tools/lazy_deps.py` routes `platform.matrix` to
`mautrix[encryption]==0.21.0`, which transitively depends on
`python-olm`. `python-olm` is a Cython extension that links against
`libolm`; without `libolm-dev` in the image's apt set the lazy-install
build fails. Add `libolm-dev` to the runtime apt install line so the
in-container source build succeeds on first matrix use.

Salvages #27795 by @konsisumer. Their PR targeted a pre-rework
Dockerfile (still had `build-essential nodejs npm` in the apt list,
no `ca-certificates`); cherry-pick conflicts on incidental apt-list
churn, so this re-applies the same one-word insert against the
current apt line plus the matching pyproject.toml comment update.

Co-authored-by: konsisumer <11262660+konsisumer@users.noreply.github.com>
2026-05-28 15:53:51 +10:00
Ben
875d930ac7 test(docker-update): stub subprocess.run in git-install regression guard
The regression-guard test
`test_cmd_update_on_git_install_does_not_print_docker_message` mocked
`is_managed` and `detect_install_method` but not `subprocess.run`, so
once `cmd_update(check=True)` decided this was a git install it shelled
out to a real `git fetch upstream` / `git fetch origin`. On CI runners
the worktree has no `upstream` remote configured and the fetch hung
past the 30s pytest-timeout — test (4) slice failed in #33659 CI.

Fix: stub `subprocess.run` with a successful CompletedProcess-shaped
object whose stdout is `"0\n"`, so:
  - no real git command is ever invoked
  - the rev-list parsing later in the flow (`int(stdout.strip())`)
    succeeds rather than `ValueError`-ing through the test's
    SystemExit catch
  - the flow proceeds far enough to confirm the docker banner is
    absent (the actual assertion)

Also broaden the except clause to `(SystemExit, Exception)`: the only
assertion in this test is the negative-banner check on captured stdout;
any further failure in the rest of the update flow is irrelevant to
that contract.

Verified locally: all 7 tests in
`tests/hermes_cli/test_cmd_update_docker.py` pass in 0.39s (previously
the regression-guard test alone consumed 30s+ and got SIGTERM'd).
2026-05-28 15:50:25 +10:00
Ben
b924b22a9d fix(docker): hermes update prints docker pull guidance instead of bogus git error
Inside the published Docker image, `hermes update` was hitting the
".git missing → reinstall via curl" fallback:

    ✗ Not a git repository. Please reinstall:
      curl -fsSL https://raw.githubusercontent.com/.../install.sh | bash

That message is wrong on two counts:
  1. It tells the user to run the host-side installer, which would
     install a *new* Hermes on the host — not update the running
     container.
  2. It doesn't mention `docker pull` at all, leaving Docker users
     to figure out the right action from scratch.

`hermes update --check` was worse: it bailed with "Not a git
repository — cannot check for updates." and nothing else.

Fix: detect the Docker install method (already stamped by
`docker/stage2-hook.sh` and surfaced by `detect_install_method()`)
in both update entry points and print a long-form message that
covers:

  - The right command: `docker pull nousresearch/hermes-agent:latest`
  - Restart guidance (`docker compose up -d --force-recreate` /
    re-run `docker run`)
  - How to verify the new version after restart
  - Tag-pinning caveat (`:latest` doesn't move a pinned tag)
  - Config persistence across upgrades (state under `HERMES_HOME` /
    `/opt/data` is bind-mounted and survives)
  - Fork escape hatch (build your own image with the repo's Dockerfile)

Exit code is 1 (matches `managed_error` semantic for "tried to
update but can't update this way").

Plumbing:
  - hermes_cli/config.py: new `format_docker_update_message()` helper
    sits next to the existing `_NIX_UPDATE_MSG` /
    `format_managed_message()` family so the wording lives in one
    place and both call sites (apply path + check path) consume it.
  - hermes_cli/main.py:
      * `cmd_update()`: bail right after the `is_managed()` gate, before
        any of the apply-path branches.
      * `_cmd_update_check()`: bail at the top of the function, before
        the existing `method == "pip"` branch.
    Neither path touches subprocess.run / git when method == "docker".

Coverage:
  - 7 new tests in `tests/hermes_cli/test_cmd_update_docker.py`:
      * `hermes update` in Docker → message + exit 1, no git calls
      * `hermes update --check` (via cmd_update) → same
      * `--yes` / `--force` don't bypass (intentional)
      * `_cmd_update_check` called directly → bails too
      * git/pip installs still take their normal paths (regression guards)
      * `format_docker_update_message` content-lock test pinning the
        five user-actionable bits the message must contain
  - Existing test_cmd_update.py (21 tests) + test_managed_installs.py
    (5 tests) still pass — no regression on the source-install path.
  - Verified end-to-end in a real container: `docker run ... update`
    and `docker run ... update --check` both render the message and
    exit 1.
2026-05-28 15:50:25 +10:00
stephenschoettler
4a6f1863ac test: cover ci-unblocker production regressions
Snapshot review_agent._session_messages before teardown so close() can
clean per-session state without dropping the user-visible
self-improvement summary. Adds two regressions:

- bg-review summarizer receives captured review-agent tool messages
  after review_agent.close() runs
- context-compressor protected-head handoff rehydration populates
  _previous_summary and keeps the old handoff out of newly summarized
  turns

Salvaged from PR #26039 onto current main after agent/background_review.py
extraction. Original commit 63eaf6055; bg-review test updated to patch
the module-level summarize_background_review_actions in
agent.background_review instead of the now-forwarder
AIAgent._summarize_background_review_actions.
2026-05-27 22:14:53 -07:00
Ben
66489f38c7 fix(docker): bake build-time git SHA into the image
`hermes dump` and the startup banner both call `git rev-parse HEAD` to
report the running commit, but `.dockerignore` line 2 excludes `.git` —
so inside the published image `hermes dump` shows
`version: ... [(unknown)]` and the banner drops its `· upstream <sha>`
suffix entirely.  That makes support triage from container bug reports
impossible: we can't tell which commit the user is actually running.

Fix: thread the build-time SHA through as a Docker build-arg, write it
to `/opt/hermes/.hermes_build_sha` in the image, and have a new
`hermes_cli/build_info.get_build_sha()` read it as a fallback after the
existing live-git lookup fails.  Output format is unchanged in both
callsites — same 8-char short SHA whether resolved live or baked.

Wiring:
  - Dockerfile: `ARG HERMES_GIT_SHA=` + write-file step after the source
    copy.  Empty/missing arg → no file written → callers fall through to
    live git (so local `docker build` without --build-arg is unchanged).
  - docker-publish.yml: passes `HERMES_GIT_SHA=${{ github.sha }}` on all
    four build-push-action steps (amd64/arm64, smoke-test + final push).
  - dump.py:_get_git_commit() / banner.py:get_git_banner_state(): try
    live git first, fall back to baked SHA, then to legacy `(unknown)`
    / None.  Banner returns `upstream == local, ahead=0` because a built
    image is by definition pinned to one commit.

Coverage:
  - Unit tests cover build_info (file present/absent/empty/error,
    truncation, whitespace), dump (live-git wins, both fallbacks,
    identical output-format regression guard), and banner (no-repo +
    baked, no-repo + no-sha, shallow-clone fallback).
  - tests/docker/test_dump_build_sha.py is an integration regression
    guard that runs against the real image, reads
    `/opt/hermes/.hermes_build_sha`, and asserts `hermes dump` surfaces
    its content (or stays at `(unknown)` if no file).
  - Verified end-to-end: `docker build --build-arg HERMES_GIT_SHA=abc...`
    → `docker run ... dump` reports `[abc12345]`; without the build-arg
    it reports `[(unknown)]` as before.
2026-05-28 15:14:05 +10:00
teknium1
ebe04c66cd fix(kanban): close kanban.db FD after every connect() in long-lived processes
`sqlite3.Connection.__exit__` commits/rollbacks but does NOT close the
underlying FD. `with kb.connect() as conn:` in long-lived processes
(gateway `run_slash`, dashboard `decompose_task_endpoint`) therefore
leaks one FD to `kanban.db` per call. After enough operations the
gateway dies with `[Errno 24] Too many open files` (~4 days uptime
in the production report — #33159).

Fix: add a `connect_closing()` context manager in `hermes_cli/kanban_db`
that wraps `connect()` with a real `try/finally: conn.close()`. Switch
the 42 leak-prone call sites in `hermes_cli/kanban.py` (35),
`hermes_cli/kanban_decompose.py` (4), and `hermes_cli/kanban_specify.py`
(3) over to it.

`kanban.py` matters because `run_slash` (called from the gateway for
every `/kanban` slash command) parses argparse and dispatches to those
`_cmd_*` functions in-process — each one was leaking one FD per
invocation.

Tests inside `tests/` are untouched: short-lived processes where OS
cleanup masks the leak. Regression tests added in
`test_kanban_db.py` cover both happy-path and exception-path closure,
plus an explicit assertion that bare `with kb.connect()` still does
NOT close (documenting the upstream sqlite3 behaviour we're working
around).

Closes #33159.
2026-05-27 22:07:49 -07:00
Teknium
6d947e4d78 feat(image_gen/fal): add Krea 2 Medium + Large to FAL catalog (#33506)
fal announced Krea 2 day-0 as an official API partner on 2026-05-27.
Add both variants to the FAL_MODELS catalog so they appear in the
'hermes tools' model picker alongside flux-2, gpt-image, nano-banana,
etc. Users who already bill through FAL or Nous Portal subscription
can now use Krea without registering directly with Krea.

Model IDs (as listed in fal's launch announcement):
  fal-ai/krea/v2/medium/text-to-image  — $0.030 / image
  fal-ai/krea/v2/large/text-to-image   — $0.060 / image

Both share the same parameter schema:
  - aspect_ratio (1:1, 4:3, 3:2, 16:9, 2.35:1, 4:5, 2:3, 9:16)
    mapped from our 3 abstract ratios via size_style='aspect_ratio'
  - creativity (raw|low|medium|high; default medium)
  - seed (reproducibility)
  - image_style_references (up to 10 per Krea's API spec)

No num_inference_steps / guidance_scale / num_images — Krea 2 does
not expose those, and the supports-set filter strips them defensively
if the agent ever passes them.

This is the FAL-routed variant. The separate native-Krea-API plugin
shipped in PR #33236 (plugins/image_gen/krea/) remains available for
users who want to bill directly through Krea's API with their own
key. Both routes converge on the same underlying model.

Nous Portal managed-FAL gateway: this commit makes the model IDs
known to the catalog and the picker. The Portal team will need to
allowlist these two endpoint slugs on the fal-queue origin server-side
for them to flow through the managed billing path.
2026-05-27 21:42:52 -07:00
Wesley Simplicio
10f13c3881 fix(web): allow mobile dashboard scrolling (#28051) (#28577)
* fix(web): allow mobile dashboard scrolling

* fix(web): combine mobile root scroll rules

---------

Co-authored-by: Wesley Simplicio <wesley.simplicio.ext@siemens-energy.com>
2026-05-28 00:02:50 -04:00
Austin Pickett
c9410b3462 feat(web): add collapsible sidebar for the dashboard (#33421)
* feat(web): add collapsible sidebar for the dashboard

The desktop sidebar can now be collapsed to an icon-only rail via a
toggle button in the sidebar header.  State is persisted in
localStorage so it survives page reloads.

When collapsed (lg+ only):
- Sidebar shrinks from w-64 to w-14 with a smooth width transition
- Nav items show only their icon with a native title tooltip
- Brand text, plugin headings, system actions, theme/language
  switchers, auth widget, and footer are hidden
- Mobile drawer behavior is unchanged (always full-width)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): align sidebar tooltips to sidebar edge consistently

Tooltip left position now uses the sidebar's right edge instead of the
anchor element's right edge, so narrow anchors (theme/language switchers)
align with full-width anchors (nav links, system actions).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(web): add tooltip animations, restore theme label, rename Sessions tab

- Sidebar tooltips now animate in with a subtle 120ms ease-out slide;
  subsequent tooltips within the same hover sequence appear instantly
  (no delay/animation) following Emil Kowalski's tooltip pattern
- Restore theme name label when sidebar is expanded
- Rename Sessions segment tab to "History" across all 16 locales

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): smooth sidebar collapse animation

- Remove icon centering on collapse; icons stay left-aligned at px-5
  so they don't jump during the width transition
- Text labels fade out with opacity transition instead of instant
  display:none, clipped naturally by overflow-hidden
- Slow collapse duration from 450ms to 600ms for a more relaxed feel
- Gateway dot always rendered with opacity toggle so it doesn't
  slide in from the right on collapse
- Pin gateway dot at fixed left offset (pl-[1.625rem]) to align
  with nav icons
- Align header toggle button with justify-center when collapsed
- Bottom switchers use items-start when collapsed to prevent reflow

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 23:58:41 -04:00
Dusk
c341a2d107 fix(docker): align HOME for dashboard and s6 gateway services (#33481) 2026-05-28 13:42:27 +10:00
teknium1
71b4a6b18e fix(docker): install python-is-python3 so bare python resolves in containers
Debian 13 ships only `python3` — there's no `/usr/bin/python` symlink. When
the agent emits bash commands using bare `python` (which models do frequently
from their training prior), every such call fails with:

    /usr/bin/bash: python: command not found
    Tool terminal returned error … exit_code 127

The agent then retries with different approaches, sessions take longer, and
agent.log fills with WARNING noise.

`python-is-python3` is the standard Debian package that drops a
`/usr/bin/python → python3` symlink. ~30 KB, zero behavior change for
anything calling `python3` directly; transparent fix for everything else.

Fixes #33178.
2026-05-28 13:37:17 +10:00
Ben Barclay
aeb992d343 fix(docker): drop docker exec to hermes uid before invoking the CLI
When operators ran `docker exec <c> hermes login` (or anything else
that wrote under $HERMES_HOME) they defaulted to root, leaving
/opt/data/auth.json root:root mode 0600. The supervised gateway
(UID 10000) then couldn't read its own credentials and returned
"Provider authentication failed: Hermes is not logged into Nous
Portal" on every Telegram/Discord/etc. message — even though
`docker exec <c> hermes chat -q ping` (also root) succeeded because
root could read its own root-owned file. _load_auth_store swallowed
PermissionError as a parse failure and copied the file aside as
auth.json.corrupt, making the diagnostic more misleading.

Fix: install a privilege-drop shim at /opt/hermes/bin/hermes,
prepended ahead of the venv on PATH. When invoked as root the shim
exec's the real venv binary via `s6-setuidgid hermes` — so any file
the docker-exec session writes is uid-aligned with the supervised
processes. Non-root callers (the supervised processes themselves,
`docker exec --user hermes`, kanban subagents, anything inside the
container that's not coming through docker-exec) hit a single exec
to the absolute venv path with no privilege change.

Recursion is impossible: the shim exec's the venv binary by
absolute path (/opt/hermes/.venv/bin/hermes), so the second hop
cannot re-enter the shim regardless of PATH state. No sentinel env
var needed (unlike #33583's gateway-run redirect which DOES need
HERMES_S6_SUPERVISED_CHILD because there's no absolute-path
equivalent for the s6 dispatch).

Opt-out: `docker exec -e HERMES_DOCKER_EXEC_AS_ROOT=1 …` for
diagnostic sessions where the operator deliberately wants root.
Strict truthiness (1/true/yes case-insensitive); typos like `=0`
do not silently opt out, mirroring HERMES_GATEWAY_NO_SUPERVISE in
#33583.

If `s6-setuidgid` is missing (someone stripped s6-overlay in a
downstream fork), the shim exits 126 with a remediation message
pointing at `--user hermes` and the opt-out — never silently runs
as root.

Test plan:
- tests/docker/test_docker_exec_privilege_drop.py — 11 tests
  - shim drops root to hermes uid (file ownership check)
  - shim short-circuits for non-root docker exec
  - HERMES_DOCKER_EXEC_AS_ROOT=1 keeps root
  - strict-truthiness parametrization (5 falsy values reject)
  - main CMD path unaffected (recursion guard)
  - E2E: every file written by docker-exec is readable by uid 10000
- Full tests/docker/ harness: 32/32 pass against fresh image build
- shellcheck --severity=error: clean
- hadolint: clean
- Manual: reproduced the original symptom (root-owned auth.json)
  by bypassing the shim; confirmed default docker-exec produces
  hermes-owned files; confirmed opt-out env keeps root semantics.

Known follow-up: this prevents NEW instances of the bug. Volumes
that already have root:root /opt/data/auth.json from a pre-shim
image need a one-time `chown hermes:hermes` before rebooting onto
the new image. A stage2-hook chown sweep can self-heal that, but
is deferred per scope decision.
2026-05-28 13:30:36 +10:00
Ben Barclay
b345323195 fix(docker): tee supervised gateway stdout to docker logs
Follow-up to #33583 (the gateway-run-supervised redirect).

Before this fix, the supervised gateway's stdout (most visibly the
"Hermes Gateway Starting…" rich-console banner) was swallowed by
`s6-log` into the rotated file at
`${HERMES_HOME}/logs/gateways/<profile>/current` and never reached
`docker logs`. Operational signal lived in two places:

  * **docker logs** — saw stderr (Python `logging` defaults to
    stderr), so warnings/errors were visible.
  * **the rotated file** — saw stdout (rich banners, `print()`
    output, third-party libs that wrote to fd 1).

This was surprising for users coming from the pre-s6 image, where
`docker run … gateway run` produced a single unified stream in
`docker logs`. They'd see partial output, conclude something was
broken, and dig around for the missing pieces.

Fix: add the `1` s6-log action directive before the file destination
so each line is forwarded to s6-log's stdout — which propagates up
the s6-supervise pipeline to /init's stdout = container stdout =
`docker logs`. The file destination is preserved as a second
destination, so the rotated log (with ISO 8601 timestamps) still
exists for `hermes logs` and for survival across container restarts.

Trade-off considered: timestamps. Putting `T` between `1` and the
file destination (not before `1`) means:

  * docker logs sees raw lines — Python's logging formatter has its
    own timestamps, and `docker logs --timestamps` adds another
    layer when desired. No double-stamping in the common reading
    path.
  * The persisted file gets s6-log's ISO 8601 timestamp so even
    output that lacked a Python-logger timestamp (rich banners,
    third-party raw prints) is correlatable in `current`.

Verification:

  * New unit-test assertion in `test_service_manager.py` locks the
    `s6-log 1` directive into the rendered run-script. Mutation-
    tested by reverting to the pre-fix script (no `1`); the assert
    catches it cleanly.
  * New docker-harness test `test_supervised_gateway_stdout_reaches_docker_logs`
    builds the image, runs `docker run … gateway run`, and asserts
    the unique `⚕` banner glyph reaches `docker logs`. Also verifies
    the rotated file still contains the banner (no regression on
    the existing file destination). Mutation-tested end-to-end: built
    a deliberately-broken image without the `1` directive and the
    test failed exactly as designed, citing the banner present in
    `current` but absent from `docker logs`.
  * `website/docs/user-guide/docker.md` gains a new `:::note Where
    gateway logs go` admonition documenting both destinations and
    the audit-log file at `${HERMES_HOME}/logs/container-boot.log`.

Existing functionality preserved: every other docker-harness test
still passes against the new image. Unit-test sweep across
`tests/hermes_cli/` (5561 tests) is green.
2026-05-28 13:18:41 +10:00
54 changed files with 2162 additions and 224 deletions

View File

@@ -71,6 +71,8 @@ jobs:
load: true
platforms: linux/amd64
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
@@ -149,6 +151,8 @@ jobs:
platforms: linux/amd64
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
@@ -203,6 +207,8 @@ jobs:
load: true
platforms: linux/arm64
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
@@ -228,6 +234,8 @@ jobs:
platforms: linux/arm64
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64

View File

@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -187,6 +187,29 @@ RUN chmod -R a+rX /opt/hermes && \
# this a fast (~1s) egg-link creation with no resolution or downloads.
RUN uv pip install --no-cache-dir --no-deps -e "."
# ---------- Bake build-time git revision ----------
# .dockerignore excludes .git, so `git rev-parse HEAD` from inside the
# container always returns nothing — meaning `hermes dump` reports
# "(unknown)" and the startup banner drops its `· upstream <sha>` suffix.
# That makes support triage from container bug reports impossible:
# we can't tell which commit the user is actually running.
#
# Fix: write the commit SHA passed via the HERMES_GIT_SHA build-arg to
# /opt/hermes/.hermes_build_sha at build time, and have
# hermes_cli/build_info.py read it at runtime. Both `hermes dump` and
# banner.get_git_banner_state() try the baked SHA first, then fall back
# to live `git rev-parse` for source installs (unchanged behaviour).
#
# The arg is optional — local `docker build` without --build-arg simply
# omits the file, and the runtime falls back to live-git lookup. CI
# (.github/workflows/docker-publish.yml) passes ${{ github.sha }} so
# every published image has it.
ARG HERMES_GIT_SHA=
RUN if [ -n "${HERMES_GIT_SHA}" ]; then \
printf '%s\n' "${HERMES_GIT_SHA}" > /opt/hermes/.hermes_build_sha && \
chown hermes:hermes /opt/hermes/.hermes_build_sha; \
fi
# ---------- s6-overlay service wiring ----------
# Static services declared at build time: main-hermes + dashboard.
# Per-profile gateway services are registered dynamically at runtime by
@@ -213,13 +236,32 @@ COPY --chmod=0755 docker/cont-init.d/02-reconcile-profiles /etc/cont-init.d/02-r
# ---------- Runtime ----------
ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
ENV HERMES_HOME=/opt/data
# `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
# up root-owned and unreadable to the supervised gateway (UID 10000).
# The shim lives at /opt/hermes/bin/hermes, sits earliest on PATH, and
# transparently re-exec's the real venv binary via `s6-setuidgid hermes`
# when invoked as root. Non-root callers (supervised processes,
# `--user hermes`, etc.) hit the short-circuit path with no overhead.
# Recursion is impossible because the shim exec's the venv binary by
# absolute path (/opt/hermes/.venv/bin/hermes). See the shim source for
# the opt-out env var (HERMES_DOCKER_EXEC_AS_ROOT=1).
COPY --chmod=0755 docker/hermes-exec-shim.sh /opt/hermes/bin/hermes
# Pre-s6 entrypoint.sh did `source .venv/bin/activate` which exported
# the venv bin onto PATH; Architecture B's main-wrapper.sh does the
# same for the container's main process, but `docker exec` and our
# cont-init.d scripts don't pass through the wrapper. Expose the venv
# bin globally so `docker exec <container> hermes ...` and any
# subprocess that doesn't activate the venv first still find hermes.
ENV PATH="/opt/hermes/.venv/bin:/opt/data/.local/bin:${PATH}"
#
# /opt/hermes/bin is prepended ahead of the venv so the privilege-drop
# shim wins PATH resolution. The shim's last act is to exec the venv
# 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
VOLUME [ "/opt/data" ]

View File

@@ -483,6 +483,11 @@ def _run_review_in_thread(
finally:
clear_thread_tool_whitelist()
# Snapshot review actions before teardown. close() is allowed to
# clean per-session state, but the user-visible self-improvement
# summary still needs the completed review agent's tool results.
review_messages = list(getattr(review_agent, "_session_messages", []))
# Tear down memory providers while stdout is still
# redirected so background thread teardown (Honcho flush,
# Hindsight sync, etc.) stays silent. The finally block
@@ -495,7 +500,6 @@ def _run_review_in_thread(
review_agent.close()
except Exception:
pass
review_messages = list(getattr(review_agent, "_session_messages", []))
review_agent = None
# Scan the review agent's messages for successful tool actions

View File

@@ -0,0 +1,87 @@
#!/bin/sh
# shellcheck shell=sh
# /opt/hermes/bin/hermes — `docker exec` privilege-drop shim.
#
# Background
# ----------
# The s6 image runs the supervised gateway/main process as the unprivileged
# `hermes` user (UID 10000). When an operator runs `docker exec <c> hermes ...`
# the default UID is root (0), and any file the command writes under
# $HERMES_HOME — auth.json, .env, config.yaml — ends up root-owned and
# unreadable to the supervised gateway. The most common manifestation: the
# user runs `docker exec <c> hermes login`, this writes
# /opt/data/auth.json as root:root mode 0600, and from then on the gateway
# returns "Provider authentication failed: Hermes is not logged into Nous
# Portal" on every incoming message — even though `docker exec <c> hermes
# chat -q ping` (also running as root) succeeds because root happens to be
# able to read its own root-owned file. See systematic-debugging skill
# notes attached to this fix.
#
# Fix
# ---
# This shim sits at /opt/hermes/bin/hermes and is placed earliest on PATH.
# When invoked as root, it drops to the hermes user (via s6-setuidgid)
# before exec'ing the real venv binary, so anything that writes under
# $HERMES_HOME is uid-aligned with the supervised processes. When invoked
# as any non-root UID — including the supervised processes themselves,
# `docker exec --user hermes`, kanban subagents, etc. — it short-circuits
# straight to the venv binary with no privilege change. Net: one extra
# fork on the docker-exec-as-root path, zero behavioral change on every
# other path.
#
# Recursion safety: the shim exec's the venv binary by *absolute path*
# (/opt/hermes/.venv/bin/hermes), so the second hop cannot re-enter this
# shim regardless of PATH state. No sentinel env var needed.
#
# Opt-out: set HERMES_DOCKER_EXEC_AS_ROOT=1 (1/true/yes, case-insensitive)
# to keep running as root. Reserved for diagnostic sessions where the
# operator deliberately wants root semantics — e.g. inspecting root-only
# state via the hermes CLI. Default is to drop.
set -e
REAL=/opt/hermes/.venv/bin/hermes
# Defensive: if the venv binary is missing (corrupted image, partial
# install), fail loudly rather than silently masking it.
if [ ! -x "$REAL" ]; then
echo "hermes-shim: $REAL not found or not executable" >&2
exit 127
fi
# Already non-root? Just exec the real binary. This is the hot path for
# supervised processes (uid 10000) and for `docker exec --user hermes`.
if [ "$(id -u)" != "0" ]; then
exec "$REAL" "$@"
fi
# Root, with opt-out set? Honor it.
case "${HERMES_DOCKER_EXEC_AS_ROOT:-}" in
1|true|TRUE|True|yes|YES|Yes)
exec "$REAL" "$@"
;;
esac
# Root, no opt-out. Drop to the hermes user.
#
# s6-setuidgid lives under /command/ which is NOT on `docker exec`'s PATH
# (s6-overlay only puts /command/ on PATH for supervision-tree children).
# Reference it by absolute path so the drop is robust against PATH
# manipulation.
S6_SUID=/command/s6-setuidgid
if [ ! -x "$S6_SUID" ]; then
# Non-s6 image (someone stripped s6-overlay, or a hand-built variant).
# Fail loud rather than silently re-execing as root and leaking the
# bug this shim exists to prevent.
echo "hermes-shim: $S6_SUID not found; refusing to silently run as root." >&2
echo "hermes-shim: re-run with --user hermes or set HERMES_DOCKER_EXEC_AS_ROOT=1." >&2
exit 126
fi
# Reset HOME to the hermes user's home before dropping privileges. Without
# this, $HOME stays /root and any library that resolves paths off $HOME
# (XDG caches, lockfiles, .config writes) will try to write to /root and
# fail with EACCES. Mirrors main-wrapper.sh.
export HOME=/opt/data
exec "$S6_SUID" hermes "$REAL" "$@"

View File

@@ -19,6 +19,10 @@ case "${HERMES_DASHBOARD:-}" in
;;
esac
# with-contenv repopulates HOME from /init as /root. Reset it before
# dropping privileges so HOME-anchored state lands under /opt/data.
export HOME=/opt/data
cd /opt/data
# shellcheck disable=SC1091
. /opt/hermes/.venv/bin/activate

View File

@@ -300,14 +300,42 @@ def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]:
def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
"""Return upstream/local git hashes for the startup banner."""
"""Return upstream/local git hashes for the startup banner.
For source installs and dev images this runs ``git rev-parse`` against
the active checkout. When no checkout is available — the canonical case
is the published Docker image, which excludes ``.git`` from the build
context — we fall back to the baked-in build SHA (see
``hermes_cli/build_info.py``) and return it as a frozen
``upstream == local`` state with ``ahead=0``. A built image is by
definition pinned to one commit, so "ahead" is always zero and the
banner correctly shows ``· upstream <sha>`` with no carried-commits
annotation.
"""
repo_dir = repo_dir or _resolve_repo_dir()
if repo_dir is None:
# No git checkout — try the baked build SHA (Docker image path).
try:
from hermes_cli.build_info import get_build_sha
baked = get_build_sha(short=8)
if baked:
return {"upstream": baked, "local": baked, "ahead": 0}
except Exception:
pass
return None
upstream = _git_short_hash(repo_dir, "origin/main")
local = _git_short_hash(repo_dir, "HEAD")
if not upstream or not local:
# Live-git lookup failed (e.g. shallow clone without origin/main).
# Fall back to the baked build SHA if available.
try:
from hermes_cli.build_info import get_build_sha
baked = get_build_sha(short=8)
if baked:
return {"upstream": baked, "local": baked, "ahead": 0}
except Exception:
pass
return None
ahead = 0

51
hermes_cli/build_info.py Normal file
View File

@@ -0,0 +1,51 @@
"""
Baked-in build metadata for Hermes Agent.
Source installs report their git revision live via ``git rev-parse`` (see
``hermes_cli/dump.py`` and ``hermes_cli/banner.py``). That doesn't work inside
the published Docker image because ``.dockerignore`` excludes ``.git``, so
those callsites fall back to ``"(unknown)"`` / drop the banner suffix entirely.
To make ``hermes dump`` and the startup banner identify the exact commit the
image was built from, the Docker build writes the build-time ``$HERMES_GIT_SHA``
arg into ``<project_root>/.hermes_build_sha``. This module is the single
read-side helper consumed by both callsites — keeping the lookup in one place
so the file path and missing-file behaviour stay consistent.
Behaviour:
- Returns ``None`` when the file is absent. Source installs and dev images
built without the ``HERMES_GIT_SHA`` build-arg fall through to live-git
resolution in the caller, so non-Docker installs are unaffected.
- Returns ``None`` on any IO / decoding error. The build-sha is a nice-to-have
for support triage; nothing in the CLI is allowed to crash because of it.
- Truncates to ``short`` characters (default 8) to match the format used by
``git rev-parse --short=8`` throughout the codebase.
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
# Path is resolved relative to this module so it works regardless of cwd —
# matches the pattern used by ``banner._resolve_repo_dir``.
_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha"
def get_build_sha(short: int = 8) -> Optional[str]:
"""Return the baked-in build SHA, truncated to ``short`` chars, or None.
Reads ``<project_root>/.hermes_build_sha`` if present. The file is
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg and contains
the full 40-character commit hash on a single line.
"""
try:
if not _BUILD_SHA_FILE.is_file():
return None
sha = _BUILD_SHA_FILE.read_text(encoding="utf-8").strip()
except Exception:
return None
if not sha:
return None
return sha[:short] if short and short > 0 else sha

View File

@@ -345,6 +345,58 @@ def recommended_update_command() -> str:
return recommended_update_command_for_method(method)
# Long-form text for ``hermes update`` / ``--check`` when running inside the
# Docker image. Surfaced by ``cmd_update`` and ``_cmd_update_check`` in
# hermes_cli/main.py; lives here so the wording stays consistent and we
# don't grow two slightly-different copies.
#
# Why this matters:
# - The published image excludes ``.git`` (see .dockerignore), so the
# git-based update path can never succeed inside the container.
# - The pre-existing fallback message ("✗ Not a git repository. Please
# reinstall: curl ... install.sh") is actively misleading inside Docker
# — that script installs a *new* host-side Hermes, it doesn't update
# the running container.
# - The right action is ``docker pull`` + restart the container; this
# helper spells that out, with notes on tag pinning and config
# persistence so users don't get blindsided.
_DOCKER_UPDATE_MESSAGE = """\
✗ ``hermes update`` doesn't apply inside the Docker container.
Hermes Agent runs as a published image (nousresearch/hermes-agent), not a
git checkout — the container has no working tree to pull into. Update by
pulling a fresh image and restarting your container instead:
docker pull nousresearch/hermes-agent:latest
# then restart whatever started the container, e.g.:
docker compose up -d --force-recreate hermes-agent
# or, for ad-hoc runs, exit the current container and `docker run` again
Verify the new version after restart:
docker run --rm nousresearch/hermes-agent:latest --version
Notes:
• If you pinned a specific tag (e.g. ``:v0.14.0``) the ``:latest`` tag
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
• 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.
• Running a fork? Build your own image with this repo's ``Dockerfile``
and replace the ``docker pull`` step with your build/push pipeline."""
def format_docker_update_message() -> str:
"""Return the user-facing message for ``hermes update`` inside Docker.
Centralised so ``cmd_update`` (the apply path) and ``_cmd_update_check``
(the dry-run path) share the same wording. See ``_DOCKER_UPDATE_MESSAGE``
above for the full rationale.
"""
return _DOCKER_UPDATE_MESSAGE
def format_managed_message(action: str = "modify this Hermes installation") -> str:
"""Build a user-facing error for managed installs."""
managed_system = get_managed_system() or "a package manager"

View File

@@ -20,7 +20,15 @@ from agent.skill_utils import is_excluded_skill_path
def _get_git_commit(project_root: Path) -> str:
"""Return short git commit hash, or '(unknown)'."""
"""Return short git commit hash, or '(unknown)'.
Source installs and dev images resolve this live via ``git rev-parse``.
The published Docker image excludes ``.git`` from the build context, so
that lookup always fails — we fall back to the baked-in build SHA written
to ``<project_root>/.hermes_build_sha`` by the Dockerfile's
``HERMES_GIT_SHA`` build-arg (see ``hermes_cli/build_info.py``).
The output format is identical regardless of source.
"""
try:
result = subprocess.run(
["git", "rev-parse", "--short=8", "HEAD"],
@@ -28,9 +36,23 @@ def _get_git_commit(project_root: Path) -> str:
cwd=str(project_root),
)
if result.returncode == 0:
return result.stdout.strip()
value = result.stdout.strip()
if value:
return value
except Exception:
pass
# Fall back to the build-time baked SHA (populated in published Docker
# images, absent otherwise). Defers the import so the dump module
# stays cheap on non-dump code paths.
try:
from hermes_cli.build_info import get_build_sha
baked = get_build_sha(short=8)
if baked:
return baked
except Exception:
pass
return "(unknown)"

View File

@@ -1021,7 +1021,7 @@ def _board_task_counts(slug: str) -> dict[str, int]:
path = kb.kanban_db_path(board=slug)
if not path.exists():
return {}
with kb.connect(board=slug) as conn:
with kb.connect_closing(board=slug) as conn:
rows = conn.execute(
"SELECT status, COUNT(*) AS n FROM tasks GROUP BY status"
).fetchall()
@@ -1264,7 +1264,7 @@ def _cmd_init(args: argparse.Namespace) -> int:
def _cmd_heartbeat(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
ok = kb.heartbeat_worker(
conn,
args.task_id,
@@ -1279,7 +1279,7 @@ def _cmd_heartbeat(args: argparse.Namespace) -> int:
def _cmd_assignees(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
data = kb.known_assignees(conn)
if getattr(args, "json", False):
print(json.dumps(data, indent=2, ensure_ascii=False))
@@ -1320,7 +1320,7 @@ def _cmd_create(args: argparse.Namespace) -> int:
file=sys.stderr,
)
return 2
with kb.connect() as conn:
with kb.connect_closing() as conn:
task_id = kb.create_task(
conn,
title=args.title,
@@ -1369,7 +1369,7 @@ def _cmd_swarm(args: argparse.Namespace) -> int:
if not workers:
print("kanban swarm: at least one --worker is required", file=sys.stderr)
return 2
with kb.connect() as conn:
with kb.connect_closing() as conn:
created = ks.create_swarm(
conn,
goal=args.goal,
@@ -1395,7 +1395,7 @@ def _cmd_list(args: argparse.Namespace) -> int:
assignee = args.assignee
if args.mine and not assignee:
assignee = _profile_author()
with kb.connect() as conn:
with kb.connect_closing() as conn:
# Cheap "mini-dispatch": recompute ready so list output reflects
# dependencies that may have cleared since the last dispatcher tick.
kb.recompute_ready(conn)
@@ -1444,7 +1444,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
file=sys.stderr,
)
return 2
with kb.connect() as conn:
with kb.connect_closing() as conn:
task = kb.get_task(conn, args.task_id)
if not task:
print(f"no such task: {args.task_id}", file=sys.stderr)
@@ -1610,7 +1610,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
def _cmd_assign(args: argparse.Namespace) -> int:
profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile
with kb.connect() as conn:
with kb.connect_closing() as conn:
ok = kb.assign_task(conn, args.task_id, profile)
if not ok:
print(f"no such task: {args.task_id}", file=sys.stderr)
@@ -1620,7 +1620,7 @@ def _cmd_assign(args: argparse.Namespace) -> int:
def _cmd_reclaim(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
ok = kb.reclaim_task(
conn, args.task_id,
reason=getattr(args, "reason", None),
@@ -1637,7 +1637,7 @@ def _cmd_reclaim(args: argparse.Namespace) -> int:
def _cmd_reassign(args: argparse.Namespace) -> int:
profile = None if args.profile.lower() in {"none", "-", "null"} else args.profile
with kb.connect() as conn:
with kb.connect_closing() as conn:
ok = kb.reassign_task(
conn, args.task_id, profile,
reclaim_first=bool(getattr(args, "reclaim", False)),
@@ -1667,7 +1667,7 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int:
diag_config = kd.config_from_runtime_config(load_config())
with kb.connect() as conn:
with kb.connect_closing() as conn:
# Either one-task mode or fleet mode.
if getattr(args, "task", None):
task = kb.get_task(conn, args.task)
@@ -1790,14 +1790,14 @@ def _cmd_diagnostics(args: argparse.Namespace) -> int:
def _cmd_link(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
kb.link_tasks(conn, args.parent_id, args.child_id)
print(f"Linked {args.parent_id} -> {args.child_id}")
return 0
def _cmd_unlink(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
ok = kb.unlink_tasks(conn, args.parent_id, args.child_id)
if not ok:
print(f"No such link: {args.parent_id} -> {args.child_id}", file=sys.stderr)
@@ -1807,7 +1807,7 @@ def _cmd_unlink(args: argparse.Namespace) -> int:
def _cmd_claim(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
task = kb.claim_task(conn, args.task_id, ttl_seconds=args.ttl)
if task is None:
# Report why
@@ -1838,7 +1838,7 @@ def _cmd_comment(args: argparse.Namespace) -> int:
suffix = f"\n\n[trimmed to {args.max_len} chars by --max-len]"
body = body[: max(0, args.max_len - len(suffix))].rstrip() + suffix
author = args.author or _profile_author()
with kb.connect() as conn:
with kb.connect_closing() as conn:
kb.add_comment(conn, args.task_id, author, body)
print(f"Comment added to {args.task_id}")
return 0
@@ -1885,7 +1885,7 @@ def _cmd_complete(args: argparse.Namespace) -> int:
print(f"kanban: --metadata: {exc}", file=sys.stderr)
return 2
failed: list[str] = []
with kb.connect() as conn:
with kb.connect_closing() as conn:
for tid in ids:
if not kb.complete_task(
conn, tid,
@@ -1912,7 +1912,7 @@ def _cmd_edit(args: argparse.Namespace) -> int:
except (ValueError, json.JSONDecodeError) as exc:
print(f"kanban: --metadata: {exc}", file=sys.stderr)
return 2
with kb.connect() as conn:
with kb.connect_closing() as conn:
if not kb.edit_completed_task_result(
conn,
args.task_id,
@@ -1934,7 +1934,7 @@ def _cmd_block(args: argparse.Namespace) -> int:
author = _profile_author()
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
failed: list[str] = []
with kb.connect() as conn:
with kb.connect_closing() as conn:
for tid in ids:
if reason:
kb.add_comment(conn, tid, author, f"BLOCKED: {reason}")
@@ -1956,7 +1956,7 @@ def _cmd_schedule(args: argparse.Namespace) -> int:
author = _profile_author()
ids = [args.task_id] + list(getattr(args, "ids", None) or [])
failed: list[str] = []
with kb.connect() as conn:
with kb.connect_closing() as conn:
for tid in ids:
if reason:
kb.add_comment(conn, tid, author, f"SCHEDULED: {reason}")
@@ -1979,7 +1979,7 @@ def _cmd_unblock(args: argparse.Namespace) -> int:
print("at least one task_id is required", file=sys.stderr)
return 1
failed: list[str] = []
with kb.connect() as conn:
with kb.connect_closing() as conn:
for tid in ids:
if not kb.unblock_task(conn, tid):
failed.append(tid)
@@ -2003,7 +2003,7 @@ def _cmd_promote(args: argparse.Namespace) -> int:
seen.add(tid)
results: list[dict[str, object]] = []
with kb.connect() as conn:
with kb.connect_closing() as conn:
for tid in ids:
ok, err = kb.promote_task(
conn,
@@ -2050,7 +2050,7 @@ def _cmd_archive(args: argparse.Namespace) -> int:
print("at least one task_id is required", file=sys.stderr)
return 1
failed: list[str] = []
with kb.connect() as conn:
with kb.connect_closing() as conn:
if purge_ids:
for tid in purge_ids:
if not kb.delete_archived_task(conn, tid):
@@ -2073,7 +2073,7 @@ def _cmd_tail(args: argparse.Namespace) -> int:
print(f"Tailing events for {args.task_id}. Ctrl-C to stop.")
try:
while True:
with kb.connect() as conn:
with kb.connect_closing() as conn:
events = kb.list_events(conn, args.task_id)
for e in events:
if e.id > last_id:
@@ -2087,7 +2087,7 @@ def _cmd_tail(args: argparse.Namespace) -> int:
def _cmd_dispatch(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
res = kb.dispatch_once(
conn,
dry_run=args.dry_run,
@@ -2257,7 +2257,7 @@ def _cmd_daemon(args: argparse.Namespace) -> int:
from the dispatcher's perspective, not stuck.
"""
try:
with kb.connect() as conn:
with kb.connect_closing() as conn:
return kb.has_spawnable_ready(conn)
except Exception:
return False
@@ -2288,7 +2288,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
cursor = 0
print("Watching kanban events. Ctrl-C to stop.", flush=True)
# Seed cursor at the latest id so we don't replay history.
with kb.connect() as conn:
with kb.connect_closing() as conn:
row = conn.execute(
"SELECT COALESCE(MAX(id), 0) AS m FROM task_events"
).fetchone()
@@ -2296,7 +2296,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
try:
while True:
with kb.connect() as conn:
with kb.connect_closing() as conn:
rows = conn.execute(
"SELECT e.id, e.task_id, e.kind, e.payload, e.created_at, "
" t.assignee, t.tenant "
@@ -2329,7 +2329,7 @@ def _cmd_watch(args: argparse.Namespace) -> int:
def _cmd_stats(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
stats = kb.board_stats(conn)
if getattr(args, "json", False):
print(json.dumps(stats, indent=2, ensure_ascii=False))
@@ -2349,7 +2349,7 @@ def _cmd_stats(args: argparse.Namespace) -> int:
def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
if kb.get_task(conn, args.task_id) is None:
print(f"no such task: {args.task_id}", file=sys.stderr)
return 1
@@ -2366,7 +2366,7 @@ def _cmd_notify_subscribe(args: argparse.Namespace) -> int:
def _cmd_notify_list(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
subs = kb.list_notify_subs(conn, args.task_id)
if getattr(args, "json", False):
print(json.dumps(subs, indent=2, ensure_ascii=False))
@@ -2383,7 +2383,7 @@ def _cmd_notify_list(args: argparse.Namespace) -> int:
def _cmd_notify_unsubscribe(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
ok = kb.remove_notify_sub(
conn, task_id=args.task_id,
platform=args.platform, chat_id=args.chat_id,
@@ -2417,7 +2417,7 @@ def _cmd_runs(args: argparse.Namespace) -> int:
file=sys.stderr,
)
return 2
with kb.connect() as conn:
with kb.connect_closing() as conn:
runs = kb.list_runs(conn, args.task_id, **rsk)
if getattr(args, "json", False):
print(json.dumps([
@@ -2456,7 +2456,7 @@ def _cmd_runs(args: argparse.Namespace) -> int:
def _cmd_context(args: argparse.Namespace) -> int:
with kb.connect() as conn:
with kb.connect_closing() as conn:
text = kb.build_worker_context(conn, args.task_id)
print(text)
return 0
@@ -2622,7 +2622,7 @@ def _cmd_gc(args: argparse.Namespace) -> int:
import shutil
scratch_root = kb.workspaces_root()
removed_ws = 0
with kb.connect() as conn:
with kb.connect_closing() as conn:
rows = conn.execute(
"SELECT id, workspace_kind, workspace_path FROM tasks WHERE status = 'archived'"
).fetchall()
@@ -2645,7 +2645,7 @@ def _cmd_gc(args: argparse.Namespace) -> int:
event_days = getattr(args, "event_retention_days", 30)
log_days = getattr(args, "log_retention_days", 30)
with kb.connect() as conn:
with kb.connect_closing() as conn:
removed_events = kb.gc_events(
conn, older_than_seconds=event_days * 24 * 3600,
)

View File

@@ -1236,6 +1236,41 @@ def connect(
return conn
@contextlib.contextmanager
def connect_closing(
db_path: Optional[Path] = None,
*,
board: Optional[str] = None,
):
"""Open a kanban DB connection and guarantee it is closed on exit.
Use this instead of ``with kb.connect() as conn:`` — sqlite3's
built-in connection context manager only commits/rollbacks the
transaction; it does NOT close the file descriptor. In long-lived
processes (gateway, dashboard) that route every kanban operation
through ``connect()`` (e.g. ``run_slash`` dispatching ``/kanban …``
commands, ``decompose_task_endpoint`` calling
``kanban_decompose.decompose_task``), the unclosed connections
accumulate as open FDs to ``kanban.db`` and ``kanban.db-wal``. After
enough operations the process hits the kernel FD limit and dies
with ``[Errno 24] Too many open files``.
See #33159 for the production incident.
The ``connect()`` function itself remains unchanged so callers that
intentionally manage the connection lifetime (tests, long-lived
callers) continue to work.
"""
conn = connect(db_path=db_path, board=board)
try:
yield conn
finally:
try:
conn.close()
except Exception:
pass
def init_db(
db_path: Optional[Path] = None,
*,

View File

@@ -281,7 +281,7 @@ def decompose_task(
configured, API error, malformed response, decomposer returned
fanout=true with empty task list) — those surface via ``ok=False``.
"""
with kb.connect() as conn:
with kb.connect_closing() as conn:
task = kb.get_task(conn, task_id)
if task is None:
return DecomposeOutcome(task_id, False, "unknown task id")
@@ -370,7 +370,7 @@ def decompose_task(
return DecomposeOutcome(
task_id, False, "decomposer returned fanout=false with no title/body",
)
with kb.connect() as conn:
with kb.connect_closing() as conn:
ok = kb.specify_triage_task(
conn,
task_id,
@@ -439,7 +439,7 @@ def decompose_task(
})
try:
with kb.connect() as conn:
with kb.connect_closing() as conn:
child_ids = kb.decompose_triage_task(
conn,
task_id,
@@ -467,7 +467,7 @@ def decompose_task(
def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
"""Return task ids currently in the triage column."""
with kb.connect() as conn:
with kb.connect_closing() as conn:
rows = kb.list_tasks(
conn,
status="triage",

View File

@@ -150,7 +150,7 @@ def specify_task(
error, malformed response) — those surface via ``ok=False`` so the
``--all`` sweep can continue past individual failures.
"""
with kb.connect() as conn:
with kb.connect_closing() as conn:
task = kb.get_task(conn, task_id)
if task is None:
return SpecifyOutcome(task_id, False, "unknown task id")
@@ -239,7 +239,7 @@ def specify_task(
task_id, False, "LLM response missing title and body"
)
with kb.connect() as conn:
with kb.connect_closing() as conn:
ok = kb.specify_triage_task(
conn,
task_id,
@@ -261,7 +261,7 @@ def list_triage_ids(*, tenant: Optional[str] = None) -> list[str]:
``tenant`` narrows the sweep; ``None`` returns every triage task.
"""
with kb.connect() as conn:
with kb.connect_closing() as conn:
tasks = kb.list_tasks(
conn,
status="triage",

View File

@@ -8416,6 +8416,14 @@ def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
"""
from hermes_cli.config import detect_install_method
method = detect_install_method(PROJECT_ROOT)
if method == "docker":
# Docker can't ``git fetch`` from within the container. Surface the
# same long-form ``docker pull`` guidance ``hermes update`` (apply
# path) uses — telling the user to "reinstall via curl" or that
# ".git is missing" would point them at the wrong remediation.
from hermes_cli.config import format_docker_update_message
print(format_docker_update_message())
sys.exit(1)
if method == "pip":
from hermes_cli.config import recommended_update_command
from hermes_cli.banner import check_via_pypi
@@ -8716,12 +8724,27 @@ def cmd_update(args):
runs the update, then restores stdio on the way out (even on
``sys.exit`` or unhandled exceptions).
"""
from hermes_cli.config import is_managed, managed_error
from hermes_cli.config import (
detect_install_method,
format_docker_update_message,
is_managed,
managed_error,
)
if is_managed():
managed_error("update Hermes Agent")
return
# Docker users can't ``git pull`` — the image excludes ``.git`` from
# the build context. Bail with a friendly explanation pointing at
# ``docker pull`` BEFORE any of the apply-path / check-path branches
# below get a chance to error out with misleading "Not a git
# repository" text. See format_docker_update_message() for the full
# rationale and tag-pinning / config-persistence notes.
if detect_install_method(PROJECT_ROOT) == "docker":
print(format_docker_update_message())
sys.exit(1)
if getattr(args, "check", False):
# --check honors --branch so the "any new commits?" answer matches
# what a subsequent `hermes update --branch=<x>` would actually pull.

View File

@@ -566,8 +566,11 @@ class S6ServiceManager:
1. Sources HERMES_HOME (and any extra env) via with-contenv —
so e.g. ``-e HERMES_HOME=/data/hermes`` is honored at run
time, not Python-substituted at registration time (OQ8-C).
2. Activates the bundled venv.
3. Drops to the hermes user and exec's
2. Resets ``HOME`` to ``/opt/data`` before the privilege drop
so with-contenv's root HOME does not leak into the
unprivileged gateway process.
3. Activates the bundled venv.
4. Drops to the hermes user and exec's
``hermes -p <profile> gateway run`` (or just ``hermes
gateway run`` for the default profile — see below).
@@ -597,6 +600,7 @@ class S6ServiceManager:
"#!/command/with-contenv sh",
"# shellcheck shell=sh",
"set -e",
"export HOME=/opt/data",
"cd /opt/data",
". /opt/hermes/.venv/bin/activate",
]
@@ -628,6 +632,38 @@ class S6ServiceManager:
— so a container started with ``-e HERMES_HOME=/data/hermes``
gets its logs under /data/hermes/logs/..., not the build-time
default.
Output routing — the script is two action directives, applied
per line, in order:
1. ``1`` (forward to stdout) — propagates the line up the
s6-supervise pipeline to /init's stdout, which is the
container's stdout, which is ``docker logs``. Without
this, supervised stdout would be terminated inside
s6-log and never reach the container's log stream;
users would have to ``docker exec`` and ``tail`` the
file just to see startup banners. (Python's ``logging``
module defaults to stderr, which s6-supervise leaves
unfiltered — so warnings/errors already reach docker
logs. This change is specifically about the rich-console
banner output and other plain stdout writes.)
2. ``T <log_dir>`` — also write a timestamped copy to the
rotated log directory (``current`` + archived ``@*.s``
files). This is what ``hermes logs`` reads and what
persists across container restarts via the volume mount.
``T`` is non-sticky: it only prefixes lines for the next
action directive. We deliberately put ``T`` between ``1``
and the log dir (not before ``1``) so:
* ``docker logs`` shows raw lines — Python's logging
formatter has its own timestamps, and ``docker logs
--timestamps`` adds a third layer when desired. No
double-stamping in the most common reading path.
* The persisted file gets s6-log's own ISO 8601 timestamp
so even output that lacked a Python-logger timestamp
(rich banners, third-party libs' raw prints) is
correlatable in ``current``.
"""
import shlex
prof = shlex.quote(profile)
@@ -638,7 +674,7 @@ class S6ServiceManager:
f'log_dir="$HERMES_HOME/logs/gateways/{prof}"\n'
f'mkdir -p "$log_dir"\n'
f'chown -R hermes:hermes "$log_dir" 2>/dev/null || true\n'
f'exec s6-setuidgid hermes s6-log n10 s1000000 T "$log_dir"\n'
f'exec s6-setuidgid hermes s6-log 1 n10 s1000000 T "$log_dir"\n'
)
# -- lifecycle ---------------------------------------------------------

View File

@@ -197,7 +197,9 @@ all = [
# no native build path on Windows or modern macOS. With matrix in
# [all], `uv sync --locked` on Windows tried to build it from sdist
# and failed on `make`. Lazy-install routes that build to first use,
# where the user is expected to have a toolchain available.
# where the user is expected to have a toolchain available. The Docker
# image ships `libolm-dev` so the lazy-install can build python-olm
# from source in the container.
"hermes-agent[cron]",
"hermes-agent[cli]",
"hermes-agent[dev]",

View File

@@ -67,3 +67,21 @@ def test_resume_rehydrates_previous_summary_from_handoff_message():
assert "TURNS TO SUMMARIZE:" not in prompt
assert prompt.count(old_summary) == 1
assert f"[USER]: {SUMMARY_PREFIX}" not in prompt
def test_handoff_in_protected_head_populates_previous_summary_before_update():
"""A resumed protected-head handoff should restore iterative-summary state."""
compressor = _compressor()
old_summary = "PROTECTED-HEAD-SUMMARY durable facts from before restart"
seen_turns = []
def fake_generate_summary(turns_to_summarize, focus_topic=None):
seen_turns.extend(turns_to_summarize)
return "new summary from resumed turns"
with patch.object(compressor, "_generate_summary", side_effect=fake_generate_summary):
compressor.compress(_messages_with_handoff(old_summary))
assert compressor._previous_summary == old_summary
assert seen_turns
assert all(old_summary not in str(msg.get("content", "")) for msg in seen_turns)

View File

@@ -0,0 +1,290 @@
"""Regression tests for the docker-exec privilege-drop shim.
The shim (docker/hermes-exec-shim.sh, installed at /opt/hermes/bin/hermes)
exists to prevent the auth.json ownership-mismatch bug where
`docker exec <c> hermes login` would write /opt/data/auth.json as
root:root mode 0600, leaving the supervised gateway (UID 10000) unable
to read its own credentials and returning "Provider authentication
failed: Hermes is not logged into Nous Portal" on every message.
These tests verify:
1. ``docker exec <c> hermes …`` (defaulting to root) gets dropped to the
hermes user before the real binary runs.
2. ``docker exec --user hermes <c> hermes …`` (already non-root) short-
circuits and doesn't try to drop again.
3. Files written under $HERMES_HOME from a ``docker exec`` session land
as hermes:hermes — the actual user-visible invariant.
4. The HERMES_DOCKER_EXEC_AS_ROOT opt-out lets diagnostic sessions keep
running as root deliberately.
5. The main CMD path (``docker run <image> …``) is unaffected by the
PATH-shim ordering — no recursion, no behavior change.
"""
from __future__ import annotations
import subprocess
import time
from collections.abc import Iterator
import pytest
# How long to give a `docker run -d` container before declaring it not ready.
_RUN_READY_TIMEOUT_S = 20
def _wait_for_init(container: str) -> None:
"""Block until /init is up enough that `docker exec` is responsive."""
deadline = time.time() + _RUN_READY_TIMEOUT_S
while time.time() < deadline:
r = subprocess.run(
["docker", "exec", container, "true"],
capture_output=True, timeout=5,
)
if r.returncode == 0:
return
time.sleep(0.2)
pytest.fail(f"container {container} not responsive to docker exec within {_RUN_READY_TIMEOUT_S}s")
@pytest.fixture
def sleep_container(built_image: str, container_name: str) -> Iterator[str]:
"""Long-lived container running `sleep infinity` so we can docker exec into it."""
subprocess.run(
["docker", "rm", "-f", container_name],
capture_output=True, check=False,
)
r = subprocess.run(
["docker", "run", "-d", "--name", container_name, built_image,
"sleep", "infinity"],
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, f"docker run failed: {r.stderr}"
try:
_wait_for_init(container_name)
yield container_name
finally:
subprocess.run(
["docker", "rm", "-f", container_name],
capture_output=True, check=False,
)
def test_shim_drops_root_to_hermes_uid(sleep_container: str) -> None:
"""docker exec defaults to root; the shim should drop to uid 10000.
We invoke `hermes` with a Python-style `-c` shim equivalent — there's no
pure-hermes "print my uid" command, so we use the venv's python directly
via the shim's PATH lookup: `python -c 'print(os.getuid())'` is resolved
through the venv. But that bypasses the shim. Instead, we exploit the
fact that the venv's `hermes` is a console_scripts entry — under the
hood it's a tiny Python wrapper. We can't easily inject "print my uid"
into it without forking subcommands. Simplest approach: have `hermes`
do anything that writes to disk, then check the file's owner.
Use `hermes config set` which writes config.yaml under HERMES_HOME.
The resulting file ownership tells us what UID the shim ended up at.
"""
# Wipe any prior state.
subprocess.run(
["docker", "exec", "--user", "root", sleep_container,
"rm", "-f", "/opt/data/config.yaml"],
capture_output=True, check=False,
)
# Default docker exec (root) — should be dropped by the shim.
r = subprocess.run(
["docker", "exec", sleep_container,
"hermes", "config", "set", "_test.shim_marker", "1"],
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, f"config set failed: stdout={r.stdout!r} stderr={r.stderr!r}"
# The written file must be owned by hermes, not root.
r = subprocess.run(
["docker", "exec", sleep_container,
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
capture_output=True, text=True, timeout=10,
)
assert r.returncode == 0, f"stat failed: {r.stderr}"
assert r.stdout.strip() == "hermes:hermes", (
f"config.yaml owned by {r.stdout.strip()!r}, expected hermes:hermes. "
"The shim did not drop privileges before invoking hermes."
)
def test_shim_short_circuits_for_non_root_exec(sleep_container: str) -> None:
"""docker exec --user hermes already runs as 10000; shim should be a no-op.
Verified indirectly: the command must still succeed end-to-end. If the
shim incorrectly tried to drop privileges a second time (e.g. by
invoking s6-setuidgid which requires root), it would fail with
EPERM. A clean success proves the short-circuit fired.
"""
subprocess.run(
["docker", "exec", "--user", "root", sleep_container,
"rm", "-f", "/opt/data/config.yaml"],
capture_output=True, check=False,
)
r = subprocess.run(
["docker", "exec", "--user", "hermes", sleep_container,
"hermes", "config", "set", "_test.shim_short_circuit", "1"],
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, (
f"docker exec --user hermes failed: {r.stderr!r} stdout={r.stdout!r}. "
"If the shim mis-handled the non-root path, this would fail with EPERM."
)
# File still ends up hermes:hermes — orthogonally confirms uid.
r = subprocess.run(
["docker", "exec", sleep_container,
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
capture_output=True, text=True, timeout=10,
)
assert r.stdout.strip() == "hermes:hermes"
def test_shim_opt_out_keeps_root(sleep_container: str) -> None:
"""HERMES_DOCKER_EXEC_AS_ROOT=1 should suppress the privilege drop.
Reserved for diagnostic sessions where the operator deliberately
wants root semantics. Verified by writing a file and checking its
owner.
"""
subprocess.run(
["docker", "exec", "--user", "root", sleep_container,
"rm", "-f", "/opt/data/config.yaml"],
capture_output=True, check=False,
)
r = subprocess.run(
["docker", "exec",
"-e", "HERMES_DOCKER_EXEC_AS_ROOT=1",
sleep_container,
"hermes", "config", "set", "_test.opt_out", "1"],
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, f"opt-out invocation failed: {r.stderr}"
r = subprocess.run(
["docker", "exec", sleep_container,
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
capture_output=True, text=True, timeout=10,
)
assert r.stdout.strip() == "root:root", (
f"With HERMES_DOCKER_EXEC_AS_ROOT=1, expected root:root, "
f"got {r.stdout.strip()!r}"
)
@pytest.mark.parametrize("falsy_value", ["0", "false", "no", "", "garbage", "2"])
def test_shim_opt_out_strict_truthiness(
sleep_container: str, falsy_value: str,
) -> None:
"""Anything other than 1/true/yes (case-insensitive) does NOT opt out.
Strict truthiness so a typo (``HERMES_DOCKER_EXEC_AS_ROOT=0``) doesn't
silently keep the user as root. Mirrors the policy used by
``HERMES_GATEWAY_NO_SUPERVISE`` in #33583.
"""
subprocess.run(
["docker", "exec", "--user", "root", sleep_container,
"rm", "-f", "/opt/data/config.yaml"],
capture_output=True, check=False,
)
r = subprocess.run(
["docker", "exec",
"-e", f"HERMES_DOCKER_EXEC_AS_ROOT={falsy_value}",
sleep_container,
"hermes", "config", "set", "_test.falsy", "1"],
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, f"falsy value {falsy_value!r} caused failure: {r.stderr}"
r = subprocess.run(
["docker", "exec", sleep_container,
"stat", "-c", "%U:%G", "/opt/data/config.yaml"],
capture_output=True, text=True, timeout=10,
)
assert r.stdout.strip() == "hermes:hermes", (
f"falsy opt-out value {falsy_value!r} unexpectedly suppressed the drop; "
f"file owner is {r.stdout.strip()!r}, expected hermes:hermes"
)
def test_main_cmd_path_unaffected(built_image: str) -> None:
"""The CMD path (docker run <image> <args>) must still work.
The shim sits at /opt/hermes/bin earliest on PATH; main-wrapper.sh
invokes `s6-setuidgid hermes hermes <args>` which resolves `hermes`
through PATH. With the shim in the way, this could regress if the
shim recurses or interferes with TTY/exit-code propagation.
`chat --help` is cheap and exercises the full subcommand
passthrough path. The duplicate of test_main_invocation's
pre-existing test is intentional — that one would have passed
pre-shim too; this one specifically guards against shim regressions
in the CMD-as-main-program codepath.
"""
r = subprocess.run(
["docker", "run", "--rm", built_image, "chat", "--help"],
capture_output=True, text=True, timeout=60,
)
assert r.returncode == 0, f"CMD path broken by shim: stderr={r.stderr!r}"
assert "Traceback" not in r.stderr
def test_e2e_login_then_supervised_gateway_can_read_auth(
sleep_container: str,
) -> None:
"""End-to-end regression for the original bug.
Pre-shim: ``docker exec <c> hermes login`` (root) wrote
/opt/data/auth.json as root:root 0600. The supervised gateway (UID
10000) couldn't read it, _load_auth_store swallowed PermissionError
as a parse failure, and resolve_nous_runtime_credentials raised
"Hermes is not logged into Nous Portal" on every message.
We can't do a real OAuth login in a unit test, but we can stand in
for it by writing the same file shape via `hermes config set`-style
writes — what matters is the *file ownership invariant* downstream
of `_save_auth_store`. If the shim works, every file the
`docker exec` path produces is hermes-readable.
Specifically: pretend the operator ran `hermes login` (writes
auth.json) and verify (a) the file exists and (b) it's readable by
the hermes UID. We use `hermes auth list` since that touches the
auth store on the read side and would fail with the same
'not logged in' shape if the file was unreadable to uid 10000.
"""
# Have the shim-protected `docker exec` write the auth store.
# `hermes auth list` is read-only but still exercises _load_auth_store
# under the shim's UID. We invoke `hermes config set` first to
# provoke a write into HERMES_HOME so we have something concrete to
# owner-check.
r = subprocess.run(
["docker", "exec", sleep_container,
"hermes", "config", "set", "_test.e2e_marker", "1"],
capture_output=True, text=True, timeout=30,
)
assert r.returncode == 0, f"config set failed: {r.stderr}"
# The supervised UID (10000) must be able to read everything under
# HERMES_HOME that docker exec just wrote.
r = subprocess.run(
["docker", "exec", "--user", "hermes", sleep_container,
"find", "/opt/data", "-maxdepth", "2", "-type", "f",
"!", "-readable", "-print"],
capture_output=True, text=True, timeout=15,
)
assert r.returncode == 0, f"find failed: {r.stderr}"
unreadable = [ln for ln in r.stdout.splitlines() if ln.strip()]
assert not unreadable, (
"Files written by `docker exec` are unreadable to the hermes user "
f"(supervised gateway UID): {unreadable}. The shim failed to drop "
"privileges before the write."
)

View File

@@ -0,0 +1,104 @@
"""Regression test: ``hermes dump`` reports a real git SHA inside the container.
Background: ``.dockerignore`` excludes ``.git``, so ``git rev-parse HEAD``
fails inside the published image and ``hermes dump`` used to report
``version: ... [(unknown)]``. The Dockerfile now writes the build-time
``$HERMES_GIT_SHA`` build-arg to ``/opt/hermes/.hermes_build_sha`` and
``hermes_cli/build_info.py`` reads it as a fallback.
CI (``.github/workflows/docker-publish.yml``) always sets the build-arg
to ``${{ github.sha }}``. Local ``docker build`` (the ``built_image``
fixture in ``tests/docker/conftest.py``) does NOT — so locally the file
is absent and ``hermes dump`` correctly falls back to ``(unknown)``.
This test handles both cases:
* If ``/opt/hermes/.hermes_build_sha`` exists in the image, assert that
``hermes dump`` surfaces its content as the version SHA (not
``(unknown)``).
* If the file is absent, assert the legacy behaviour (``(unknown)``)
still holds — defensive guard against the helper accidentally
reporting bogus data from somewhere else.
"""
from __future__ import annotations
import re
import subprocess
_VERSION_LINE = re.compile(r"^version:\s+(?P<rest>.+)$", re.MULTILINE)
_SHA_BRACKET = re.compile(r"\[(?P<sha>[^\]]+)\]\s*$")
def _run_dump(image: str) -> str:
"""Return the stdout of ``docker run <image> dump``.
Relies on Docker's anonymous VOLUME for ``/opt/data`` (declared by the
Dockerfile) so the container's hermes user (UID 10000) can bootstrap
its config. Anonymous volumes are auto-cleaned by ``--rm``, so unlike
a host bind-mount we don't have to chown anything to UID 10000 (which
would break cleanup on non-root hosts).
"""
r = subprocess.run(
["docker", "run", "--rm", image, "dump"],
capture_output=True, text=True, timeout=120,
)
assert r.returncode == 0, (
f"hermes dump exited {r.returncode}: "
f"stderr={r.stderr[-1000:]!r}\nstdout={r.stdout[-1000:]!r}"
)
return r.stdout
def _read_baked_sha_from_image(image: str) -> str | None:
"""Return the ``/opt/hermes/.hermes_build_sha`` content, or None if absent."""
r = subprocess.run(
[
"docker", "run", "--rm", "--entrypoint", "cat", image,
"/opt/hermes/.hermes_build_sha",
],
capture_output=True, text=True, timeout=30,
)
if r.returncode != 0:
return None
return r.stdout.strip() or None
def test_dump_reports_baked_sha_when_present(built_image: str) -> None:
"""When the image was built with ``HERMES_GIT_SHA``, dump must surface it.
Together with the smoke-test action (which exercises ``--help``), this
closes the regression loop for the missing-sha bug: any future change
that breaks the baked-file -> dump pipeline will fail CI here.
"""
baked = _read_baked_sha_from_image(built_image)
stdout = _run_dump(built_image)
match = _VERSION_LINE.search(stdout)
assert match, f"no `version:` line in dump output:\n{stdout[:2000]}"
sha_match = _SHA_BRACKET.search(match.group("rest"))
assert sha_match, (
f"`version:` line missing [<sha>] bracket: {match.group('rest')!r}"
)
reported = sha_match.group("sha")
if baked is None:
# Local-build path: no build-arg was passed. Verify the legacy
# fallback ``(unknown)`` is intact — guards against the helper
# ever inventing a SHA from thin air.
assert reported == "(unknown)", (
f"expected '(unknown)' when no SHA baked, got {reported!r}"
)
return
# CI path: build-arg was set, baked file exists. ``hermes dump``
# truncates to 8 chars via ``git rev-parse --short=8`` semantics.
assert reported != "(unknown)", (
"baked SHA file present in image but dump still reported "
f"'(unknown)' — the build-info fallback is broken. "
f"Baked file content: {baked!r}"
)
assert reported == baked[:8], (
f"dump reported {reported!r} but baked file contained {baked!r} "
f"(expected first 8 chars: {baked[:8]!r})"
)

View File

@@ -327,3 +327,69 @@ def test_dashboard_supervised_when_env_set(
assert _svstat_wants_up(container_name, "dashboard"), (
f"dashboard slot not up: {_svstat(container_name, 'dashboard')!r}"
)
def test_supervised_gateway_stdout_reaches_docker_logs(
built_image: str, container_name: str,
) -> None:
"""The supervised gateway's stdout — including the rich-console
startup banner — must reach ``docker logs``, not just the rotated
log file under ``${HERMES_HOME}/logs/gateways/<profile>/current``.
Without the ``1`` action directive in ``_render_log_run``, s6-log
swallows the gateway's stdout into the file and ``docker logs``
only sees stderr (Python ``logging`` defaults to stderr). That's
a poor user experience: the iconic "Hermes Gateway Starting…"
banner with the ⚕ symbol is the most visible "yes, your gateway
started" signal, and forcing users to ``docker exec`` + ``tail``
the log file just to see it is friction users don't expect.
With the ``1`` directive, s6-log forwards every line to its own
stdout (which propagates up through the s6-supervise pipeline to
/init's stdout = container stdout = ``docker logs``) AND also
writes a timestamped copy to the rotated file. Best of both.
We assert by looking for the literal banner glyph (``⚕``) — a
distinctive character that won't appear in stderr-routed
Python-logging output, so its presence in ``docker logs`` proves
the stdout-tee is working.
"""
subprocess.run(
["docker", "run", "-d", "--name", container_name, built_image,
"gateway", "run"],
check=True, capture_output=True, timeout=30,
)
# Banner is printed during gateway startup — give it time to
# initialize past the imports + config-load phase.
time.sleep(8)
logs = subprocess.run(
["docker", "logs", container_name],
capture_output=True, text=True, timeout=10,
)
combined = logs.stdout + logs.stderr
# The banner ⚕ symbol is the load-bearing assertion — it's unique
# to gateway startup stdout output and won't appear in stderr
# (Python logging) or s6 boot messages.
assert "" in combined or "Hermes Gateway Starting" in combined, (
"Supervised gateway's stdout banner did not reach docker logs. "
"This means the `1` action directive in _render_log_run isn't "
"forwarding stdout to /init. "
f"docker logs (last 2000 chars):\n{combined[-2000:]}\n"
f"file contents:\n{_sh(container_name, 'cat /opt/data/logs/gateways/default/current').stdout}"
)
# Cross-check: the same banner must also be in the rotated log
# file (we kept the file destination, just added stdout). The
# file version has s6-log's ISO 8601 timestamp prefix; the
# docker logs version is raw.
file_contents = _sh(
container_name, "cat /opt/data/logs/gateways/default/current",
).stdout
assert "" in file_contents or "Hermes Gateway Starting" in file_contents, (
"Banner also missing from rotated log file — the file "
"destination may have been dropped by the new s6-log script. "
f"File contents:\n{file_contents}"
)

View File

@@ -61,3 +61,56 @@ def test_get_git_banner_state_reads_origin_and_head(tmp_path):
state = banner.get_git_banner_state(repo_dir)
assert state == {"upstream": "b2f477a3", "local": "af8aad31", "ahead": 3}
def test_get_git_banner_state_falls_back_to_build_sha_when_no_repo():
"""Docker image case: no .git checkout — baked build SHA fills the gap.
``_resolve_repo_dir`` returns None when neither the running code's
parent nor ``$HERMES_HOME/hermes-agent/`` is a git repo (the canonical
case inside the published container, where .git is dockerignored).
The banner should still report the build SHA so support bug reports
can identify the running commit.
"""
from hermes_cli import banner
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
state = banner.get_git_banner_state()
assert state == {"upstream": "abcdef12", "local": "abcdef12", "ahead": 0}
def test_get_git_banner_state_returns_none_when_no_repo_and_no_build_sha():
"""Pip-installed wheel with neither git checkout nor baked SHA → None.
Banner correctly omits the upstream/local suffix in this case.
"""
from hermes_cli import banner
with patch.object(banner, "_resolve_repo_dir", return_value=None), \
patch("hermes_cli.build_info.get_build_sha", return_value=None):
state = banner.get_git_banner_state()
assert state is None
def test_get_git_banner_state_falls_back_when_live_git_returns_nothing(tmp_path):
"""Shallow clone without origin/main → still surface build SHA if baked.
Some install paths (e.g. ``git clone --depth 1`` without a remote) have
a ``.git`` directory but ``git rev-parse origin/main`` fails. When that
happens AND a baked SHA exists, return the baked one instead of None.
"""
from hermes_cli import banner
repo_dir = tmp_path / "repo"
(repo_dir / ".git").mkdir(parents=True)
# All git invocations fail (returncode=1, empty stdout).
failed = MagicMock(returncode=1, stdout="")
with patch("hermes_cli.banner.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
state = banner.get_git_banner_state(repo_dir)
assert state == {"upstream": "cafef00d", "local": "cafef00d", "ahead": 0}

View File

@@ -0,0 +1,78 @@
"""Tests for hermes_cli.build_info — baked-in build SHA resolution.
The build SHA is written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg
into ``<project_root>/.hermes_build_sha``. These tests cover the read-side
helper: missing file, malformed file, truncation, and error tolerance.
"""
from pathlib import Path
from unittest.mock import patch
def test_get_build_sha_returns_none_when_file_absent(tmp_path):
"""Source installs: no file present → None, callers fall back to git."""
from hermes_cli import build_info
missing = tmp_path / ".hermes_build_sha" # never created
with patch.object(build_info, "_BUILD_SHA_FILE", missing):
assert build_info.get_build_sha() is None
def test_get_build_sha_reads_baked_file(tmp_path):
"""Docker image case: file exists with full 40-char SHA → truncated to 8."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text("abcdef1234567890abcdef1234567890abcdef12\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() == "abcdef12"
def test_get_build_sha_respects_short_argument(tmp_path):
"""``short=N`` truncates to N chars; ``short<=0`` returns full SHA."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
full_sha = "abcdef1234567890abcdef1234567890abcdef12"
sha_file.write_text(full_sha + "\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha(short=12) == "abcdef123456"
assert build_info.get_build_sha(short=0) == full_sha
assert build_info.get_build_sha(short=-1) == full_sha
def test_get_build_sha_strips_whitespace(tmp_path):
"""The Dockerfile uses ``printf '%s\\n'`` — strip the trailing newline."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text(" abcdef1234567890\n\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() == "abcdef12"
def test_get_build_sha_returns_none_for_empty_file(tmp_path):
"""A whitespace-only file is treated as absent."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text(" \n\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file):
assert build_info.get_build_sha() is None
def test_get_build_sha_swallows_read_errors(tmp_path):
"""Any IO exception from the read returns None — never raises."""
from hermes_cli import build_info
sha_file = tmp_path / ".hermes_build_sha"
sha_file.write_text("abcdef1234567890\n")
with patch.object(build_info, "_BUILD_SHA_FILE", sha_file), \
patch.object(Path, "read_text", side_effect=OSError("boom")):
assert build_info.get_build_sha() is None

View File

@@ -0,0 +1,185 @@
"""Tests for ``hermes update`` / ``--check`` inside the Docker container.
Background: ``.dockerignore`` excludes ``.git``, so the existing git-pull
update path can never succeed inside the published image. Before this
fix, ``hermes update`` would fall through to ``"✗ Not a git repository.
Please reinstall: curl ... install.sh"`` — that script installs a *new*
host-side Hermes, not an update to the running container, so the message
was actively misleading.
These tests pin the new behaviour: when ``detect_install_method`` reports
``"docker"`` (stamped by ``docker/stage2-hook.sh``), both the apply path
(``cmd_update``) and the check path (``_cmd_update_check``) print the
``docker pull`` guidance from ``format_docker_update_message`` and exit
with status 1, without running ``git fetch`` / ``subprocess.run``.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from hermes_cli.main import _cmd_update_check, cmd_update
# ---------- cmd_update (apply path) ----------
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_in_docker_prints_guidance_and_exits(
mock_run, _mock_method, _mock_managed, capsys
):
"""``hermes update`` inside Docker → friendly message + exit 1, no git calls."""
with pytest.raises(SystemExit) as excinfo:
cmd_update(SimpleNamespace(check=False))
assert excinfo.value.code == 1
out = capsys.readouterr().out
# Spot-check the key guidance — exhaustive wording is locked in by the
# config-module test below to keep these CLI tests resilient to copy edits.
assert "doesn't apply inside the Docker container" in out
assert "docker pull nousresearch/hermes-agent:latest" in out
# No git invocations — the early-return must beat every git command.
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == [], f"expected no git calls, got: {git_calls}"
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_check_in_docker_prints_guidance_and_exits(
mock_run, _mock_method, _mock_managed, capsys
):
"""``hermes update --check`` inside Docker → same message + exit 1, no fetch."""
with pytest.raises(SystemExit) as excinfo:
cmd_update(SimpleNamespace(check=True, branch=None))
assert excinfo.value.code == 1
out = capsys.readouterr().out
assert "doesn't apply inside the Docker container" in out
assert "docker pull nousresearch/hermes-agent:latest" in out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == [], f"expected no git calls, got: {git_calls}"
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_in_docker_ignores_yes_and_force(
mock_run, _mock_method, _mock_managed, capsys
):
"""``--yes`` / ``--force`` don't bypass the Docker bail-out.
The point of the bail-out is "git pull will never work here", so even
a user trying to barge through with ``--yes --force`` should see the
docker-pull guidance.
"""
with pytest.raises(SystemExit):
cmd_update(SimpleNamespace(check=False, yes=True, force=True))
assert "docker pull" in capsys.readouterr().out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == []
# ---------- _cmd_update_check (check path, direct entry) ----------
@patch("hermes_cli.config.detect_install_method", return_value="docker")
@patch("subprocess.run")
def test_cmd_update_check_direct_in_docker(mock_run, _mock_method, capsys):
"""Calling ``_cmd_update_check`` directly (no apply path) also bails."""
with pytest.raises(SystemExit) as excinfo:
_cmd_update_check()
assert excinfo.value.code == 1
assert "docker pull" in capsys.readouterr().out
git_calls = [c for c in mock_run.call_args_list if c.args and c.args[0] and "git" in str(c.args[0][0])]
assert git_calls == []
# ---------- Non-Docker installs unaffected ----------
@patch("hermes_cli.config.is_managed", return_value=False)
@patch("hermes_cli.config.detect_install_method", return_value="git")
@patch(
"subprocess.run",
return_value=SimpleNamespace(returncode=0, stdout="0\n", stderr=""),
)
def test_cmd_update_on_git_install_does_not_print_docker_message(
_mock_run, _mock_method, _mock_managed, capsys
):
"""Source/git installs MUST NOT hit the Docker branch.
Regression guard: an over-eager detection refactor could accidentally
route git users through the docker-pull message. We swallow
SystemExit / unrelated errors from the rest of the update flow —
those don't matter for this assertion; what matters is that the
docker text is absent.
``subprocess.run`` is mocked because the git path will otherwise shell
out to ``git fetch upstream`` / ``git fetch origin`` — on CI runners
with no ``upstream`` remote configured this can hang past the 30s
pytest-timeout depending on git's network behaviour. The stub
returns a successful CompletedProcess-shaped object with ``"0\\n"``
stdout, which both keeps the flow shell-free AND parses cleanly as
the "0 commits behind" rev-list output the check path later parses
via ``int(rev_result.stdout.strip())``.
"""
try:
cmd_update(SimpleNamespace(check=True, branch=None))
except (SystemExit, Exception):
# Update flow may exit for unrelated reasons in a stubbed env —
# that's fine; we only care about the banner not appearing.
pass
assert "doesn't apply inside the Docker container" not in capsys.readouterr().out
@patch("hermes_cli.config.detect_install_method", return_value="pip")
@patch("hermes_cli.banner.check_via_pypi", return_value=0)
def test_cmd_update_check_on_pip_install_still_uses_pypi(
_mock_pypi, _mock_method, capsys
):
"""PyPI installs route to PyPI check, not the Docker bail-out."""
_cmd_update_check()
out = capsys.readouterr().out
assert "Already up to date" in out
assert "doesn't apply inside the Docker container" not in out
# ---------- format_docker_update_message — content lock ----------
def test_format_docker_update_message_contents():
"""Lock in the high-value content of the Docker update message.
These are the bits a user actually needs to act on; if any of them
disappear in a copy edit, the message has lost its value. Specific
wording around them is free to evolve (we don't assert full text).
"""
from hermes_cli.config import format_docker_update_message
msg = format_docker_update_message()
# Primary command — the entire reason this message exists.
assert "docker pull nousresearch/hermes-agent:latest" in msg
# The four key concepts the message must cover:
assert "restart" in msg.lower(), "must explain that a restart is required"
assert "--version" in msg, "must show how to verify the new version"
assert ":latest" in msg, "must mention tag pinning caveat"
assert "HERMES_HOME" in msg or "/opt/data" in msg, (
"must address config persistence across upgrades"
)
# Acknowledges that forks exist (build-your-own-image escape hatch).
assert "fork" in msg.lower() or "Dockerfile" in msg

View File

@@ -0,0 +1,118 @@
"""Tests for hermes_cli.dump._get_git_commit — git SHA resolution for ``hermes dump``.
``hermes dump`` prints the running commit so support bug reports identify the
exact version. Source installs resolve it live via ``git rev-parse``; the
published Docker image excludes ``.git`` and falls back to the baked SHA
written by the Dockerfile's ``HERMES_GIT_SHA`` build-arg.
These tests cover both paths plus the failure modes (no git, no baked file).
"""
from unittest.mock import MagicMock, patch
def test_get_git_commit_uses_live_git_when_available(tmp_path):
"""Source install: ``git rev-parse --short=8 HEAD`` wins; no fallback."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
git_result = MagicMock(returncode=0, stdout="deadbeef\n")
# build_info should NOT be consulted when live git succeeds.
with patch("hermes_cli.dump.subprocess.run", return_value=git_result) as mock_run, \
patch("hermes_cli.build_info.get_build_sha") as mock_build:
commit = dump._get_git_commit(repo_dir)
assert commit == "deadbeef"
mock_run.assert_called_once()
mock_build.assert_not_called()
def test_get_git_commit_falls_back_to_build_sha_when_live_git_fails(tmp_path):
"""Docker image case: live git returns non-zero → use baked SHA."""
from hermes_cli import dump
repo_dir = tmp_path / "no-git-here"
repo_dir.mkdir()
failed = MagicMock(returncode=128, stdout="")
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="cafef00d"):
commit = dump._get_git_commit(repo_dir)
assert commit == "cafef00d"
def test_get_git_commit_falls_back_when_git_returns_empty_stdout(tmp_path):
"""Edge case: git exits 0 but prints nothing — still try the baked SHA."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
empty = MagicMock(returncode=0, stdout="\n")
with patch("hermes_cli.dump.subprocess.run", return_value=empty), \
patch("hermes_cli.build_info.get_build_sha", return_value="abcdef12"):
commit = dump._get_git_commit(repo_dir)
assert commit == "abcdef12"
def test_get_git_commit_falls_back_when_git_raises(tmp_path):
"""git binary missing (e.g. minimal container w/o git) → baked SHA path."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
with patch("hermes_cli.dump.subprocess.run", side_effect=FileNotFoundError("git")), \
patch("hermes_cli.build_info.get_build_sha", return_value="feedface"):
commit = dump._get_git_commit(repo_dir)
assert commit == "feedface"
def test_get_git_commit_returns_unknown_when_neither_source_available(tmp_path):
"""Pip-installed wheel: no git, no baked SHA → '(unknown)' (legacy contract)."""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
failed = MagicMock(returncode=128, stdout="")
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value=None):
commit = dump._get_git_commit(repo_dir)
assert commit == "(unknown)"
def test_get_git_commit_output_format_identical_between_sources(tmp_path):
"""Regression guard: live-git and baked-SHA outputs share the same shape.
Ben explicitly asked for identical output between Docker and source installs
so support tooling that parses ``hermes dump`` doesn't have to special-case
container builds. Both paths must return a bare 8-char SHA — no prefix,
no suffix, no annotation.
"""
from hermes_cli import dump
repo_dir = tmp_path / "repo"
repo_dir.mkdir()
# Live-git path.
git_result = MagicMock(returncode=0, stdout="b2f477a3\n")
with patch("hermes_cli.dump.subprocess.run", return_value=git_result):
live = dump._get_git_commit(repo_dir)
# Baked-SHA path.
failed = MagicMock(returncode=128, stdout="")
with patch("hermes_cli.dump.subprocess.run", return_value=failed), \
patch("hermes_cli.build_info.get_build_sha", return_value="b2f477a3"):
baked = dump._get_git_commit(repo_dir)
assert live == baked == "b2f477a3"
# Same length, same charset — no decoration in either branch.
assert len(live) == 8
assert all(c in "0123456789abcdef" for c in live)

View File

@@ -3805,3 +3805,66 @@ def test_dispatch_once_still_reaps_via_extracted_fn(kanban_home):
pids = kb.reap_worker_zombies()
assert pids == [99999]
# ---------------------------------------------------------------------------
# connect_closing(): context manager that actually closes the FD
# Regression coverage for #33159 (kanban.db FD leak — gateway crashes after
# ~4 days). sqlite3.Connection's built-in __exit__ commits/rollbacks but
# does NOT close, so `with kb.connect() as conn:` leaks the FD in
# long-lived processes (gateway run_slash, dashboard decompose handler).
# `connect_closing()` is the leak-safe replacement.
# ---------------------------------------------------------------------------
def test_connect_closing_closes_connection_on_exit(tmp_path):
"""The new context manager MUST actually close the underlying FD."""
db_path = tmp_path / "kanban.db"
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with kb.connect_closing(db_path=db_path) as conn:
conn.execute("SELECT 1").fetchone()
# After exit, the connection MUST be closed — subsequent execute
# should raise ProgrammingError.
with pytest.raises(sqlite3.ProgrammingError):
conn.execute("SELECT 1")
def test_connect_closing_closes_on_exception(tmp_path):
"""Connection closed even when the body raises."""
db_path = tmp_path / "kanban.db"
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
captured = []
with pytest.raises(RuntimeError, match="boom"):
with kb.connect_closing(db_path=db_path) as conn:
captured.append(conn)
raise RuntimeError("boom")
with pytest.raises(sqlite3.ProgrammingError):
captured[0].execute("SELECT 1")
def test_connect_closing_yields_usable_connection(tmp_path):
"""Smoke test: schema is initialized and basic ops work."""
db_path = tmp_path / "kanban.db"
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with kb.connect_closing(db_path=db_path) as conn:
tid = kb.create_task(conn, title="closing-cm test")
task = kb.get_task(conn, tid)
assert task is not None
assert task.title == "closing-cm test"
def test_bare_connect_does_not_close_on_context_exit(tmp_path):
"""Document the leak that connect_closing exists to prevent.
sqlite3.Connection's __exit__ commits/rollbacks but doesn't close.
This is the upstream behaviour we cannot change; the regression
guard is to make sure connect_closing() does the right thing.
"""
db_path = tmp_path / "kanban.db"
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
with kb.connect(db_path=db_path) as conn:
pass
# Still usable after with-block exit (the leak).
conn.execute("SELECT 1").fetchone()
conn.close() # explicit close to avoid leaking THIS test

View File

@@ -536,6 +536,7 @@ def test_s6_register_creates_service_dir_and_triggers_scan(
assert run_path.is_file()
assert run_path.stat().st_mode & 0o111 # executable
run_text = run_path.read_text()
assert "export HOME=/opt/data" in run_text
assert "hermes -p coder gateway run" in run_text
assert "s6-setuidgid hermes" in run_text
# Sentinel marking this as the supervised-child invocation. Without
@@ -555,6 +556,16 @@ def test_s6_register_creates_service_dir_and_triggers_scan(
assert "/opt/data/logs/gateways/coder" not in log_text, (
"log_dir was hard-coded; must use ${HERMES_HOME} at run time"
)
# `1` action directive forwards lines to stdout BEFORE the file
# destination so the supervised gateway's stdout (including the
# rich-console banner and plain print() output) reaches docker
# logs, not just the rotated file. See _render_log_run's docstring
# for the full output-routing rationale.
assert "s6-log 1 " in log_text, (
"log/run must include the `1` action directive before the file "
"destination so supervised stdout reaches docker logs. Saw: "
f"{log_text!r}"
)
# s6-svscanctl -a was invoked against the scandir
assert any(
@@ -576,6 +587,15 @@ def test_s6_register_extra_env_is_quoted(s6_scandir, fake_subprocess_run) -> Non
assert "export QUOTED='a'\"'\"'b'" in run_text
def test_render_run_script_resets_home_before_exec() -> None:
from hermes_cli.service_manager import S6ServiceManager
run_text = S6ServiceManager._render_run_script("coder", {})
assert "export HOME=/opt/data" in run_text
assert "exec s6-setuidgid hermes hermes -p coder gateway run" in run_text
def test_s6_register_rejects_invalid_profile_name(s6_scandir) -> None:
from hermes_cli.service_manager import S6ServiceManager
mgr = S6ServiceManager(scandir=s6_scandir)

View File

@@ -76,6 +76,78 @@ def test_background_review_shuts_down_memory_provider_before_close(monkeypatch):
]
def test_background_review_summarizer_receives_captured_messages_after_close(monkeypatch):
"""The action summarizer must see review messages even after close cleanup.
Regression for the bug where ``review_messages`` was snapshot AFTER
``review_agent.close()``. close() is allowed to clean per-session state
(including ``_session_messages``), so the summarizer would receive an
empty list and the user-visible self-improvement summary would silently
disappear. The fix snapshots ``_session_messages`` before teardown.
"""
import json
import agent.background_review as bg_review
review_tool_message = {
"role": "tool",
"tool_call_id": "call_bg",
"content": json.dumps(
{"success": True, "message": "Entry added", "target": "memory"}
),
}
captured: dict = {}
events: list[str] = []
class FakeReviewAgent:
def __init__(self, **kwargs):
self._session_messages = []
def run_conversation(self, **kwargs):
events.append("run_conversation")
self._session_messages = [review_tool_message]
def shutdown_memory_provider(self):
events.append("shutdown_memory_provider")
def close(self):
events.append("close")
# close() is allowed to clean _session_messages — the fix
# must have snapshot them before this runs.
self._session_messages = []
def fake_summarize(review_messages, prior_snapshot):
events.append("summarize")
captured["review_messages"] = list(review_messages)
captured["prior_snapshot"] = list(prior_snapshot)
return []
monkeypatch.setattr(run_agent_module, "AIAgent", FakeReviewAgent)
monkeypatch.setattr(run_agent_module.threading, "Thread", ImmediateThread)
monkeypatch.setattr(
bg_review,
"summarize_background_review_actions",
fake_summarize,
)
messages_snapshot = [{"role": "user", "content": "hi"}]
agent = _bare_agent()
AIAgent._spawn_background_review(
agent,
messages_snapshot=messages_snapshot,
review_memory=True,
)
assert events == [
"run_conversation",
"shutdown_memory_provider",
"close",
"summarize",
]
assert captured["review_messages"] == [review_tool_message]
assert captured["prior_snapshot"] == messages_snapshot
def test_background_review_installs_auto_deny_approval_callback(monkeypatch):
"""Regression guard for #15216.

View File

@@ -0,0 +1,15 @@
"""Regression tests for Docker HOME overrides under s6/with-contenv."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DASHBOARD_RUN = REPO_ROOT / "docker" / "s6-rc.d" / "dashboard" / "run"
def test_dashboard_run_resets_home_before_dropping_privileges() -> None:
text = DASHBOARD_RUN.read_text(encoding="utf-8")
assert "#!/command/with-contenv sh" in text
assert "export HOME=/opt/data" in text
assert "exec s6-setuidgid hermes hermes dashboard" in text

View File

@@ -317,6 +317,54 @@ FAL_MODELS: Dict[str, Dict[str, Any]] = {
},
"upscale": False,
},
# Krea 2 — Krea's first foundation image model, day-0 partner launch on
# fal (2026-05-27). Same model family as our direct ``plugins/image_gen/krea``
# backend, exposed here for users who prefer to bill through their
# existing FAL key / Nous Portal subscription rather than register
# directly with Krea. Both variants share the same parameter schema —
# only model id, price, and recommended use case differ.
"fal-ai/krea/v2/medium/text-to-image": {
"display": "Krea 2 Medium",
"speed": "~15-25s",
"strengths": "Illustration, anime, painting, expressive/artistic styles",
"price": "$0.030 (text) / $0.035 (style refs)",
"size_style": "aspect_ratio",
# Krea natively accepts 1:1, 4:3, 3:2, 16:9, 2.35:1, 4:5, 2:3, 9:16 —
# we map our 3 abstract ratios to the closest match.
"sizes": {
"landscape": "16:9",
"square": "1:1",
"portrait": "9:16",
},
"defaults": {
"creativity": "medium",
},
"supports": {
"prompt", "aspect_ratio", "creativity", "seed",
"image_style_references",
},
"upscale": False,
},
"fal-ai/krea/v2/large/text-to-image": {
"display": "Krea 2 Large",
"speed": "~25-60s",
"strengths": "Photorealism, raw textured looks (motion blur, grain, film)",
"price": "$0.060 (text) / $0.065 (style refs)",
"size_style": "aspect_ratio",
"sizes": {
"landscape": "16:9",
"square": "1:1",
"portrait": "9:16",
},
"defaults": {
"creativity": "medium",
},
"supports": {
"prompt", "aspect_ratio", "creativity", "seed",
"image_style_references",
},
"upscale": False,
},
}
# Default model is the fastest reasonable option. Kept cheap and sub-1s.

View File

@@ -2,10 +2,12 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import {
Routes,
Route,
@@ -31,6 +33,8 @@ import {
Menu,
MessageSquare,
Package,
PanelLeftClose,
PanelLeftOpen,
Puzzle,
RotateCw,
Settings,
@@ -44,14 +48,15 @@ import {
Zap,
} from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Typography } from "@/components/NouiTypography";
import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop";
import { SidebarFooter } from "@/components/SidebarFooter";
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
import { SidebarStatusStrip, gatewayLine } from "@/components/SidebarStatusStrip";
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { AuthWidget } from "@/components/AuthWidget";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import { useSystemActions } from "@/contexts/useSystemActions";
@@ -77,6 +82,7 @@ import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
import { api } from "@/lib/api";
import type { StatusResponse } from "@/lib/api";
function RootRedirect() {
return <Navigate to="/sessions" replace />;
@@ -306,6 +312,8 @@ function buildRoutes(
return routes;
}
const SIDEBAR_COLLAPSED_KEY = "hermes-sidebar-collapsed";
export default function App() {
const { t } = useI18n();
const { pathname } = useLocation();
@@ -313,6 +321,27 @@ export default function App() {
const { theme } = useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []);
const [collapsed, setCollapsed] = useState(() => {
try {
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true";
} catch {
return false;
}
});
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
} catch { /* localStorage may be unavailable in private browsing */ }
return next;
});
}, []);
const isMobile = useBelowBreakpoint(1024);
const isDesktopCollapsed = collapsed && !isMobile;
const tooltipWarmRef = useRef(0);
const sidebarStatus = useSidebarStatus();
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
const normalizedPath = pathname.replace(/\/$/, "") || "/";
const isChatRoute = normalizedPath === "/chat";
@@ -483,9 +512,11 @@ export default function App() {
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
"border-r border-current/20",
"bg-background-base/95 backdrop-blur-sm",
"transition-transform duration-200 ease-out",
"transition-[transform] duration-200 ease-out",
mobileOpen ? "translate-x-0" : "-translate-x-full",
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0",
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0 lg:overflow-hidden",
"lg:transition-[width] lg:duration-[600ms] lg:ease-[cubic-bezier(0.33,1.35,0.62,1)]",
collapsed && "lg:w-14",
)}
style={{
background: "var(--component-sidebar-background)",
@@ -495,11 +526,17 @@ export default function App() {
>
<div
className={cn(
"flex h-14 shrink-0 items-center justify-between gap-2 px-4",
"flex h-14 shrink-0 items-center gap-2",
"border-b border-current/20",
collapsed ? "lg:justify-center lg:px-0" : "px-4 justify-between",
)}
>
<div className="flex items-center gap-2">
<div
className={cn(
"flex items-center gap-2",
collapsed && "lg:hidden",
)}
>
<PluginSlot name="header-left" />
<Typography
@@ -521,6 +558,22 @@ export default function App() {
>
<X />
</Button>
<Button
ghost
size="icon"
onClick={toggleCollapsed}
aria-label={
collapsed ? t.common.expand : t.common.collapse
}
className="hidden lg:flex text-text-secondary hover:text-midground"
>
{collapsed ? (
<PanelLeftOpen className="h-4 w-4" />
) : (
<PanelLeftClose className="h-4 w-4" />
)}
</Button>
</div>
<nav
@@ -531,9 +584,11 @@ export default function App() {
{sidebarNav.coreItems.map((item) => (
<SidebarNavLink
closeMobile={closeMobile}
collapsed={isDesktopCollapsed}
item={item}
key={item.path}
t={t}
tooltipWarmRef={tooltipWarmRef}
/>
))}
</ul>
@@ -548,6 +603,7 @@ export default function App() {
className={cn(
"px-5 pt-2.5 pb-1",
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
isDesktopCollapsed && "lg:hidden",
)}
id="hermes-sidebar-plugin-nav-heading"
>
@@ -558,9 +614,11 @@ export default function App() {
{sidebarNav.pluginItems.map((item) => (
<SidebarNavLink
closeMobile={closeMobile}
collapsed={isDesktopCollapsed}
item={item}
key={item.path}
t={t}
tooltipWarmRef={tooltipWarmRef}
/>
))}
</ul>
@@ -568,24 +626,58 @@ export default function App() {
)}
</nav>
<SidebarSystemActions onNavigate={closeMobile} />
<SidebarSystemActions
collapsed={isDesktopCollapsed}
onNavigate={closeMobile}
status={sidebarStatus}
tooltipWarmRef={tooltipWarmRef}
/>
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"flex shrink-0 items-center gap-2",
"px-3 py-2",
"border-t border-current/20",
isDesktopCollapsed
? "lg:flex-col lg:items-start lg:gap-3 lg:py-3"
: "justify-between",
)}
>
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
"flex min-w-0 items-center gap-2",
isDesktopCollapsed && "lg:flex-col lg:items-start",
)}
>
<PluginSlot name="header-right" />
<ThemeSwitcher dropUp />
<LanguageSwitcher dropUp />
<SidebarIconWithTooltip
collapsed={isDesktopCollapsed}
label={t.theme?.switchTheme ?? "Switch theme"}
tooltipWarmRef={tooltipWarmRef}
>
<ThemeSwitcher collapsed={isDesktopCollapsed} dropUp />
</SidebarIconWithTooltip>
<SidebarIconWithTooltip
collapsed={isDesktopCollapsed}
label={t.language.switchTo}
tooltipWarmRef={tooltipWarmRef}
>
<LanguageSwitcher collapsed={isDesktopCollapsed} dropUp />
</SidebarIconWithTooltip>
</div>
</div>
<AuthWidget />
<SidebarFooter />
<div
className={cn(
"flex shrink-0 flex-col",
isDesktopCollapsed && "lg:hidden",
)}
>
<AuthWidget />
<SidebarFooter status={sidebarStatus} />
</div>
</aside>
<PageHeaderProvider pluginTabs={pluginTabMeta}>
@@ -660,22 +752,37 @@ export default function App() {
);
}
function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
function SidebarNavLink({
closeMobile,
collapsed,
item,
tooltipWarmRef,
t,
}: SidebarNavLinkProps) {
const { path, label, labelKey, icon: Icon } = item;
const liRef = useRef<HTMLLIElement>(null);
const [hovered, setHovered] = useState(false);
const navLabel = labelKey
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
: label;
return (
<li>
<li
ref={liRef}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
>
<NavLink
to={path}
end={path === "/sessions"}
onClick={closeMobile}
aria-label={collapsed ? navLabel : undefined}
onFocus={collapsed ? () => setHovered(true) : undefined}
onBlur={collapsed ? () => setHovered(false) : undefined}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3",
"group/nav relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-display uppercase text-sm tracking-[0.12em]",
"whitespace-nowrap transition-colors cursor-pointer",
@@ -692,11 +799,19 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{navLabel}</span>
<span
className={cn(
"truncate transition-opacity duration-300",
collapsed ? "lg:opacity-0" : "lg:opacity-100",
)}
>
{navLabel}
</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/nav:opacity-5"
/>
{isActive && (
@@ -709,11 +824,20 @@ function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
</>
)}
</NavLink>
{collapsed && hovered && liRef.current && (
<SidebarTooltip anchor={liRef.current} label={navLabel} warmRef={tooltipWarmRef} />
)}
</li>
);
}
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
function SidebarSystemActions({
collapsed,
onNavigate,
status,
tooltipWarmRef,
}: SidebarSystemActionsProps) {
const { t } = useI18n();
const navigate = useNavigate();
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
@@ -755,75 +879,248 @@ function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
className={cn(
"px-5 pt-0.5 pb-0.5",
"font-mondwest text-display text-xs tracking-[0.12em] text-text-tertiary",
collapsed && "lg:hidden",
)}
>
{t.app.system}
</span>
<SidebarStatusStrip />
<div className={cn(collapsed && "lg:hidden")}>
<SidebarStatusStrip status={status} />
</div>
<GatewayDot collapsed={collapsed} status={status} tooltipWarmRef={tooltipWarmRef} />
<ul className="flex flex-col">
{items.map(({ action, icon: Icon, label, runningLabel, spin }) => {
const isPending = pendingAction === action;
const isActionRunning =
activeAction === action && isRunning && !isPending;
const busy = isPending || isActionRunning;
const displayLabel = isActionRunning ? runningLabel : label;
const disabled = isBusy && !busy;
return (
<li key={action}>
<ListItem
onClick={() => handleClick(action)}
disabled={disabled}
aria-busy={busy}
active={busy}
className={cn(
"gap-3 px-5 py-1.5 whitespace-nowrap",
"font-mondwest text-display text-xs tracking-[0.1em]",
"transition-colors",
busy
? "text-midground"
: "text-text-secondary hover:text-midground",
"disabled:text-text-disabled",
)}
>
{isPending ? (
<Spinner className="shrink-0 text-[0.875rem]" />
) : isActionRunning && spin ? (
<Spinner className="shrink-0 text-[0.875rem]" />
) : (
<Icon
className={cn(
"h-3.5 w-3.5 shrink-0",
isActionRunning && !spin && "animate-pulse",
)}
/>
)}
<span className="truncate">{displayLabel}</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{busy && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</ListItem>
</li>
);
})}
{items.map((item) => (
<SystemActionButton
key={item.action}
collapsed={collapsed}
disabled={isBusy && !(pendingAction === item.action || (activeAction === item.action && isRunning))}
tooltipWarmRef={tooltipWarmRef}
isPending={pendingAction === item.action}
isRunning={activeAction === item.action && isRunning && pendingAction !== item.action}
item={item}
onClick={() => handleClick(item.action)}
/>
))}
</ul>
</div>
);
}
function SystemActionButton({
collapsed,
disabled,
isPending,
isRunning: isActionRunning,
item,
onClick,
tooltipWarmRef,
}: SystemActionButtonProps) {
const { icon: Icon, label, runningLabel, spin } = item;
const liRef = useRef<HTMLLIElement>(null);
const [hovered, setHovered] = useState(false);
const busy = isPending || isActionRunning;
const displayLabel = isActionRunning ? runningLabel : label;
return (
<li
ref={liRef}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
>
<button
onClick={onClick}
disabled={disabled}
aria-busy={busy}
aria-label={collapsed ? displayLabel : undefined}
onFocus={collapsed ? () => setHovered(true) : undefined}
onBlur={collapsed ? () => setHovered(false) : undefined}
type="button"
className={cn(
"group/action relative flex w-full items-center gap-3",
"px-5 py-2.5",
"font-mondwest text-display text-xs tracking-[0.1em]",
"whitespace-nowrap transition-colors cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
busy
? "text-midground"
: "text-text-secondary hover:text-midground",
"disabled:text-text-disabled disabled:cursor-not-allowed",
)}
>
{isPending ? (
<Spinner className="shrink-0 text-[0.875rem]" />
) : isActionRunning && spin ? (
<Spinner className="shrink-0 text-[0.875rem]" />
) : (
<Icon
className={cn(
"h-3.5 w-3.5 shrink-0",
isActionRunning && !spin && "animate-pulse",
)}
/>
)}
<span className={cn(
"truncate transition-opacity duration-300",
collapsed ? "lg:opacity-0" : "lg:opacity-100",
)}>
{displayLabel}
</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/action:opacity-5"
/>
{busy && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</button>
{collapsed && hovered && liRef.current && (
<SidebarTooltip anchor={liRef.current} label={displayLabel} warmRef={tooltipWarmRef} />
)}
</li>
);
}
function SidebarIconWithTooltip({
children,
collapsed,
label,
tooltipWarmRef,
}: SidebarIconWithTooltipProps) {
const ref = useRef<HTMLDivElement>(null);
const [hovered, setHovered] = useState(false);
return (
<div
ref={ref}
className={cn(
"relative w-fit",
collapsed && "group/icon",
)}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
>
{children}
{collapsed && (
<span
aria-hidden
className="absolute inset-y-0 inset-x-[-0.375rem] bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover/icon:opacity-5 hidden lg:block"
/>
)}
{collapsed && hovered && ref.current && (
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
)}
</div>
);
}
function GatewayDot({ collapsed, status, tooltipWarmRef }: GatewayDotProps) {
const { t } = useI18n();
const ref = useRef<HTMLDivElement>(null);
const [hovered, setHovered] = useState(false);
const toneToColor: Record<string, string> = {
"text-success": "bg-success",
"text-warning": "bg-warning",
"text-destructive": "bg-destructive",
"text-muted-foreground": "bg-muted-foreground",
};
let color: string;
let label: string;
if (!status) {
color = "bg-midground/20";
label = t.status.gateway;
} else {
const gw = gatewayLine(status, t);
color = toneToColor[gw.tone] ?? "bg-muted-foreground";
label = `${t.status.gateway} ${gw.label}`;
}
return (
<div
ref={ref}
className={cn(
"hidden lg:flex py-3 pl-[1.625rem] transition-opacity duration-300",
collapsed ? "lg:opacity-100" : "lg:opacity-0 lg:h-0 lg:py-0 lg:overflow-hidden",
)}
role="status"
aria-label={label}
tabIndex={collapsed ? 0 : -1}
onMouseEnter={collapsed ? () => setHovered(true) : undefined}
onMouseLeave={collapsed ? () => setHovered(false) : undefined}
onFocus={collapsed ? () => setHovered(true) : undefined}
onBlur={collapsed ? () => setHovered(false) : undefined}
>
<span
aria-hidden
className={cn("h-1.5 w-1.5 rounded-full", color)}
/>
{hovered && ref.current && (
<SidebarTooltip anchor={ref.current} label={label} warmRef={tooltipWarmRef} />
)}
</div>
);
}
function SidebarTooltip({ anchor, label, warmRef }: SidebarTooltipProps) {
const rect = anchor.getBoundingClientRect();
const sidebar = document.getElementById("app-sidebar");
const sidebarRight = sidebar?.getBoundingClientRect().right ?? rect.right;
const isWarm = warmRef ? Date.now() - warmRef.current < 300 : false;
useEffect(() => {
if (warmRef) warmRef.current = Date.now();
return () => {
if (warmRef) warmRef.current = Date.now();
};
}, [warmRef]);
return createPortal(
<span
className={cn(
"fixed z-[100] pointer-events-none",
"px-2 py-1",
"bg-background-base/95 border border-current/20 backdrop-blur-sm shadow-lg",
"font-mondwest text-display text-xs tracking-[0.1em] text-midground uppercase",
)}
style={{
top: rect.top + rect.height / 2,
left: sidebarRight + 8,
transform: "translateY(-50%)",
opacity: isWarm ? 1 : undefined,
animation: isWarm ? "none" : "sidebar-tooltip-in 120ms ease-out",
}}
>
{label}
</span>,
document.body,
);
}
type TooltipWarmRef = React.RefObject<number>;
interface GatewayDotProps {
collapsed: boolean;
status: StatusResponse | null;
tooltipWarmRef: TooltipWarmRef;
}
interface NavItem {
icon: ComponentType<{ className?: string }>;
label: string;
@@ -831,10 +1128,42 @@ interface NavItem {
path: string;
}
interface SidebarIconWithTooltipProps {
children: ReactNode;
collapsed: boolean;
label: string;
tooltipWarmRef: TooltipWarmRef;
}
interface SidebarNavLinkProps {
closeMobile: () => void;
collapsed: boolean;
item: NavItem;
t: Translations;
tooltipWarmRef: TooltipWarmRef;
}
interface SidebarSystemActionsProps {
collapsed: boolean;
onNavigate: () => void;
status: StatusResponse | null;
tooltipWarmRef: TooltipWarmRef;
}
interface SidebarTooltipProps {
anchor: HTMLElement;
label: string;
warmRef?: TooltipWarmRef;
}
interface SystemActionButtonProps {
collapsed: boolean;
disabled: boolean;
isPending: boolean;
isRunning: boolean;
item: SystemActionItem;
onClick: () => void;
tooltipWarmRef: TooltipWarmRef;
}
interface SystemActionItem {

View File

@@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { BottomPickSheet } from "@/components/BottomPickSheet";
import { Typography } from "@/components/NouiTypography";
@@ -25,10 +27,11 @@ import { cn } from "@/lib/utils";
* viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
* bottom sheet portaled to `document.body` instead of an anchored dropdown.
*/
export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
export function LanguageSwitcher({ collapsed = false, dropUp = false }: LanguageSwitcherProps) {
const { locale, setLocale, t } = useI18n();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const narrowViewport = useBelowBreakpoint(640);
const useMobileSheet = Boolean(dropUp && narrowViewport);
@@ -41,15 +44,14 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
return () => document.removeEventListener("keydown", onKey);
}, [open]);
// Outside-click closing only for anchored dropdown — sheet uses backdrop + portal.
useEffect(() => {
if (!open || useMobileSheet) return;
function onPointerDown(e: PointerEvent) {
if (!containerRef.current) return;
if (!containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
const target = e.target as Node;
if (containerRef.current?.contains(target)) return;
if (dropdownRef.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
@@ -69,7 +71,10 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
aria-label={t.language.switchTo}
aria-haspopup="listbox"
aria-expanded={open}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
className={cn(
"px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
collapsed && "hover:bg-transparent",
)}
>
<span className="inline-flex items-center gap-1.5">
<Typography
@@ -99,23 +104,33 @@ export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
</BottomPickSheet>
)}
{open && !useMobileSheet && (
<div
aria-label={sheetTitle}
className={cn(
"absolute right-0 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
dropUp ? "bottom-full mb-1" : "top-full mt-1",
)}
role="listbox"
>
<LanguageSwitcherOptions
allLocales={allLocales}
locale={locale}
setLocale={setLocale}
setOpen={setOpen}
/>
</div>
)}
{open && !useMobileSheet && (() => {
const rect = containerRef.current?.getBoundingClientRect();
const dropdown = (
<div
ref={dropdownRef}
aria-label={sheetTitle}
className={cn(
"min-w-[10rem] border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
)}
role="listbox"
style={
dropUp && rect
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
: undefined
}
>
<LanguageSwitcherOptions
allLocales={allLocales}
locale={locale}
setLocale={setLocale}
setOpen={setOpen}
/>
</div>
);
return dropUp ? createPortal(dropdown, document.body) : dropdown;
})()}
</div>
);
}
@@ -134,10 +149,12 @@ function LanguageSwitcherOptions({
return (
<button
aria-selected={selected}
className={
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
}
className={cn(
"w-full text-left px-3 py-1.5 flex items-center gap-2 cursor-pointer",
"font-mondwest text-display text-xs tracking-[0.08em]",
"hover:bg-accent hover:text-accent-foreground transition-colors",
selected ? "font-semibold text-foreground" : "text-muted-foreground",
)}
key={code}
onClick={() => {
setLocale(code);
@@ -148,7 +165,7 @@ function LanguageSwitcherOptions({
>
<span className="truncate">{meta.name}</span>
{selected && <span className="ml-auto text-xs"></span>}
{selected && <Check className="ml-auto h-3 w-3 shrink-0 text-midground" />}
</button>
);
})}
@@ -164,5 +181,6 @@ interface LanguageSwitcherOptionsProps {
}
interface LanguageSwitcherProps {
collapsed?: boolean;
dropUp?: boolean;
}

View File

@@ -1,10 +1,9 @@
import { Typography } from "@/components/NouiTypography";
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import type { StatusResponse } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
export function SidebarFooter() {
const status = useSidebarStatus();
export function SidebarFooter({ status }: SidebarFooterProps) {
const { t } = useI18n();
return (
@@ -37,3 +36,7 @@ export function SidebarFooter() {
</div>
);
}
interface SidebarFooterProps {
status: StatusResponse | null;
}

View File

@@ -1,12 +1,10 @@
import { Link } from "react-router-dom";
import type { StatusResponse } from "@/lib/api";
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
/** Gateway + session summary for the System sidebar block (no separate strip chrome). */
export function SidebarStatusStrip() {
const status = useSidebarStatus();
export function SidebarStatusStrip({ status }: SidebarStatusStripProps) {
const { t } = useI18n();
if (status === null) {
@@ -50,7 +48,7 @@ export function SidebarStatusStrip() {
);
}
function gatewayLine(
export function gatewayLine(
status: StatusResponse,
t: ReturnType<typeof useI18n>["t"],
): { label: string; tone: string } {
@@ -68,3 +66,7 @@ function gatewayLine(
? { label: g.running, tone: "text-success" }
: { label: g.off, tone: "text-muted-foreground" };
}
interface SidebarStatusStripProps {
status: StatusResponse | null;
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Palette, Check } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
@@ -23,11 +24,12 @@ import { cn } from "@/lib/utils";
* bottom sheet portaled to `document.body` so the picker is not clipped by
* the sidebar (same idea as a responsive Drawer).
*/
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
export function ThemeSwitcher({ collapsed = false, dropUp = false }: ThemeSwitcherProps) {
const { themeName, availableThemes, setTheme } = useTheme();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const narrowViewport = useBelowBreakpoint(640);
const useMobileSheet = Boolean(dropUp && narrowViewport);
@@ -45,12 +47,10 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
useEffect(() => {
if (!open || useMobileSheet) return;
const onMouseDown = (e: MouseEvent) => {
if (
wrapperRef.current &&
!wrapperRef.current.contains(e.target as Node)
) {
close();
}
const target = e.target as Node;
if (wrapperRef.current?.contains(target)) return;
if (dropdownRef.current?.contains(target)) return;
close();
};
document.addEventListener("mousedown", onMouseDown);
return () => document.removeEventListener("mousedown", onMouseDown);
@@ -64,9 +64,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
<div ref={wrapperRef} className="relative">
<Button
ghost
size={collapsed ? "icon" : undefined}
onClick={() => setOpen((o) => !o)}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground"
title={t.theme?.switchTheme ?? "Switch theme"}
className={cn(
collapsed
? "text-text-secondary hover:text-foreground hover:bg-transparent"
: "px-2 py-1 normal-case tracking-normal font-normal text-xs text-text-secondary hover:text-foreground",
)}
title={`${t.theme?.switchTheme ?? "Switch theme"}: ${label}`}
aria-label={t.theme?.switchTheme ?? "Switch theme"}
aria-expanded={open}
aria-haspopup="listbox"
@@ -74,12 +79,14 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
<span className="inline-flex items-center gap-1.5">
<Palette className="h-3.5 w-3.5" />
<Typography
mondwest
className="hidden sm:inline text-display tracking-wide text-xs"
>
{label}
</Typography>
{!collapsed && (
<Typography
mondwest
className="hidden sm:inline text-display tracking-wide text-xs"
>
{label}
</Typography>
)}
</span>
</Button>
@@ -101,34 +108,44 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
</BottomPickSheet>
)}
{open && !useMobileSheet && (
<div
aria-label={sheetTitle}
className={cn(
"absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto",
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
)}
role="listbox"
>
<div className="border-b border-current/20 px-3 py-2">
<Typography
mondwest
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{sheetTitle}
</Typography>
</div>
{open && !useMobileSheet && (() => {
const rect = wrapperRef.current?.getBoundingClientRect();
const dropdown = (
<div
ref={dropdownRef}
aria-label={sheetTitle}
className={cn(
"min-w-[240px] max-h-[70dvh] overflow-y-auto",
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
dropUp ? "fixed z-[100]" : "absolute z-50 right-0 top-full mt-1",
)}
role="listbox"
style={
dropUp && rect
? { bottom: window.innerHeight - rect.top + 4, left: rect.left }
: undefined
}
>
<div className="border-b border-current/20 px-3 py-2">
<Typography
mondwest
className="text-display text-xs tracking-[0.12em] text-text-tertiary"
>
{sheetTitle}
</Typography>
</div>
<ThemeSwitcherOptions
availableThemes={availableThemes}
close={close}
setTheme={setTheme}
themeName={themeName}
/>
</div>
)}
<ThemeSwitcherOptions
availableThemes={availableThemes}
close={close}
setTheme={setTheme}
themeName={themeName}
/>
</div>
);
return dropUp ? createPortal(dropdown, document.body) : dropdown;
})()}
</div>
);
}
@@ -221,5 +238,6 @@ interface ThemeSwitcherOptionsProps {
}
interface ThemeSwitcherProps {
collapsed?: boolean;
dropUp?: boolean;
}

View File

@@ -127,6 +127,7 @@ export const af: Translations = {
sessions: {
title: "Sessies",
history: "Geskiedenis",
overview: "Oorsig",
searchPlaceholder: "Soek boodskap-inhoud...",
noSessions: "Nog geen sessies nie",
@@ -422,7 +423,7 @@ export const af: Translations = {
},
language: {
switchTo: "Skakel oor na Engels",
switchTo: "Verander taal",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const de: Translations = {
sessions: {
title: "Sitzungen",
history: "Verlauf",
overview: "Übersicht",
searchPlaceholder: "Nachrichteninhalt suchen...",
noSessions: "Noch keine Sitzungen",
@@ -422,7 +423,7 @@ export const de: Translations = {
},
language: {
switchTo: "Zu Englisch wechseln",
switchTo: "Sprache wechseln",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const en: Translations = {
sessions: {
title: "Sessions",
history: "History",
overview: "Overview",
searchPlaceholder: "Search message content...",
noSessions: "No sessions yet",
@@ -422,7 +423,7 @@ export const en: Translations = {
},
language: {
switchTo: "Switch to Chinese",
switchTo: "Switch language",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const es: Translations = {
sessions: {
title: "Sesiones",
history: "Historial",
overview: "Resumen",
searchPlaceholder: "Buscar contenido de mensajes...",
noSessions: "Aún no hay sesiones",
@@ -422,7 +423,7 @@ export const es: Translations = {
},
language: {
switchTo: "Cambiar a inglés",
switchTo: "Cambiar idioma",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const fr: Translations = {
sessions: {
title: "Sessions",
history: "Historique",
overview: "Aperçu",
searchPlaceholder: "Rechercher dans les messages...",
noSessions: "Aucune session pour l'instant",
@@ -422,7 +423,7 @@ export const fr: Translations = {
},
language: {
switchTo: "Passer à l'anglais",
switchTo: "Changer de langue",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const ga: Translations = {
sessions: {
title: "Seisiúin",
history: "Stair",
overview: "Forbhreathnú",
searchPlaceholder: "Cuardaigh ábhar teachtaireachta...",
noSessions: "Gan seisiúin go fóill",
@@ -422,7 +423,7 @@ export const ga: Translations = {
},
language: {
switchTo: "Athraigh go Béarla",
switchTo: "Athraigh teanga",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const hu: Translations = {
sessions: {
title: "Munkamenetek",
history: "Előzmények",
overview: "Áttekintés",
searchPlaceholder: "Keresés üzenettartalomban...",
noSessions: "Még nincsenek munkamenetek",
@@ -422,7 +423,7 @@ export const hu: Translations = {
},
language: {
switchTo: "Váltás angolra",
switchTo: "Nyelv váltása",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const it: Translations = {
sessions: {
title: "Sessioni",
history: "Cronologia",
overview: "Panoramica",
searchPlaceholder: "Cerca nel contenuto dei messaggi...",
noSessions: "Nessuna sessione",
@@ -422,7 +423,7 @@ export const it: Translations = {
},
language: {
switchTo: "Passa all'inglese",
switchTo: "Cambia lingua",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const ja: Translations = {
sessions: {
title: "セッション",
history: "履歴",
overview: "概要",
searchPlaceholder: "メッセージ内容を検索...",
noSessions: "まだセッションがありません",
@@ -422,7 +423,7 @@ export const ja: Translations = {
},
language: {
switchTo: "英語に切り替え",
switchTo: "言語を切り替え",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const ko: Translations = {
sessions: {
title: "세션",
history: "기록",
overview: "개요",
searchPlaceholder: "메시지 내용 검색...",
noSessions: "아직 세션이 없습니다",
@@ -422,7 +423,7 @@ export const ko: Translations = {
},
language: {
switchTo: "영어로 전환",
switchTo: "언어 변경",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const pt: Translations = {
sessions: {
title: "Sessões",
history: "Histórico",
overview: "Visão geral",
searchPlaceholder: "Pesquisar conteúdo das mensagens...",
noSessions: "Ainda não há sessões",
@@ -422,7 +423,7 @@ export const pt: Translations = {
},
language: {
switchTo: "Mudar para inglês",
switchTo: "Mudar idioma",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const ru: Translations = {
sessions: {
title: "Сессии",
history: "История",
overview: "Обзор",
searchPlaceholder: "Поиск по содержимому сообщений...",
noSessions: "Сессий пока нет",
@@ -422,7 +423,7 @@ export const ru: Translations = {
},
language: {
switchTo: "Переключиться на английский",
switchTo: "Сменить язык",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const tr: Translations = {
sessions: {
title: "Oturumlar",
history: "Geçmiş",
overview: "Genel bakış",
searchPlaceholder: "Mesaj içeriğinde ara...",
noSessions: "Henüz oturum yok",
@@ -422,7 +423,7 @@ export const tr: Translations = {
},
language: {
switchTo: "İngilizce'ye geç",
switchTo: "Dil değiştir",
},
theme: {

View File

@@ -145,6 +145,7 @@ export interface Translations {
// ── Sessions page ──
sessions: {
title: string;
history: string;
overview: string;
searchPlaceholder: string;
noSessions: string;

View File

@@ -127,6 +127,7 @@ export const uk: Translations = {
sessions: {
title: "Сесії",
history: "Історія",
overview: "Огляд",
searchPlaceholder: "Пошук у вмісті повідомлень...",
noSessions: "Поки немає сесій",
@@ -422,7 +423,7 @@ export const uk: Translations = {
},
language: {
switchTo: "Перемкнути на англійську",
switchTo: "Змінити мову",
},
theme: {

View File

@@ -127,6 +127,7 @@ export const zhHant: Translations = {
sessions: {
title: "工作階段",
history: "歷史",
overview: "總覽",
searchPlaceholder: "搜尋訊息內容...",
noSessions: "尚無工作階段",
@@ -422,7 +423,7 @@ export const zhHant: Translations = {
},
language: {
switchTo: "切換為英文",
switchTo: "切換語言",
},
theme: {

View File

@@ -126,6 +126,7 @@ export const zh: Translations = {
sessions: {
title: "会话",
history: "历史",
overview: "概览",
searchPlaceholder: "搜索消息内容...",
noSessions: "暂无会话",
@@ -417,7 +418,7 @@ export const zh: Translations = {
},
language: {
switchTo: "切换到英文",
switchTo: "切换语言",
},
theme: {

View File

@@ -124,6 +124,18 @@ code, kbd, pre, samp, .font-mono, .font-mono-ui {
overflow: hidden;
}
@media (max-width: 768px) {
html,
body,
#root {
min-height: 100dvh;
height: auto;
max-height: none;
overflow-x: hidden;
overflow-y: auto;
}
}
/* Nousnet's hermes-agent layout bumps `small` and `code` to readable
dashboard sizes. Keep in sync. */
small { font-size: 1.0625rem; }
@@ -170,6 +182,12 @@ code { font-size: 0.875rem; }
}
/* Collapsed sidebar tooltip entrance — skipped when moving between items. */
@keyframes sidebar-tooltip-in {
from { opacity: 0; transform: translateY(-50%) translateX(-4px); }
to { opacity: 1; transform: translateY(-50%) translateX(0); }
}
/* Toast animations used by `components/Toast.tsx`. */
@keyframes toast-in {
from { opacity: 0; transform: translateX(16px); }

View File

@@ -778,7 +778,7 @@ export default function SessionsPage() {
onChange={setView}
options={[
{ value: "overview", label: t.sessions.overview },
{ value: "list", label: t.sessions.title },
{ value: "list", label: t.sessions.history },
]}
/>
)}

View File

@@ -49,6 +49,15 @@ You'll see a one-line breadcrumb in `docker logs` confirming the upgrade. To opt
This behavior applies to the s6-based image only. Earlier (tini-based) images still run `gateway run` as the foreground main process.
:::
:::note Where gateway logs go
Inside the s6 image, the supervised gateway's output is tee'd to two destinations:
- **`docker logs <container>`** — every line in real time (raw, no extra prefix). This is the same stream you'd get from a foreground gateway, so existing `docker logs --follow` / `--timestamps` / log-shipper integrations work unchanged.
- **`${HERMES_HOME}/logs/gateways/<profile>/current`** (mapped to `~/.hermes/logs/gateways/<profile>/current` on the host via the volume mount) — rotated, with an ISO 8601 timestamp prepended per line. Rotation is 10 archives × 1 MB each, so it can't fill the disk. This is what `hermes logs` reads and what survives container restarts.
The per-profile reconciler keeps a separate audit log at `${HERMES_HOME}/logs/container-boot.log` — one line per profile per container boot, recording whether each gateway was restored to its prior state.
:::
Note: the API server is gated on `API_SERVER_ENABLED=true`. To expose it beyond `127.0.0.1` inside the container, also set `API_SERVER_HOST=0.0.0.0` and an `API_SERVER_KEY` (minimum 8 characters — generate one with `openssl rand -hex 32`). Example:
```sh