Compare commits
7 Commits
bb/gui
...
fix/nix-ga
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e817700c | ||
|
|
10f13c3881 | ||
|
|
c9410b3462 | ||
|
|
c341a2d107 | ||
|
|
71b4a6b18e | ||
|
|
aeb992d343 | ||
|
|
b345323195 |
23
Dockerfile
23
Dockerfile
@@ -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 procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
@@ -213,13 +213,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" ]
|
||||
|
||||
|
||||
87
docker/hermes-exec-shim.sh
Normal file
87
docker/hermes-exec-shim.sh
Normal 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" "$@"
|
||||
@@ -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
|
||||
|
||||
@@ -2075,6 +2075,60 @@ def _build_wsl_interop_paths(path_entries: list[str]) -> list[str]:
|
||||
return result
|
||||
|
||||
|
||||
_PACKAGED_RUNTIME_ENV_VARS: tuple[str, ...] = (
|
||||
# Nix/packaged wrappers point Hermes at read-only bundled assets that are
|
||||
# intentionally not importable from the sealed Python environment alone.
|
||||
"HERMES_BUNDLED_SKILLS",
|
||||
"HERMES_BUNDLED_PLUGINS",
|
||||
"HERMES_WEB_DIST",
|
||||
"HERMES_TUI_DIR",
|
||||
"HERMES_PYTHON",
|
||||
"HERMES_NODE",
|
||||
# Wrappers may also expose native libraries (e.g. libopus for Discord
|
||||
# voice) or extra Python plugin paths. A systemd unit that bypasses the
|
||||
# wrapper still needs those paths.
|
||||
"LD_LIBRARY_PATH",
|
||||
"PYTHONPATH",
|
||||
)
|
||||
|
||||
|
||||
def _escape_systemd_env_value(value: str) -> str:
|
||||
"""Escape a value for a quoted systemd ``Environment=`` assignment."""
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
|
||||
def _remap_colon_separated_paths_for_user(value: str, target_home_dir: str) -> str:
|
||||
"""Remap each path component in a colon-separated environment value."""
|
||||
return os.pathsep.join(
|
||||
_remap_path_for_user(part, target_home_dir) if part else part
|
||||
for part in value.split(os.pathsep)
|
||||
)
|
||||
|
||||
|
||||
def _packaged_runtime_environment_lines(target_home_dir: str | None = None) -> str:
|
||||
"""Return systemd Environment= lines for wrapper-provided runtime paths.
|
||||
|
||||
``hermes gateway install`` writes a service unit that launches the Python
|
||||
module directly. Packaged launchers (notably Nix wrappers) set environment
|
||||
variables that locate bundled plugins/skills/web assets and native library
|
||||
paths before execing that Python module. Persist those variables into the
|
||||
unit so the background gateway sees the same runtime layout as the CLI that
|
||||
generated it.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
for key in _PACKAGED_RUNTIME_ENV_VARS:
|
||||
value = os.environ.get(key)
|
||||
if not value:
|
||||
continue
|
||||
if target_home_dir:
|
||||
if key in {"LD_LIBRARY_PATH", "PYTHONPATH"}:
|
||||
value = _remap_colon_separated_paths_for_user(value, target_home_dir)
|
||||
elif value.startswith(("/", "~")):
|
||||
value = _remap_path_for_user(value, target_home_dir)
|
||||
lines.append(f'Environment="{key}={_escape_systemd_env_value(value)}"')
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _remap_path_for_user(path: str, target_home_dir: str) -> str:
|
||||
"""Remap *path* from the current user's home to *target_home_dir*.
|
||||
|
||||
@@ -2199,6 +2253,7 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
||||
path_entries.extend(_build_wsl_interop_paths(path_entries))
|
||||
path_entries.extend(common_bin_paths)
|
||||
sane_path = ":".join(path_entries)
|
||||
packaged_runtime_env = _packaged_runtime_environment_lines(home_dir)
|
||||
return f"""[Unit]
|
||||
Description={SERVICE_DESCRIPTION}
|
||||
After=network-online.target
|
||||
@@ -2217,6 +2272,7 @@ Environment="LOGNAME={username}"
|
||||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
{packaged_runtime_env}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
@@ -2239,6 +2295,7 @@ WantedBy=multi-user.target
|
||||
path_entries.extend(_build_wsl_interop_paths(path_entries))
|
||||
path_entries.extend(common_bin_paths)
|
||||
sane_path = ":".join(path_entries)
|
||||
packaged_runtime_env = _packaged_runtime_environment_lines()
|
||||
return f"""[Unit]
|
||||
Description={SERVICE_DESCRIPTION}
|
||||
After=network-online.target
|
||||
@@ -2252,6 +2309,7 @@ WorkingDirectory={working_dir}
|
||||
Environment="PATH={sane_path}"
|
||||
Environment="VIRTUAL_ENV={venv_dir}"
|
||||
Environment="HERMES_HOME={hermes_home}"
|
||||
{packaged_runtime_env}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
RestartMaxDelaySec=300
|
||||
|
||||
@@ -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 ---------------------------------------------------------
|
||||
|
||||
290
tests/docker/test_docker_exec_privilege_drop.py
Normal file
290
tests/docker/test_docker_exec_privilege_drop.py
Normal 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."
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
|
||||
@@ -394,6 +394,60 @@ class TestGeneratedSystemdUnits:
|
||||
assert self._expected_timeout_stop_sec() in unit
|
||||
assert "WantedBy=multi-user.target" in unit
|
||||
|
||||
def test_user_unit_preserves_packaged_runtime_environment(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_BUNDLED_PLUGINS", "/nix/store/hermes/share/plugins")
|
||||
monkeypatch.setenv("HERMES_BUNDLED_SKILLS", "/nix/store/hermes/share/skills")
|
||||
monkeypatch.setenv("HERMES_WEB_DIST", "/nix/store/hermes/share/web_dist")
|
||||
monkeypatch.setenv("LD_LIBRARY_PATH", "/nix/store/libopus/lib")
|
||||
|
||||
unit = gateway_cli.generate_systemd_unit(system=False)
|
||||
|
||||
assert (
|
||||
'Environment="HERMES_BUNDLED_PLUGINS=/nix/store/hermes/share/plugins"'
|
||||
in unit
|
||||
)
|
||||
assert (
|
||||
'Environment="HERMES_BUNDLED_SKILLS=/nix/store/hermes/share/skills"'
|
||||
in unit
|
||||
)
|
||||
assert 'Environment="HERMES_WEB_DIST=/nix/store/hermes/share/web_dist"' in unit
|
||||
assert 'Environment="LD_LIBRARY_PATH=/nix/store/libopus/lib"' in unit
|
||||
|
||||
def test_system_unit_remaps_packaged_runtime_environment_for_target_user(
|
||||
self, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(Path, "home", lambda: Path("/root"))
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_system_service_identity",
|
||||
lambda run_as_user=None: ("alice", "alice", "/home/alice"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
gateway_cli,
|
||||
"_hermes_home_for_target_user",
|
||||
lambda home: "/home/alice/.hermes",
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
"HERMES_BUNDLED_PLUGINS",
|
||||
"/root/.nix-profile/share/hermes/plugins",
|
||||
)
|
||||
monkeypatch.setenv(
|
||||
"LD_LIBRARY_PATH",
|
||||
"/root/.nix-profile/lib:/nix/store/libopus/lib",
|
||||
)
|
||||
|
||||
unit = gateway_cli.generate_systemd_unit(system=True, run_as_user="alice")
|
||||
|
||||
assert (
|
||||
'Environment="HERMES_BUNDLED_PLUGINS='
|
||||
'/home/alice/.nix-profile/share/hermes/plugins"' in unit
|
||||
)
|
||||
assert (
|
||||
'Environment="LD_LIBRARY_PATH='
|
||||
'/home/alice/.nix-profile/lib:/nix/store/libopus/lib"' in unit
|
||||
)
|
||||
assert "/root/.nix-profile" not in unit
|
||||
|
||||
|
||||
class TestGatewayStopCleanup:
|
||||
def test_stop_only_kills_current_profile_by_default(self, tmp_path, monkeypatch):
|
||||
|
||||
@@ -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)
|
||||
|
||||
15
tests/test_docker_home_override_scripts.py
Normal file
15
tests/test_docker_home_override_scripts.py
Normal 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
|
||||
481
web/src/App.tsx
481
web/src/App.tsx
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -145,6 +145,7 @@ export interface Translations {
|
||||
// ── Sessions page ──
|
||||
sessions: {
|
||||
title: string;
|
||||
history: string;
|
||||
overview: string;
|
||||
searchPlaceholder: string;
|
||||
noSessions: string;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user