From bde487c91137436aced5819e2aea2d354a1815a6 Mon Sep 17 00:00:00 2001
From: Wesley Simplicio
Date: Sat, 9 May 2026 08:55:00 -0300
Subject: [PATCH 001/260] fix(voice): honor PULSE_SERVER/PIPEWIRE_REMOTE inside
Docker (#21203)
detect_audio_environment() unconditionally added a hard warning when
running inside a container, blocking /voice on even when the host audio
socket was correctly forwarded (PulseAudio or PipeWire) and sounddevice
could enumerate devices.
Mirror the existing WSL/PulseAudio handling: if PULSE_SERVER or
PIPEWIRE_REMOTE is set, downgrade to a notice and let the audio backend
decide. When neither is set, keep the block but extend the message with
the exact -v / -e flags users need.
Closes #21203
---
tests/tools/test_voice_mode.py | 54 ++++++++++++++++++++++++++++++++++
tools/voice_mode.py | 16 ++++++++--
2 files changed, 68 insertions(+), 2 deletions(-)
diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py
index 1d35c48625f..0da0a06040d 100644
--- a/tests/tools/test_voice_mode.py
+++ b/tests/tools/test_voice_mode.py
@@ -216,6 +216,60 @@ class TestDetectAudioEnvironment:
assert any("Termux:API Android app is not installed" in w for w in result["warnings"])
+ def test_docker_with_pulse_server_allows_voice(self, monkeypatch):
+ """Docker with PULSE_SERVER set should NOT block voice mode (#21203)."""
+ monkeypatch.delenv("SSH_CLIENT", raising=False)
+ monkeypatch.delenv("SSH_TTY", raising=False)
+ monkeypatch.delenv("SSH_CONNECTION", raising=False)
+ monkeypatch.setenv("PULSE_SERVER", "unix:/run/user/1000/pulse/native")
+ monkeypatch.delenv("PIPEWIRE_REMOTE", raising=False)
+ monkeypatch.setattr("hermes_constants.is_container", lambda: True)
+ monkeypatch.setattr("tools.voice_mode._import_audio",
+ lambda: (MagicMock(), MagicMock()))
+
+ from tools.voice_mode import detect_audio_environment
+ result = detect_audio_environment()
+
+ assert result["available"] is True
+ assert result["warnings"] == []
+ assert any("Docker" in n for n in result.get("notices", []))
+
+ def test_docker_with_pipewire_remote_allows_voice(self, monkeypatch):
+ """Docker with PIPEWIRE_REMOTE set should NOT block voice mode (#21203)."""
+ monkeypatch.delenv("SSH_CLIENT", raising=False)
+ monkeypatch.delenv("SSH_TTY", raising=False)
+ monkeypatch.delenv("SSH_CONNECTION", raising=False)
+ monkeypatch.delenv("PULSE_SERVER", raising=False)
+ monkeypatch.setenv("PIPEWIRE_REMOTE", "/run/user/1000/pipewire-0")
+ monkeypatch.setattr("hermes_constants.is_container", lambda: True)
+ monkeypatch.setattr("tools.voice_mode._import_audio",
+ lambda: (MagicMock(), MagicMock()))
+
+ from tools.voice_mode import detect_audio_environment
+ result = detect_audio_environment()
+
+ assert result["available"] is True
+ assert result["warnings"] == []
+ assert any("Docker" in n for n in result.get("notices", []))
+
+ def test_docker_without_audio_forwarding_blocks_voice(self, monkeypatch):
+ """Docker without PULSE_SERVER/PIPEWIRE_REMOTE keeps blocking voice mode."""
+ monkeypatch.delenv("SSH_CLIENT", raising=False)
+ monkeypatch.delenv("SSH_TTY", raising=False)
+ monkeypatch.delenv("SSH_CONNECTION", raising=False)
+ monkeypatch.delenv("PULSE_SERVER", raising=False)
+ monkeypatch.delenv("PIPEWIRE_REMOTE", raising=False)
+ monkeypatch.setattr("hermes_constants.is_container", lambda: True)
+ monkeypatch.setattr("tools.voice_mode._import_audio",
+ lambda: (MagicMock(), MagicMock()))
+
+ from tools.voice_mode import detect_audio_environment
+ result = detect_audio_environment()
+
+ assert result["available"] is False
+ assert any("Docker" in w for w in result["warnings"])
+ assert any("PULSE_SERVER" in w or "PIPEWIRE_REMOTE" in w for w in result["warnings"])
+
def test_termux_api_microphone_allows_voice_without_sounddevice(self, monkeypatch):
monkeypatch.setenv("TERMUX_VERSION", "0.118.3")
monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr")
diff --git a/tools/voice_mode.py b/tools/voice_mode.py
index 6166ade2a3f..7c226afbaf7 100644
--- a/tools/voice_mode.py
+++ b/tools/voice_mode.py
@@ -102,10 +102,22 @@ def detect_audio_environment() -> dict:
if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')):
warnings.append("Running over SSH -- no audio devices available")
- # Docker/Podman container detection
+ # Docker/Podman container detection — honor host audio forwarding.
+ # When the user mounts a PulseAudio/PipeWire socket into the container
+ # and points PULSE_SERVER / PIPEWIRE_REMOTE at it, audio works fine
+ # (issue #21203). Only block when no forwarding is configured.
from hermes_constants import is_container
if is_container():
- warnings.append("Running inside Docker container -- no audio devices")
+ if os.environ.get('PULSE_SERVER') or os.environ.get('PIPEWIRE_REMOTE'):
+ notices.append("Running inside Docker container with host audio forwarding")
+ else:
+ warnings.append(
+ "Running inside Docker container -- no audio devices.\n"
+ " Forward host audio with one of:\n"
+ " PulseAudio: -v /run/user/1000/pulse/native:/run/user/1000/pulse/native \\\n"
+ " -e PULSE_SERVER=unix:/run/user/1000/pulse/native\n"
+ " PipeWire: -e PIPEWIRE_REMOTE=/run/user/1000/pipewire-0"
+ )
# WSL detection — PulseAudio bridge makes audio work in WSL.
# Only block if PULSE_SERVER is not configured.
From 30dd5547ada8f316b6a558d4aa4c5e025f5b9ee6 Mon Sep 17 00:00:00 2001
From: Wesley Simplicio
Date: Sat, 9 May 2026 15:21:12 -0300
Subject: [PATCH 002/260] fix(voice_mode): generalize container phrasing and
use $XDG_RUNTIME_DIR
---
tests/tools/test_voice_mode.py | 6 +++---
tools/voice_mode.py | 13 +++++++------
2 files changed, 10 insertions(+), 9 deletions(-)
diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py
index 0da0a06040d..7dd9a757752 100644
--- a/tests/tools/test_voice_mode.py
+++ b/tests/tools/test_voice_mode.py
@@ -232,7 +232,7 @@ class TestDetectAudioEnvironment:
assert result["available"] is True
assert result["warnings"] == []
- assert any("Docker" in n for n in result.get("notices", []))
+ assert any("container" in n.lower() for n in result.get("notices", []))
def test_docker_with_pipewire_remote_allows_voice(self, monkeypatch):
"""Docker with PIPEWIRE_REMOTE set should NOT block voice mode (#21203)."""
@@ -250,7 +250,7 @@ class TestDetectAudioEnvironment:
assert result["available"] is True
assert result["warnings"] == []
- assert any("Docker" in n for n in result.get("notices", []))
+ assert any("container" in n.lower() for n in result.get("notices", []))
def test_docker_without_audio_forwarding_blocks_voice(self, monkeypatch):
"""Docker without PULSE_SERVER/PIPEWIRE_REMOTE keeps blocking voice mode."""
@@ -267,7 +267,7 @@ class TestDetectAudioEnvironment:
result = detect_audio_environment()
assert result["available"] is False
- assert any("Docker" in w for w in result["warnings"])
+ assert any("container" in w.lower() for w in result["warnings"])
assert any("PULSE_SERVER" in w or "PIPEWIRE_REMOTE" in w for w in result["warnings"])
def test_termux_api_microphone_allows_voice_without_sounddevice(self, monkeypatch):
diff --git a/tools/voice_mode.py b/tools/voice_mode.py
index 7c226afbaf7..1c019cc0400 100644
--- a/tools/voice_mode.py
+++ b/tools/voice_mode.py
@@ -109,14 +109,15 @@ def detect_audio_environment() -> dict:
from hermes_constants import is_container
if is_container():
if os.environ.get('PULSE_SERVER') or os.environ.get('PIPEWIRE_REMOTE'):
- notices.append("Running inside Docker container with host audio forwarding")
+ notices.append("Running inside container (Docker/Podman/LXC) with host audio forwarding")
else:
warnings.append(
- "Running inside Docker container -- no audio devices.\n"
- " Forward host audio with one of:\n"
- " PulseAudio: -v /run/user/1000/pulse/native:/run/user/1000/pulse/native \\\n"
- " -e PULSE_SERVER=unix:/run/user/1000/pulse/native\n"
- " PipeWire: -e PIPEWIRE_REMOTE=/run/user/1000/pipewire-0"
+ "Running inside container (Docker/Podman/LXC) -- no audio devices.\n"
+ " Forward host audio with one of (substitute $XDG_RUNTIME_DIR for your runtime dir,\n"
+ " typically /run/user/$UID):\n"
+ " PulseAudio: -v $XDG_RUNTIME_DIR/pulse/native:$XDG_RUNTIME_DIR/pulse/native \\\n"
+ " -e PULSE_SERVER=unix:$XDG_RUNTIME_DIR/pulse/native\n"
+ " PipeWire: -e PIPEWIRE_REMOTE=$XDG_RUNTIME_DIR/pipewire-0"
)
# WSL detection — PulseAudio bridge makes audio work in WSL.
From ec641d497a6b967c13d5224b1d7c200000f0f54c Mon Sep 17 00:00:00 2001
From: slowtokki0409
Date: Fri, 15 May 2026 09:23:14 +0900
Subject: [PATCH 003/260] chore: ignore local Hermes runtime files
Keep local Hermes Docker runtime data, NotebookLM auth/cache, and personal compose overrides out of Git and Docker build contexts. This protects tokens, OAuth state, sessions, logs, and caches while preserving the source tree.
Constraint: Only .gitignore and .dockerignore are in scope for this commit.
Tested: git diff --cached --name-only and git diff --cached --stat
Co-authored-by: OmX
---
.dockerignore | 6 ++++++
.gitignore | 7 +++++++
2 files changed, 13 insertions(+)
diff --git a/.dockerignore b/.dockerignore
index f4a02484ebf..3c16d71b226 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -8,6 +8,10 @@ node_modules
**/node_modules
.venv
**/.venv
+.notebooklm-cli-venv/
+.notebooklm-playwright/
+.pip-cache/
+.uv-cache/
# Built artifacts that are regenerated inside the image. Excluded so local
# rebuilds on the developer's machine don't invalidate the npm-install layer
@@ -25,6 +29,8 @@ ui-tui/packages/hermes-ink/dist/
# Runtime data (bind-mounted at /opt/data; must not leak into build context)
data/
+.hermes-docker/
+.notebooklm-home/
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
hermes-config/
diff --git a/.gitignore b/.gitignore
index 37b1f602cc9..3858051ab16 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,13 @@ __pycache__/
.env.production.local
.env.development
.env.test
+.hermes-docker/
+.notebooklm-home/
+.notebooklm-cli-venv/
+.notebooklm-playwright/
+.pip-cache/
+.uv-cache/
+compose.hermes.local.yml
export*
__pycache__/model_tools.cpython-310.pyc
__pycache__/web_tools.cpython-310.pyc
From 51689a420696b2bd4883281ce0ee2848b28e9a9a Mon Sep 17 00:00:00 2001
From: emozilla
Date: Wed, 20 May 2026 22:18:47 -0400
Subject: [PATCH 004/260] feat(cli): add --branch flag to `hermes update`
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`hermes update` has always hard-coded its target to `main`. Add --branch
so callers can update against a non-default channel while preserving every
existing behavior at the default:
- `hermes update` still pulls main (no behavior change)
- `hermes update --branch X` pulls origin/X, auto-stashing and switching
local HEAD to X first if needed
- `hermes update --check --branch X` reports behindness against
origin/X (and skips the upstream/X probe,
since forks don't have upstream copies of
their own feature branches)
- Branch absent locally → retry as `checkout -B X origin/X` (track)
- Branch absent everywhere → exit 1 with a clear error, after restoring
the user's prior stash so we don't strand
them in a weird state
The fork-upstream sync logic was already guarded on `branch == 'main'`,
so non-main updates correctly skip the upstream trampling without
further changes.
5 new tests cover: explicit --branch, default-to-main, switch-from-other,
track-from-origin, and the fail-cleanly case. Full test_cmd_update.py
suite (15 tests) passes on main.
---
hermes_cli/main.py | 119 +++++++++++++++++------
tests/hermes_cli/test_cmd_update.py | 143 ++++++++++++++++++++++++++++
2 files changed, 235 insertions(+), 27 deletions(-)
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 925f93e77c6..e2bd362ef8c 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -8045,8 +8045,13 @@ def _finalize_update_output(state):
pass
-def _cmd_update_check():
- """Implement ``hermes update --check``: fetch and report without installing."""
+def _cmd_update_check(branch: str = "main"):
+ """Implement ``hermes update --check``: fetch and report without installing.
+
+ ``branch`` selects which branch the check compares against. Default is
+ "main"; callers can pass another branch to ask "are there new commits
+ on origin/?" without performing the update.
+ """
from hermes_cli.config import detect_install_method
method = detect_install_method(PROJECT_ROOT)
if method == "pip":
@@ -8072,16 +8077,34 @@ def _cmd_update_check():
if sys.platform == "win32":
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
- # Fetch both origin and upstream; prefer upstream as the canonical reference
- print("→ Fetching from upstream...")
- fetch_result = subprocess.run(
- git_cmd + ["fetch", "upstream"],
- cwd=PROJECT_ROOT,
- capture_output=True,
- text=True,
- )
- if fetch_result.returncode != 0:
- # Fallback to origin if upstream doesn't exist
+ # Fetch both origin and upstream; prefer upstream as the canonical reference.
+ # Note: upstream/ may not exist for non-main branches (a fork's
+ # bb/gui has no upstream counterpart), so when the caller picks a
+ # non-default branch we skip the upstream probe and use origin directly.
+ if branch == "main":
+ print("→ Fetching from upstream...")
+ fetch_result = subprocess.run(
+ git_cmd + ["fetch", "upstream"],
+ cwd=PROJECT_ROOT,
+ capture_output=True,
+ text=True,
+ )
+ if fetch_result.returncode != 0:
+ # Fallback to origin if upstream doesn't exist
+ print("→ Fetching from origin...")
+ fetch_result = subprocess.run(
+ git_cmd + ["fetch", "origin"],
+ cwd=PROJECT_ROOT,
+ capture_output=True,
+ text=True,
+ )
+ upstream_exists = False
+ compare_branch = f"origin/{branch}"
+ else:
+ upstream_exists = True
+ compare_branch = f"upstream/{branch}"
+ else:
+ # Non-default branch: compare against origin/ directly.
print("→ Fetching from origin...")
fetch_result = subprocess.run(
git_cmd + ["fetch", "origin"],
@@ -8090,10 +8113,7 @@ def _cmd_update_check():
text=True,
)
upstream_exists = False
- compare_branch = "origin/main"
- else:
- upstream_exists = True
- compare_branch = "upstream/main"
+ compare_branch = f"origin/{branch}"
if fetch_result.returncode != 0:
stderr = fetch_result.stderr.strip()
@@ -8325,7 +8345,10 @@ def cmd_update(args):
return
if getattr(args, "check", False):
- _cmd_update_check()
+ # --check honors --branch so the "any new commits?" answer matches
+ # what a subsequent `hermes update --branch=` would actually pull.
+ branch = (getattr(args, "branch", None) or "main").strip() or "main"
+ _cmd_update_check(branch=branch)
return
gateway_mode = getattr(args, "gateway", False)
@@ -8485,26 +8508,57 @@ def _cmd_update_impl(args, gateway_mode: bool):
)
current_branch = result.stdout.strip()
- # Always update against main
- branch = "main"
+ # Determine the target branch. Default is "main" (the long-standing
+ # CLI behavior); --branch overrides for callers that want to update
+ # against a non-default channel.
+ branch = (getattr(args, "branch", None) or "main").strip() or "main"
- # If user is on a non-main branch or detached HEAD, switch to main
- if current_branch != "main":
+ # If user is on a different branch than the update target, switch
+ # to the target. When the target is "main" this is the historical
+ # "always update against main" behavior; for any other target it's
+ # the same thing — get HEAD onto the requested branch first, then
+ # fast-forward.
+ if current_branch != branch:
label = (
"detached HEAD"
if current_branch == "HEAD"
else f"branch '{current_branch}'"
)
- print(f" ⚠ Currently on {label} — switching to main for update...")
+ print(f" ⚠ Currently on {label} — switching to {branch} for update...")
# Stash before checkout so uncommitted work isn't lost
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
- subprocess.run(
- git_cmd + ["checkout", "main"],
+ checkout_result = subprocess.run(
+ git_cmd + ["checkout", branch],
cwd=PROJECT_ROOT,
capture_output=True,
text=True,
- check=True,
)
+ if checkout_result.returncode != 0:
+ # Local checkout doesn't have this branch yet. Try to set
+ # it up as a tracking branch of origin/. This is
+ # the common case when the requested branch exists upstream
+ # but was never checked out locally.
+ track_result = subprocess.run(
+ git_cmd + ["checkout", "-B", branch, f"origin/{branch}"],
+ cwd=PROJECT_ROOT,
+ capture_output=True,
+ text=True,
+ )
+ if track_result.returncode != 0:
+ # Restore the user's prior branch + stash before bailing
+ # so we don't leave them stranded in a weird state.
+ if auto_stash_ref is not None:
+ _restore_stashed_changes(
+ git_cmd,
+ PROJECT_ROOT,
+ auto_stash_ref,
+ prompt_user=False,
+ input_fn=gw_input_fn,
+ )
+ print(f"✗ Branch '{branch}' does not exist locally or on origin.")
+ if track_result.stderr.strip():
+ print(f" {track_result.stderr.strip().splitlines()[0]}")
+ sys.exit(1)
else:
auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT)
@@ -8535,7 +8589,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
prompt_user=prompt_for_restore,
input_fn=gw_input_fn,
)
- if current_branch not in {"main", "HEAD"}:
+ if current_branch not in {branch, "HEAD"}:
subprocess.run(
git_cmd + ["checkout", current_branch],
cwd=PROJECT_ROOT,
@@ -8597,7 +8651,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
if reset_result.stderr.strip():
print(f" {reset_result.stderr.strip()}")
print(
- " Try manually: git fetch origin && git reset --hard origin/main"
+ f" Try manually: git fetch origin && git reset --hard origin/{branch}"
)
sys.exit(1)
@@ -12835,6 +12889,17 @@ Examples:
default=False,
help="Assume yes for interactive prompts (config migration, stash restore). API-key entry is skipped; run 'hermes config migrate' separately for those.",
)
+ update_parser.add_argument(
+ "--branch",
+ default=None,
+ metavar="NAME",
+ help=(
+ "Update against this branch instead of the default (main). "
+ "If the local checkout is on a different branch, hermes will "
+ "switch to the requested branch first (auto-stashing any "
+ "uncommitted changes)."
+ ),
+ )
update_parser.add_argument(
"--force",
action="store_true",
diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py
index b9087c06663..fb83ae6b6f3 100644
--- a/tests/hermes_cli/test_cmd_update.py
+++ b/tests/hermes_cli/test_cmd_update.py
@@ -276,6 +276,149 @@ class TestCmdUpdateProfileSkillSync:
assert default_p.path in synced_paths
+class TestCmdUpdateBranchFlag:
+ """``hermes update --branch `` targets the requested branch.
+
+ The CLI default stays 'main'; --branch lets callers pick a different
+ target without monkey-patching the implementation.
+ """
+
+ def _branch_side_effect(self, current_branch, target_branch, *, checkout_fails=False, track_fails=False, commit_count="0"):
+ """Mock side-effect that knows about checkout/track behavior.
+
+ - ``current_branch`` what ``git rev-parse --abbrev-ref HEAD`` returns
+ - ``target_branch`` passed via --branch; what we expect the code to switch to
+ - ``checkout_fails`` if True, ``git checkout `` returns non-zero
+ (simulates branch absent locally; code should retry with -B)
+ - ``track_fails`` if True, ``git checkout -B origin/`` ALSO fails
+ (simulates branch absent on origin too)
+ - ``commit_count`` rev-list count returned (0 = up-to-date, >0 = behind)
+ """
+
+ def side_effect(cmd, **kwargs):
+ joined = " ".join(str(c) for c in cmd)
+
+ if "rev-parse" in joined and "--abbrev-ref" in joined:
+ return subprocess.CompletedProcess(cmd, 0, stdout=f"{current_branch}\n", stderr="")
+
+ if "checkout" in joined and "-B" in joined:
+ rc = 128 if track_fails else 0
+ err = f"fatal: '{target_branch}' did not match any file(s) known to git\n" if track_fails else ""
+ return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
+
+ if "checkout" in joined and "-B" not in joined and "rev-parse" not in joined:
+ rc = 128 if checkout_fails else 0
+ err = f"error: pathspec '{target_branch}' did not match\n" if checkout_fails else ""
+ return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
+
+ if "rev-list" in joined:
+ return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
+
+ return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+ return side_effect
+
+ @patch("shutil.which", return_value=None)
+ @patch("subprocess.run")
+ def test_branch_flag_pulls_against_named_branch(self, mock_run, _mock_which, capsys):
+ """--branch bb/gui makes rev-list and pull target origin/bb/gui."""
+ mock_run.side_effect = self._branch_side_effect(
+ current_branch="bb/gui", target_branch="bb/gui", commit_count="3"
+ )
+ args = SimpleNamespace(branch="bb/gui")
+
+ cmd_update(args)
+
+ commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
+
+ # rev-list must compare against origin/bb/gui, not origin/main
+ rev_list_cmds = [c for c in commands if "rev-list" in c]
+ assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds
+ assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds
+
+ # pull must target bb/gui
+ pull_cmds = [c for c in commands if "pull" in c and "ff-only" in c]
+ assert any("bb/gui" in c and "main" not in c.split() for c in pull_cmds), pull_cmds
+
+ @patch("shutil.which", return_value=None)
+ @patch("subprocess.run")
+ def test_branch_flag_defaults_to_main_when_none(self, mock_run, _mock_which, capsys):
+ """No --branch (or --branch=None) preserves the historical 'main' default."""
+ mock_run.side_effect = self._branch_side_effect(
+ current_branch="main", target_branch="main", commit_count="0"
+ )
+ args = SimpleNamespace(branch=None)
+
+ cmd_update(args)
+
+ commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
+ rev_list_cmds = [c for c in commands if "rev-list" in c]
+ assert all("origin/main" in c for c in rev_list_cmds), rev_list_cmds
+
+ @patch("shutil.which", return_value=None)
+ @patch("subprocess.run")
+ def test_branch_flag_switches_from_different_branch(self, mock_run, _mock_which, capsys):
+ """When HEAD is on main and --branch=bb/gui, switch to bb/gui first."""
+ mock_run.side_effect = self._branch_side_effect(
+ current_branch="main", target_branch="bb/gui", commit_count="2"
+ )
+ args = SimpleNamespace(branch="bb/gui")
+
+ cmd_update(args)
+
+ commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
+ # First checkout call should switch us to bb/gui (not -B; happy-path branch exists locally)
+ checkout_cmds = [c for c in commands if "checkout" in c and "rev-parse" not in c]
+ assert len(checkout_cmds) >= 1
+ assert "bb/gui" in checkout_cmds[0]
+
+ out = capsys.readouterr().out
+ assert "switching to bb/gui" in out
+
+ @patch("shutil.which", return_value=None)
+ @patch("subprocess.run")
+ def test_branch_flag_tracks_remote_when_branch_absent_locally(self, mock_run, _mock_which, capsys):
+ """If local lacks the branch but origin has it, fall back to ``checkout -B``."""
+ mock_run.side_effect = self._branch_side_effect(
+ current_branch="main",
+ target_branch="bb/gui",
+ checkout_fails=True, # plain checkout fails
+ track_fails=False, # -B from origin/bb/gui succeeds
+ commit_count="2",
+ )
+ args = SimpleNamespace(branch="bb/gui")
+
+ cmd_update(args)
+
+ commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
+ # Should have BOTH a failed `checkout bb/gui` AND a successful `checkout -B bb/gui origin/bb/gui`
+ track_cmds = [c for c in commands if "checkout" in c and "-B" in c]
+ assert len(track_cmds) == 1
+ assert "bb/gui" in track_cmds[0]
+ assert "origin/bb/gui" in track_cmds[0]
+
+ @patch("shutil.which", return_value=None)
+ @patch("subprocess.run")
+ def test_branch_flag_fails_when_branch_missing_everywhere(self, mock_run, _mock_which, capsys):
+ """If branch doesn't exist locally OR on origin, exit non-zero with clear error."""
+ mock_run.side_effect = self._branch_side_effect(
+ current_branch="main",
+ target_branch="nonexistent",
+ checkout_fails=True,
+ track_fails=True,
+ commit_count="0",
+ )
+ args = SimpleNamespace(branch="nonexistent")
+
+ with pytest.raises(SystemExit) as exc_info:
+ cmd_update(args)
+ assert exc_info.value.code == 1
+
+ out = capsys.readouterr().out
+ assert "does not exist locally or on origin" in out
+ assert "nonexistent" in out
+
+
def test_is_termux_env_true_for_termux_prefix():
from hermes_cli import main as hm
From d5b73937db88c8168782e6216ba4f28b679c66ca Mon Sep 17 00:00:00 2001
From: emozilla
Date: Thu, 21 May 2026 02:14:08 -0400
Subject: [PATCH 005/260] fix(cli): plug silent-divergence holes in --branch
flag
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three follow-up fixes — all the same shape: silently doing the wrong
thing instead of either honoring --branch or refusing.
1) --check --branch raised CalledProcessError from
'git rev-list ... --count' (check=True) when the branch didn't
exist on origin. 'git fetch origin' succeeds without a refspec
(it just fetches what's there), so the bad-branch case wasn't
caught at the fetch step. Now verify the compare ref with
'git rev-parse --verify --quiet' before rev-list and emit a
friendly error.
2) _update_via_zip (Windows fallback for broken git file I/O)
hard-coded branch = 'main', so on the ZIP path --branch=foo
silently downloaded main.zip and told the user it worked. Refuse
in that case instead — silently lying about which branch got
installed is exactly what --branch was added to prevent.
3) _cmd_update_check PyPI path returned before looking at branch,
so PyPI users running 'hermes update --check --branch=x' got a
generic PyPI version check with no indication --branch was
dropped. Now prints a one-line warning when --branch was explicit
and non-main.
Also pull the '(getattr(args, branch, None) or main).strip() or main'
expression into _resolve_update_branch(args) — three callsites agree
on the same parsing.
Tests: 5 new tests for the --check + --branch matrix (named branch,
missing branch, default-main upstream-first, PyPI warning) and the
ZIP refusal. test_cmd_update.py is 20/20 green, broader hermes_cli/
suite (4952 tests) unchanged.
---
hermes_cli/main.py | 63 ++++++++++-
tests/hermes_cli/test_cmd_update.py | 166 ++++++++++++++++++++++++++++
2 files changed, 224 insertions(+), 5 deletions(-)
diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index e2bd362ef8c..79cdee1e843 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -6682,7 +6682,25 @@ def _update_via_zip(args):
import zipfile
from urllib.request import urlretrieve
- branch = "main"
+ # The ZIP fallback exists for Windows git-file-I/O breakage. It pulls a
+ # static archive from GitHub, which is fine for the default "main"
+ # channel but would silently ignore --branch and update from main even
+ # if the user asked for something else — exactly the silent-divergence
+ # bug --branch was added to prevent. Refuse to proceed in that case
+ # rather than lie.
+ branch = _resolve_update_branch(args)
+ if branch != "main":
+ print(
+ f"✗ --branch={branch} is not supported on the Windows ZIP-fallback "
+ "update path."
+ )
+ print(
+ " This path runs when git file I/O is broken on the system. "
+ "Either resolve the git-side breakage (typically an antivirus "
+ "or NTFS filter holding files open) and rerun `hermes update "
+ f"--branch {branch}`, or update against main with `hermes update`."
+ )
+ sys.exit(1)
zip_url = (
f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip"
)
@@ -8045,18 +8063,36 @@ def _finalize_update_output(state):
pass
-def _cmd_update_check(branch: str = "main"):
+def _resolve_update_branch(args) -> str:
+ """Normalize ``args.branch`` into a non-empty branch name.
+
+ Centralizes the "default to main, accept --branch override, treat empty
+ or whitespace-only values as the default" parsing so every consumer of
+ ``--branch`` (check path, git-update path, ZIP-fallback path) agrees on
+ the same answer.
+ """
+ return (getattr(args, "branch", None) or "main").strip() or "main"
+
+
+def _cmd_update_check(branch: str = "main", *, branch_explicit: bool = False):
"""Implement ``hermes update --check``: fetch and report without installing.
``branch`` selects which branch the check compares against. Default is
"main"; callers can pass another branch to ask "are there new commits
on origin/?" without performing the update.
+
+ ``branch_explicit`` is True iff the caller passed --branch on the CLI.
+ PyPI installs can't honor non-default branches, so when this is True
+ on a PyPI install we surface a one-line notice instead of silently
+ dropping the flag.
"""
from hermes_cli.config import detect_install_method
method = detect_install_method(PROJECT_ROOT)
if method == "pip":
from hermes_cli.config import recommended_update_command
from hermes_cli.banner import check_via_pypi
+ if branch_explicit and branch != "main":
+ print(f"⚠ --branch is ignored for PyPI installs (would have checked '{branch}').")
result = check_via_pypi()
if result is None:
print("✗ Could not reach PyPI to check for updates.")
@@ -8127,6 +8163,20 @@ def _cmd_update_check(branch: str = "main"):
print(f" {stderr.splitlines()[0]}")
sys.exit(1)
+ # Verify the compare ref actually exists before asking rev-list about it.
+ # Without this, `git rev-list HEAD..origin/ --count` exits 128 and
+ # (with check=True) raises CalledProcessError, surfacing a Python
+ # traceback. Friendlier to detect-and-report.
+ verify_result = subprocess.run(
+ git_cmd + ["rev-parse", "--verify", "--quiet", compare_branch],
+ cwd=PROJECT_ROOT,
+ capture_output=True,
+ text=True,
+ )
+ if verify_result.returncode != 0:
+ print(f"✗ Branch '{branch}' not found on {compare_branch.split('/', 1)[0]}.")
+ sys.exit(1)
+
rev_result = subprocess.run(
git_cmd + ["rev-list", f"HEAD..{compare_branch}", "--count"],
cwd=PROJECT_ROOT,
@@ -8347,8 +8397,11 @@ def cmd_update(args):
if getattr(args, "check", False):
# --check honors --branch so the "any new commits?" answer matches
# what a subsequent `hermes update --branch=` would actually pull.
- branch = (getattr(args, "branch", None) or "main").strip() or "main"
- _cmd_update_check(branch=branch)
+ branch = _resolve_update_branch(args)
+ _cmd_update_check(
+ branch=branch,
+ branch_explicit=bool(getattr(args, "branch", None)),
+ )
return
gateway_mode = getattr(args, "gateway", False)
@@ -8511,7 +8564,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
# Determine the target branch. Default is "main" (the long-standing
# CLI behavior); --branch overrides for callers that want to update
# against a non-default channel.
- branch = (getattr(args, "branch", None) or "main").strip() or "main"
+ branch = _resolve_update_branch(args)
# If user is on a different branch than the update target, switch
# to the target. When the target is "main" this is the historical
diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py
index fb83ae6b6f3..e4cf849f261 100644
--- a/tests/hermes_cli/test_cmd_update.py
+++ b/tests/hermes_cli/test_cmd_update.py
@@ -419,6 +419,172 @@ class TestCmdUpdateBranchFlag:
assert "nonexistent" in out
+class TestCmdUpdateCheckBranchFlag:
+ """``hermes update --check --branch `` honors the branch override.
+
+ The check path used to call ``git rev-list HEAD..origin/ --count``
+ with ``check=True``. When the branch didn't exist on origin, the fetch
+ silently succeeded (no refspec) but rev-list exited 128 and a raw
+ ``CalledProcessError`` propagated to the user. These tests pin the
+ friendlier behavior: detect-the-missing-ref before rev-list, exit 1
+ with a clear message.
+ """
+
+ def _check_side_effect(
+ self,
+ target_branch: str,
+ *,
+ verify_ok: bool = True,
+ commit_count: str = "0",
+ upstream_fetch_ok: bool = True,
+ ):
+ """Mock side-effect for the _cmd_update_check git pipeline.
+
+ - ``target_branch`` what we expect compare ref to point at
+ - ``verify_ok`` if False, ``git rev-parse --verify --quiet
+ origin/`` fails (branch missing
+ on origin)
+ - ``commit_count`` rev-list count (0 = up-to-date)
+ - ``upstream_fetch_ok`` if False, ``git fetch upstream`` fails
+ (forces fallback to origin on branch==main)
+ """
+
+ def side_effect(cmd, **kwargs):
+ joined = " ".join(str(c) for c in cmd)
+
+ if "fetch" in joined and "upstream" in joined:
+ rc = 0 if upstream_fetch_ok else 128
+ err = "" if upstream_fetch_ok else "fatal: 'upstream' does not appear to be a git repository\n"
+ return subprocess.CompletedProcess(cmd, rc, stdout="", stderr=err)
+
+ if "fetch" in joined and "origin" in joined:
+ return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+ if "rev-parse" in joined and "--verify" in joined:
+ rc = 0 if verify_ok else 1
+ return subprocess.CompletedProcess(cmd, rc, stdout="", stderr="")
+
+ if "rev-list" in joined:
+ return subprocess.CompletedProcess(cmd, 0, stdout=f"{commit_count}\n", stderr="")
+
+ return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+
+ return side_effect
+
+ @patch("hermes_cli.config.detect_install_method", return_value="git")
+ @patch("subprocess.run")
+ def test_check_branch_compares_against_named_origin_branch(
+ self, mock_run, _mock_method, capsys
+ ):
+ """--check --branch bb/gui compares against origin/bb/gui, never origin/main."""
+ mock_run.side_effect = self._check_side_effect(
+ target_branch="bb/gui", verify_ok=True, commit_count="2"
+ )
+ args = SimpleNamespace(check=True, branch="bb/gui")
+
+ cmd_update(args)
+
+ commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
+ # Non-main branch skips upstream probe entirely.
+ assert not any("fetch" in c and "upstream" in c for c in commands), commands
+ # Verify and rev-list both target origin/bb/gui.
+ verify_cmds = [c for c in commands if "rev-parse" in c and "--verify" in c]
+ assert any("origin/bb/gui" in c for c in verify_cmds), verify_cmds
+ rev_list_cmds = [c for c in commands if "rev-list" in c]
+ assert any("origin/bb/gui" in c for c in rev_list_cmds), rev_list_cmds
+ assert not any("origin/main" in c for c in rev_list_cmds), rev_list_cmds
+
+ @patch("hermes_cli.config.detect_install_method", return_value="git")
+ @patch("subprocess.run")
+ def test_check_branch_missing_on_origin_exits_cleanly(
+ self, mock_run, _mock_method, capsys
+ ):
+ """If origin/ doesn't exist, surface a friendly error and exit 1.
+
+ Pre-fix this case raised CalledProcessError from rev-list's check=True
+ and dumped a Python traceback to stdout.
+ """
+ mock_run.side_effect = self._check_side_effect(
+ target_branch="ghost", verify_ok=False
+ )
+ args = SimpleNamespace(check=True, branch="ghost")
+
+ with pytest.raises(SystemExit) as exc_info:
+ cmd_update(args)
+ assert exc_info.value.code == 1
+
+ out = capsys.readouterr().out
+ # No raw Python traceback.
+ assert "Traceback" not in out
+ assert "CalledProcessError" not in out
+ # Friendly message naming the branch.
+ assert "ghost" in out
+ assert "not found" in out
+
+ # rev-list must never have been called once verify failed.
+ commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
+ assert not any("rev-list" in c for c in commands), commands
+
+ @patch("hermes_cli.config.detect_install_method", return_value="git")
+ @patch("subprocess.run")
+ def test_check_default_main_still_prefers_upstream(
+ self, mock_run, _mock_method, capsys
+ ):
+ """No --branch (or --branch=None) preserves the upstream-then-origin probe."""
+ mock_run.side_effect = self._check_side_effect(
+ target_branch="main", verify_ok=True, commit_count="0"
+ )
+ args = SimpleNamespace(check=True, branch=None)
+
+ cmd_update(args)
+
+ commands = [" ".join(str(a) for a in c.args[0]) for c in mock_run.call_args_list]
+ # Should have tried upstream first.
+ assert any("fetch" in c and "upstream" in c for c in commands), commands
+ # Compare ref is upstream/main (upstream fetch succeeded).
+ rev_list_cmds = [c for c in commands if "rev-list" in c]
+ assert any("upstream/main" in c for c in rev_list_cmds), rev_list_cmds
+
+ @patch("hermes_cli.config.detect_install_method", return_value="pip")
+ @patch("hermes_cli.banner.check_via_pypi", return_value=0)
+ @patch("subprocess.run")
+ def test_check_branch_warns_on_pypi_install(
+ self, mock_run, _mock_pypi, _mock_method, capsys
+ ):
+ """PyPI install + --branch= surfaces a warning instead of silent drop."""
+ args = SimpleNamespace(check=True, branch="bb/gui")
+
+ cmd_update(args)
+
+ out = capsys.readouterr().out
+ assert "--branch is ignored for PyPI installs" in out
+ assert "bb/gui" in out
+
+
+class TestCmdUpdateZipBranchRefusal:
+ """``hermes update --branch=`` must refuse on the ZIP fallback path.
+
+ The ZIP fallback hard-codes a GitHub archive URL for main.zip; honoring
+ --branch arbitrarily would require remote-branch existence checks the
+ fallback can't easily do. Refusing is the right move — silently lying
+ about which branch got installed is the bug --branch was meant to prevent.
+ """
+
+ def test_zip_fallback_refuses_non_main_branch(self, capsys):
+ from hermes_cli.main import _update_via_zip
+
+ args = SimpleNamespace(branch="bb/gui")
+ with pytest.raises(SystemExit) as exc_info:
+ _update_via_zip(args)
+ assert exc_info.value.code == 1
+
+ out = capsys.readouterr().out
+ assert "bb/gui" in out
+ assert "not supported" in out
+ # No actual download attempted.
+ assert "Downloading latest version" not in out
+
+
def test_is_termux_env_true_for_termux_prefix():
from hermes_cli import main as hm
From b96a1a042f173c135d5f2fd0bd9d709b9c63a21f Mon Sep 17 00:00:00 2001
From: ilonagaja509-glitch
Date: Fri, 22 May 2026 23:58:55 +0800
Subject: [PATCH 006/260] fix(docker): include anthropic, bedrock,
azure-identity extras in image
Docker containers often run in isolated networks without access to PyPI.
The lazy-install mechanism fails silently in these environments, causing
ImportError when users try to use Anthropic, Bedrock, or Azure providers.
Add --extra anthropic, --extra bedrock, and --extra azure-identity to the
Dockerfile's uv sync command so these provider packages are pre-installed
in the published image.
Fixes #30394
---
Dockerfile | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/Dockerfile b/Dockerfile
index 6e8f0209636..721400f2056 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -75,10 +75,14 @@ RUN npm install --prefer-offline --no-audit && \
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
# redundancy), none of which belong in the published container.
#
+# Provider packages (anthropic, bedrock, azure-identity) are included
+# so Docker users can use these providers without requiring runtime
+# lazy-install access to PyPI (often blocked in containerized envs).
+#
# The editable link is created after the source copy below.
COPY pyproject.toml uv.lock ./
RUN touch ./README.md
-RUN uv sync --frozen --no-install-project --extra all --extra messaging
+RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
From f8695ed6a7e64f9a62ed73fd559bf6887d69d079 Mon Sep 17 00:00:00 2001
From: Sunil123135
Date: Sat, 23 May 2026 21:52:34 +0530
Subject: [PATCH 007/260] feat(docker): add Windows Docker Desktop compatible
compose file
---
docker-compose.windows.yml | 38 ++++++++++++++++++++++++++++++++++++++
1 file changed, 38 insertions(+)
create mode 100644 docker-compose.windows.yml
diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml
new file mode 100644
index 00000000000..31362ddd973
--- /dev/null
+++ b/docker-compose.windows.yml
@@ -0,0 +1,38 @@
+#
+# docker-compose.windows.yml — Windows Docker Desktop compatible
+#
+# Differences from docker-compose.yml:
+# - Removes `network_mode: host` (not supported on Docker Desktop for Windows)
+# - Uses explicit port mappings instead
+# - Uses Windows-style volume path for ~/.hermes
+#
+# Usage:
+# docker compose -f docker-compose.windows.yml up -d
+#
+services:
+ gateway:
+ image: nousresearch/hermes-agent:latest
+ container_name: hermes
+ restart: unless-stopped
+ volumes:
+ - ${USERPROFILE}/.hermes:/opt/data
+ environment:
+ - HERMES_UID=10000
+ - HERMES_GID=10000
+ command: ["gateway", "run"]
+
+ dashboard:
+ image: nousresearch/hermes-agent:latest
+ container_name: hermes-dashboard
+ restart: unless-stopped
+ depends_on:
+ - gateway
+ volumes:
+ - ${USERPROFILE}/.hermes:/opt/data
+ environment:
+ - HERMES_UID=10000
+ - HERMES_GID=10000
+ - HERMES_DASHBOARD_HOST=0.0.0.0
+ ports:
+ - "127.0.0.1:9119:9119"
+ command: ["dashboard", "--host", "0.0.0.0", "--port", "9119", "--no-open", "--insecure"]
From 1579a6f4a974831a16beb4c05b173da7986e888d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Stellar=E9=B1=BC?= <2182712990@qq.com>
Date: Sun, 24 May 2026 23:50:31 +0800
Subject: [PATCH 008/260] docs: clarify xurl auth HOME in Docker
---
skills/social-media/xurl/SKILL.md | 13 +++++++++++--
website/docs/user-guide/docker.md | 3 +++
.../bundled/social-media/social-media-xurl.md | 13 +++++++++++--
3 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/skills/social-media/xurl/SKILL.md b/skills/social-media/xurl/SKILL.md
index 2fe23ef8575..257e86af34f 100644
--- a/skills/social-media/xurl/SKILL.md
+++ b/skills/social-media/xurl/SKILL.md
@@ -38,7 +38,7 @@ Critical rules when operating inside an agent/LLM session:
- **Never** read, print, parse, summarize, upload, or send `~/.xurl` to LLM context.
- **Never** ask the user to paste credentials/tokens into chat.
-- The user must fill `~/.xurl` with secrets manually on their own machine.
+- The user must fill `~/.xurl` with secrets manually on their own machine. In Docker, this must be the `~` seen by Hermes tool subprocesses; see the Docker note below.
- **Never** recommend or execute auth commands with inline secrets in agent sessions.
- **Never** use `--verbose` / `-v` in agent sessions — it can expose auth headers/tokens.
- To verify credentials exist, only use: `xurl auth status`.
@@ -115,6 +115,15 @@ After this, the agent can use any command below without further setup. OAuth 2.0
> **Common pitfall:** If you omit `--app my-app` from `xurl auth oauth2`, the OAuth token is saved to the built-in `default` app profile — which has no client-id or client-secret. Commands will fail with auth errors even though the OAuth flow appeared to succeed. If you hit this, re-run `xurl auth oauth2 --app my-app` and `xurl auth default my-app`.
+> **Docker HOME pitfall:** In the official Hermes Docker layout, `/opt/data` is `HERMES_HOME`, but Hermes tool subprocesses use `/opt/data/home` as `HOME`. That means `~/.xurl` resolves to `/opt/data/home/.xurl` for Hermes-run `xurl` commands, not `/opt/data/.xurl`. Run the user setup with the same HOME:
+> ```bash
+> HOME=/opt/data/home xurl auth apps add my-app --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET
+> HOME=/opt/data/home xurl auth oauth2 --app my-app YOUR_USERNAME
+> HOME=/opt/data/home xurl auth default my-app YOUR_USERNAME
+> HOME=/opt/data/home xurl auth status
+> ```
+> If `HOME=/opt/data xurl auth status` succeeds but `HOME=/opt/data/home xurl auth status` shows no apps or tokens, Hermes tool calls will not see the credentials.
+
---
## Quick Reference
@@ -402,7 +411,7 @@ xurl --app staging /2/users/me # one-off against staging
- **Token refresh:** OAuth 2.0 tokens auto-refresh. Nothing to do.
- **Multiple apps:** Each app has isolated credentials/tokens. Switch with `xurl auth default` or `--app`.
- **Multiple accounts per app:** Select with `-u / --username`, or set a default with `xurl auth default APP USER`.
-- **Token storage:** `~/.xurl` is YAML. Never read or send this file to LLM context.
+- **Token storage:** `~/.xurl` is YAML. In Docker, use the Hermes subprocess HOME (`/opt/data/home` in the official image) so tokens land under `/opt/data/home/.xurl`. Never read or send this file to LLM context.
- **Cost:** X API access is typically paid for meaningful usage. Many failures are plan/permission problems, not code problems.
---
diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md
index 2cd931751da..b0fa44a1069 100644
--- a/website/docs/user-guide/docker.md
+++ b/website/docs/user-guide/docker.md
@@ -115,11 +115,14 @@ The `/opt/data` volume is the single source of truth for all Hermes state. It ma
| `sessions/` | Conversation history |
| `memories/` | Persistent memory store |
| `skills/` | Installed skills |
+| `home/` | Per-profile HOME for Hermes tool subprocesses (`git`, `ssh`, `gh`, `npm`, and skill CLIs) |
| `cron/` | Scheduled job definitions |
| `hooks/` | Event hooks |
| `logs/` | Runtime logs |
| `skins/` | Custom CLI skins |
+Skill CLIs that store credentials under `~` must be initialized against the subprocess HOME, not just the data-volume root. For example, the [xurl skill](./skills/bundled/social-media/social-media-xurl.md) stores OAuth state in `~/.xurl`; in the official Docker layout, Hermes tool calls read that as `/opt/data/home/.xurl`, so run manual xurl auth with `HOME=/opt/data/home` and verify with `HOME=/opt/data/home xurl auth status`.
+
:::warning
Never run two Hermes **gateway** containers against the same data directory simultaneously — session files and memory stores are not designed for concurrent write access.
:::
diff --git a/website/docs/user-guide/skills/bundled/social-media/social-media-xurl.md b/website/docs/user-guide/skills/bundled/social-media/social-media-xurl.md
index 15ab18eea7f..9bbfffc291f 100644
--- a/website/docs/user-guide/skills/bundled/social-media/social-media-xurl.md
+++ b/website/docs/user-guide/skills/bundled/social-media/social-media-xurl.md
@@ -52,7 +52,7 @@ Critical rules when operating inside an agent/LLM session:
- **Never** read, print, parse, summarize, upload, or send `~/.xurl` to LLM context.
- **Never** ask the user to paste credentials/tokens into chat.
-- The user must fill `~/.xurl` with secrets manually on their own machine.
+- The user must fill `~/.xurl` with secrets manually on their own machine. In Docker, this must be the `~` seen by Hermes tool subprocesses; see the Docker note below.
- **Never** recommend or execute auth commands with inline secrets in agent sessions.
- **Never** use `--verbose` / `-v` in agent sessions — it can expose auth headers/tokens.
- To verify credentials exist, only use: `xurl auth status`.
@@ -129,6 +129,15 @@ After this, the agent can use any command below without further setup. OAuth 2.0
> **Common pitfall:** If you omit `--app my-app` from `xurl auth oauth2`, the OAuth token is saved to the built-in `default` app profile — which has no client-id or client-secret. Commands will fail with auth errors even though the OAuth flow appeared to succeed. If you hit this, re-run `xurl auth oauth2 --app my-app` and `xurl auth default my-app`.
+> **Docker HOME pitfall:** In the official Hermes Docker layout, `/opt/data` is `HERMES_HOME`, but Hermes tool subprocesses use `/opt/data/home` as `HOME`. That means `~/.xurl` resolves to `/opt/data/home/.xurl` for Hermes-run `xurl` commands, not `/opt/data/.xurl`. Run the user setup with the same HOME:
+> ```bash
+> HOME=/opt/data/home xurl auth apps add my-app --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET
+> HOME=/opt/data/home xurl auth oauth2 --app my-app YOUR_USERNAME
+> HOME=/opt/data/home xurl auth default my-app YOUR_USERNAME
+> HOME=/opt/data/home xurl auth status
+> ```
+> If `HOME=/opt/data xurl auth status` succeeds but `HOME=/opt/data/home xurl auth status` shows no apps or tokens, Hermes tool calls will not see the credentials.
+
---
## Quick Reference
@@ -416,7 +425,7 @@ xurl --app staging /2/users/me # one-off against staging
- **Token refresh:** OAuth 2.0 tokens auto-refresh. Nothing to do.
- **Multiple apps:** Each app has isolated credentials/tokens. Switch with `xurl auth default` or `--app`.
- **Multiple accounts per app:** Select with `-u / --username`, or set a default with `xurl auth default APP USER`.
-- **Token storage:** `~/.xurl` is YAML. Never read or send this file to LLM context.
+- **Token storage:** `~/.xurl` is YAML. In Docker, use the Hermes subprocess HOME (`/opt/data/home` in the official image) so tokens land under `/opt/data/home/.xurl`. Never read or send this file to LLM context.
- **Cost:** X API access is typically paid for meaningful usage. Many failures are plan/permission problems, not code problems.
---
From 95cee443013c2850eca30494e93c47aeeb280d02 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Stellar=E9=B1=BC?= <2182712990@qq.com>
Date: Mon, 25 May 2026 22:45:12 +0800
Subject: [PATCH 009/260] docs: add Docker audio bridge notes
---
website/docs/user-guide/docker.md | 78 +++++++++++++++++++
.../docs/user-guide/features/voice-mode.md | 2 +
2 files changed, 80 insertions(+)
diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md
index 9456acb21f3..381e5da757d 100644
--- a/website/docs/user-guide/docker.md
+++ b/website/docs/user-guide/docker.md
@@ -238,6 +238,84 @@ services:
Start with `docker compose up -d` and view logs with `docker compose logs -f`. Dashboard output is prefixed with `[dashboard]` so it's easy to filter from gateway logs.
+## Optional: Linux desktop audio bridge
+
+Voice mode in Docker needs two separate things to work: Hermes must be allowed to probe audio devices inside the container, and the container must be able to reach your host audio server. The setup below covers the host audio plumbing for Linux desktops that expose a PulseAudio-compatible socket, including many PipeWire setups.
+
+:::caution
+This is a Linux desktop workaround, not a general Docker Desktop feature. It is useful when you already have host audio working and want CLI voice mode inside the Hermes container. If Hermes still reports `Running inside Docker container -- no audio devices`, use a build that includes Docker audio probing support for `PULSE_SERVER` / `PIPEWIRE_REMOTE`.
+:::
+
+First, create an ALSA config next to your Compose file:
+
+```conf title="asound.conf"
+pcm.!default {
+ type pulse
+ hint {
+ show on
+ description "Default ALSA Output (PulseAudio)"
+ }
+}
+
+pcm.pulse {
+ type pulse
+}
+
+ctl.!default {
+ type pulse
+}
+```
+
+Then build a small derived image with the ALSA PulseAudio plugin installed:
+
+```dockerfile title="Dockerfile.audio"
+FROM nousresearch/hermes-agent:latest
+
+USER root
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends libasound2-plugins \
+ && rm -rf /var/lib/apt/lists/*
+```
+
+Use that image in Compose and pass through the host user's PulseAudio socket and cookie:
+
+```yaml
+services:
+ hermes:
+ build:
+ context: .
+ dockerfile: Dockerfile.audio
+ image: hermes-agent-audio
+ container_name: hermes
+ restart: unless-stopped
+ command: gateway run
+ volumes:
+ - ~/.hermes:/opt/data
+ - /run/user/${HERMES_UID}/pulse:/run/user/${HERMES_UID}/pulse
+ - ~/.config/pulse/cookie:/tmp/pulse-cookie:ro
+ - ./asound.conf:/etc/asound.conf:ro
+ environment:
+ - HERMES_UID=${HERMES_UID}
+ - HERMES_GID=${HERMES_GID}
+ - XDG_RUNTIME_DIR=/run/user/${HERMES_UID}
+ - PULSE_SERVER=unix:/run/user/${HERMES_UID}/pulse/native
+ - PULSE_COOKIE=/tmp/pulse-cookie
+```
+
+Start it with your host UID/GID so the container process can access the per-user audio socket:
+
+```sh
+export HERMES_UID="$(id -u)"
+export HERMES_GID="$(id -g)"
+docker compose up -d --build
+```
+
+To verify what PortAudio sees inside the container:
+
+```sh
+docker exec hermes /opt/hermes/.venv/bin/python -c "import sounddevice as sd; print(sd.query_devices())"
+```
+
## Resource limits
The Hermes container needs moderate resources. Recommended minimums:
diff --git a/website/docs/user-guide/features/voice-mode.md b/website/docs/user-guide/features/voice-mode.md
index ead50fe7c4c..fff3eaa808f 100644
--- a/website/docs/user-guide/features/voice-mode.md
+++ b/website/docs/user-guide/features/voice-mode.md
@@ -485,6 +485,8 @@ brew install portaudio # macOS
sudo apt install portaudio19-dev # Ubuntu
```
+If you are running Hermes inside Docker on a Linux desktop, the container also needs access to your host audio socket. See the [Docker audio bridge](/user-guide/docker#optional-linux-desktop-audio-bridge) notes for a PulseAudio/PipeWire-compatible setup.
+
### Bot doesn't respond in Discord server channels
The bot requires an @mention by default in server channels. Make sure you:
From 0ec0cafdd0ca842822a1ddbdd52f6018122949c0 Mon Sep 17 00:00:00 2001
From: brooklyn!
Date: Mon, 25 May 2026 10:22:43 -0500
Subject: [PATCH 010/260] Merge pull request #31084 from
NousResearch/bb/tui-right-click-copy-selection
fix(tui): right-click copies active transcript selection
---
.../hermes-ink/src/ink/app-mouse.test.ts | 90 +++++++++++++++++++
.../hermes-ink/src/ink/components/App.tsx | 26 ++++++
ui-tui/packages/hermes-ink/src/ink/ink.tsx | 8 +-
3 files changed, 123 insertions(+), 1 deletion(-)
create mode 100644 ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts
diff --git a/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts b/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts
new file mode 100644
index 00000000000..a4c63d3ebed
--- /dev/null
+++ b/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import { handleMouseEvent } from './components/App.js'
+import { createSelectionState, startSelection, updateSelection } from './selection.js'
+
+const makeApp = () => {
+ const selection = createSelectionState()
+
+ return {
+ clickCount: 1,
+ lastHoverCol: -1,
+ lastHoverRow: -1,
+ mouseCaptureTarget: undefined,
+ props: {
+ getSelectedText: vi.fn(() => 'selected text'),
+ onCopySelectionNoClear: vi.fn(async () => 'selected text'),
+ onHoverAt: vi.fn(),
+ onMouseDownAt: vi.fn(),
+ onMouseDragAt: vi.fn(),
+ onMouseUpAt: vi.fn(),
+ onSelectionChange: vi.fn(),
+ selection
+ }
+ } as any
+}
+
+describe('handleMouseEvent right-click selection behavior', () => {
+ it('copies an active selection instead of dispatching right-click paste handlers', async () => {
+ const app = makeApp()
+
+ startSelection(app.props.selection, 0, 0)
+ updateSelection(app.props.selection, 4, 0)
+
+ handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
+ await Promise.resolve()
+
+ expect(app.props.onCopySelectionNoClear).toHaveBeenCalledOnce()
+ expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
+ expect(app.clickCount).toBe(0)
+ })
+
+ it('falls back to right-click handlers when selection copy has no clipboard path', async () => {
+ const app = makeApp()
+ app.props.onCopySelectionNoClear.mockResolvedValue('')
+
+ startSelection(app.props.selection, 0, 0)
+ updateSelection(app.props.selection, 4, 0)
+
+ handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
+ await Promise.resolve()
+
+ expect(app.props.onCopySelectionNoClear).toHaveBeenCalledOnce()
+ expect(app.props.onMouseDownAt).toHaveBeenCalledWith(2, 0, 2)
+ })
+
+ it('does not paste when highlighted selection text is empty', async () => {
+ const app = makeApp()
+ app.props.getSelectedText.mockReturnValue('')
+
+ startSelection(app.props.selection, 0, 0)
+ updateSelection(app.props.selection, 4, 0)
+
+ handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
+ await Promise.resolve()
+
+ expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
+ expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
+ })
+
+ it('does not repeatedly copy or paste during right-button motion events over a selection', () => {
+ const app = makeApp()
+
+ startSelection(app.props.selection, 0, 0)
+ updateSelection(app.props.selection, 4, 0)
+
+ handleMouseEvent(app, { action: 'press', button: 0x20 | 2, col: 3, kind: 'mouse', row: 1 })
+
+ expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
+ expect(app.props.onMouseDownAt).not.toHaveBeenCalled()
+ })
+
+ it('still dispatches right-click handlers when no text is selected', () => {
+ const app = makeApp()
+
+ handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 })
+
+ expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled()
+ expect(app.props.onMouseDownAt).toHaveBeenCalledWith(2, 0, 2)
+ })
+})
diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx
index 54892e3b7b1..81d3a689f28 100644
--- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx
+++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx
@@ -76,6 +76,10 @@ type Props = {
// DOM elements. Called for mode-1003 motion events with no button held.
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
readonly onHoverAt: (col: number, row: number) => void
+ // Copy the active fullscreen text selection without clearing the highlight.
+ // Used for terminal-native right-click-copy behaviour.
+ readonly onCopySelectionNoClear: () => Promise
+ readonly getSelectedText: () => string
// Look up the OSC 8 hyperlink at (col, row) synchronously at click
// time. Returns the URL or undefined. The browser-open is deferred by
// MULTI_CLICK_TIMEOUT_MS so double-click can cancel it.
@@ -631,6 +635,28 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void {
if (baseButton !== 0) {
// Non-left press breaks the multi-click chain.
app.clickCount = 0
+
+ if (baseButton === 2 && hasSelection(sel)) {
+ if ((m.button & 0x20) !== 0) {
+ return
+ }
+
+ if (!app.props.getSelectedText()) {
+ return
+ }
+
+ void app.props
+ .onCopySelectionNoClear()
+ .then(text => {
+ if (!text) {
+ app.props.onMouseDownAt(col, row, baseButton)
+ }
+ })
+ .catch(() => app.props.onMouseDownAt(col, row, baseButton))
+
+ return
+ }
+
app.props.onMouseDownAt(col, row, baseButton)
return
diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx
index 485ef5ffca9..d8c95fcc703 100644
--- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx
+++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx
@@ -1492,7 +1492,7 @@ export default class Ink {
return ''
}
- const text = getSelectedText(this.selection, this.frontFrame.screen)
+ const text = this.getTextSelectionText()
if (text) {
try {
@@ -1514,6 +1514,10 @@ export default class Ink {
return ''
}
+ getTextSelectionText(): string {
+ return hasSelection(this.selection) ? getSelectedText(this.selection, this.frontFrame.screen) : ''
+ }
+
/**
* Copy the current text selection to the system clipboard via OSC 52
* and clear the selection. Returns the copied text (empty if no selection
@@ -2332,7 +2336,9 @@ export default class Ink {
dispatchKeyboardEvent={this.dispatchKeyboardEvent}
exitOnCtrlC={this.options.exitOnCtrlC}
getHyperlinkAt={this.getHyperlinkAt}
+ getSelectedText={this.getTextSelectionText}
onClickAt={this.dispatchClick}
+ onCopySelectionNoClear={this.copySelectionNoClear}
onCursorAdvance={this.noteExternalCursorAdvance}
onCursorDeclaration={this.setCursorDeclaration}
onExit={this.unmount}
From 50aaf0c4ad84635b53400ca8c5b0837689164bb0 Mon Sep 17 00:00:00 2001
From: brooklyn!
Date: Mon, 25 May 2026 10:23:03 -0500
Subject: [PATCH 011/260] fix(tui): delineate assistant responses from details
(#31087)
* fix(tui): delineate assistant responses from details
Add a muted Response marker before assistant text when thinking/tool details are visible so reasoning and final output do not visually run together.
* fix(tui): account for response separator height
Keep virtual transcript estimates aligned with the new response separator and avoid allocating trimmed copies of long assistant text.
* fix(tui): gate response separator estimate on details
Only add response-separator height when assistant details actually render, and use a non-allocating body-text check.
* fix(tui): skip empty detail height estimates
Do not add virtual transcript height for assistant details when no thinking or tool detail UI will render.
* fix(tui): estimate details by section visibility
Pass resolved thinking/tool visibility into virtual height estimates so hidden detail sections do not reserve response-separator rows.
---
ui-tui/src/__tests__/messageLine.test.ts | 19 ++++++++++
ui-tui/src/__tests__/virtualHeights.test.ts | 39 +++++++++++++++++++++
ui-tui/src/app/useMainApp.ts | 18 ++++++++--
ui-tui/src/components/messageLine.tsx | 16 +++++++++
ui-tui/src/lib/virtualHeights.ts | 18 ++++++++--
5 files changed, 106 insertions(+), 4 deletions(-)
create mode 100644 ui-tui/src/__tests__/messageLine.test.ts
diff --git a/ui-tui/src/__tests__/messageLine.test.ts b/ui-tui/src/__tests__/messageLine.test.ts
new file mode 100644
index 00000000000..b330bbd2374
--- /dev/null
+++ b/ui-tui/src/__tests__/messageLine.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+
+import { shouldShowResponseSeparator } from '../components/messageLine.js'
+
+describe('shouldShowResponseSeparator', () => {
+ it('separates assistant response text from visible details', () => {
+ expect(shouldShowResponseSeparator({ role: 'assistant', text: 'final', thinking: 'plan' }, true)).toBe(true)
+ })
+
+ it('does not add a response separator without details or body text', () => {
+ expect(shouldShowResponseSeparator({ role: 'assistant', text: 'final' }, false)).toBe(false)
+ expect(shouldShowResponseSeparator({ role: 'assistant', text: ' ', thinking: 'plan' }, true)).toBe(false)
+ })
+
+ it('does not add response separators to non-assistant transcript rows', () => {
+ expect(shouldShowResponseSeparator({ role: 'user', text: 'prompt' }, true)).toBe(false)
+ expect(shouldShowResponseSeparator({ role: 'system', text: 'note' }, true)).toBe(false)
+ })
+})
diff --git a/ui-tui/src/__tests__/virtualHeights.test.ts b/ui-tui/src/__tests__/virtualHeights.test.ts
index b93df65d72a..37cb9c009ce 100644
--- a/ui-tui/src/__tests__/virtualHeights.test.ts
+++ b/ui-tui/src/__tests__/virtualHeights.test.ts
@@ -32,6 +32,45 @@ describe('virtual height estimates', () => {
)
})
+ it('accounts for the response separator when assistant details are visible', () => {
+ const msg: Msg = { role: 'assistant', text: 'ok', thinking: 'plan' }
+
+ expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBe(
+ estimatedMsgHeight(msg, 80, { compact: false, details: false }) + 3
+ )
+ })
+
+ it('does not account for a response separator without visible details', () => {
+ const msg: Msg = { role: 'assistant', text: 'ok' }
+
+ expect(estimatedMsgHeight(msg, 80, { compact: false, details: true })).toBe(
+ estimatedMsgHeight(msg, 80, { compact: false, details: false })
+ )
+ })
+
+ it('honors per-section visibility when estimating response separators', () => {
+ const thinkingOnly: Msg = { role: 'assistant', text: 'ok', thinking: 'plan' }
+ const toolsOnly: Msg = { role: 'assistant', text: 'ok', tools: ['Tool A'] }
+
+ expect(
+ estimatedMsgHeight(thinkingOnly, 80, {
+ compact: false,
+ details: true,
+ thinkingVisible: false,
+ toolsVisible: true
+ })
+ ).toBe(estimatedMsgHeight(thinkingOnly, 80, { compact: false, details: false }))
+
+ expect(
+ estimatedMsgHeight(toolsOnly, 80, {
+ compact: false,
+ details: true,
+ thinkingVisible: true,
+ toolsVisible: false
+ })
+ ).toBe(estimatedMsgHeight(toolsOnly, 80, { compact: false, details: false }))
+ })
+
it('reserves two extra rows for the inter-turn separator on non-first user messages', () => {
const msg: Msg = { role: 'user', text: 'follow-up question' }
const base = estimatedMsgHeight(msg, 80, { compact: false, details: false })
diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts
index 4d7ab8926ba..ad2348d0dce 100644
--- a/ui-tui/src/app/useMainApp.ts
+++ b/ui-tui/src/app/useMainApp.ts
@@ -252,7 +252,10 @@ export function useMainApp(gw: GatewayClient) {
return `${thinking}:${tools}`
}, [ui.detailsMode, ui.detailsModeCommandOverride, ui.sections])
- const detailsVisible = detailsLayoutKey !== 'hidden:hidden'
+ const [thinkingDetailsMode, toolsDetailsMode] = detailsLayoutKey.split(':')
+ const thinkingDetailsVisible = thinkingDetailsMode !== 'hidden'
+ const toolsDetailsVisible = toolsDetailsMode !== 'hidden'
+ const detailsVisible = thinkingDetailsVisible || toolsDetailsVisible
const userPromptWidth = composerPromptWidth(ui.theme.brand.prompt)
const heightCacheKey = `${ui.sid ?? 'draft'}:${cols}:${userPromptWidth}:${ui.compact ? '1' : '0'}:${detailsLayoutKey}`
@@ -281,10 +284,21 @@ export function useMainApp(gw: GatewayClient) {
estimatedMsgHeight(virtualRows[index]!.msg, cols, {
compact: ui.compact,
details: detailsVisible,
+ thinkingVisible: thinkingDetailsVisible,
+ toolsVisible: toolsDetailsVisible,
userPrompt: ui.theme.brand.prompt,
withSeparator: virtualRows[index]!.msg.role === 'user' && firstUserIdx >= 0 && index > firstUserIdx
}),
- [cols, detailsVisible, firstUserIdx, ui.compact, ui.theme.brand.prompt, virtualRows]
+ [
+ cols,
+ detailsVisible,
+ firstUserIdx,
+ thinkingDetailsVisible,
+ toolsDetailsVisible,
+ ui.compact,
+ ui.theme.brand.prompt,
+ virtualRows
+ ]
)
const syncHeightCache = useCallback(
diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx
index 4d1481373ab..2a7f0bbba23 100644
--- a/ui-tui/src/components/messageLine.tsx
+++ b/ui-tui/src/components/messageLine.tsx
@@ -109,6 +109,8 @@ export const MessageLine = memo(function MessageLine({
const showDetails =
(toolsMode !== 'hidden' && Boolean(msg.tools?.length)) || (thinkingMode !== 'hidden' && Boolean(thinking))
+ const showResponseSeparator = shouldShowResponseSeparator(msg, showDetails)
+
const content = (() => {
if (msg.kind === 'slash') {
return {msg.text}
@@ -195,6 +197,17 @@ export const MessageLine = memo(function MessageLine({
)}
+ {showResponseSeparator && (
+
+
+ └─
+
+
+ Response
+
+
+ )}
+
@@ -208,6 +221,9 @@ export const MessageLine = memo(function MessageLine({
)
})
+export const shouldShowResponseSeparator = (msg: Msg, showDetails: boolean): boolean =>
+ msg.role === 'assistant' && showDetails && /\S/.test(msg.text)
+
interface MessageLineProps {
cols: number
compact?: boolean
diff --git a/ui-tui/src/lib/virtualHeights.ts b/ui-tui/src/lib/virtualHeights.ts
index 874f8a1b8dc..4ae2ee3f734 100644
--- a/ui-tui/src/lib/virtualHeights.ts
+++ b/ui-tui/src/lib/virtualHeights.ts
@@ -1,6 +1,6 @@
+import { TERMUX_TUI_MODE } from '../config/env.js'
import type { Msg } from '../types.js'
-import { TERMUX_TUI_MODE } from '../config/env.js'
import { transcriptBodyWidth } from './inputMetrics.js'
const hashText = (text: string) => {
@@ -72,11 +72,15 @@ export const estimatedMsgHeight = (
{
compact,
details,
+ thinkingVisible = details,
+ toolsVisible = details,
userPrompt = '',
withSeparator = false
}: {
compact: boolean
details: boolean
+ thinkingVisible?: boolean
+ toolsVisible?: boolean
userPrompt?: string
withSeparator?: boolean
}
@@ -111,7 +115,17 @@ export const estimatedMsgHeight = (
}
if (details) {
- h += (msg.tools?.length ?? 0) + wrappedLines(msg.thinking ?? '', bodyWidth)
+ const hasVisibleTools = toolsVisible && Boolean(msg.tools?.length)
+ const hasVisibleThinking = thinkingVisible && /\S/.test(msg.thinking ?? '')
+ const hasVisibleDetails = hasVisibleTools || hasVisibleThinking
+
+ if (hasVisibleDetails) {
+ h += (hasVisibleTools ? (msg.tools?.length ?? 0) : 0) + (hasVisibleThinking ? wrappedLines(msg.thinking ?? '', bodyWidth) : 0)
+
+ if (msg.role === 'assistant' && /\S/.test(msg.text)) {
+ h += 2
+ }
+ }
}
if (msg.role === 'user' || msg.kind === 'diff') {
From 99d62f6ba1fe7d59542cc4350d90c46bada108b2 Mon Sep 17 00:00:00 2001
From: xxxigm
Date: Fri, 22 May 2026 09:39:40 +0700
Subject: [PATCH 012/260] fix(gateway): protect in-flight subagents from
busy-mode interrupts (#30170)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a user sends a conversational follow-up while delegate_task is
running, gateway/run.py calls running_agent.interrupt(event.text) on
the PARENT agent. AIAgent.interrupt() then cascades synchronously
through self._active_children and calls interrupt() on every child
subagent, aborting in-flight delegate_task work. The user sees the
fallback cascade with no root-cause in the gateway log, and minutes of
subagent progress are destroyed — the exact failure mode reported in
Add GatewayRunner._agent_has_active_subagents(running_agent) — a
static helper that returns True iff the parent is currently driving
subagents via delegate_task. The helper is type-defensive: it ignores
truthy MagicMock auto-attributes (so this doesn't accidentally fire
in every test mock that hits the busy path), the _AGENT_PENDING_SENTINEL
placeholder, and missing locks.
Wire the helper into both interrupt branches:
1. _handle_active_session_busy_message — the adapter-level busy
handler. When busy_input_mode == 'interrupt' AND the parent has
active subagents, demote to 'queue' semantics: skip the
parent.interrupt() call, merge the message into the pending
queue, and surface a dedicated ack ("⏳ Subagent working — your
message is queued for when it finishes (use /stop to cancel
everything).") so the operator knows the message wasn't lost and
discovers the explicit escape hatch.
2. The PRIORITY interrupt branch inside _handle_message — the
non-command fast path. Same rationale, same demotion. Routes
through _queue_or_replace_pending_event so the next-turn pickup
stays unchanged.
Explicit /stop and /new commands take a completely different path
(_interrupt_and_clear_session in the slash-command dispatch at line
~6771) and are NOT affected by this guard — the operator still has a
way to force-cancel everything when they actually mean it. Configured
'queue' and 'steer' modes are also untouched: 'queue' already does the
right thing, and 'steer' goes through running_agent.steer() which does
NOT cascade to children (so subagents survive a steer too).
This is Phase 1 of the fix outlined in #30170 — the minimum viable
change that stops subagent loss. Phase 2 (delegation-aware steer
forwarding to active children) and Phase 3 (async delegation, #11508)
are intentionally out of scope.
Refs #30170.
---
gateway/run.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 81 insertions(+)
diff --git a/gateway/run.py b/gateway/run.py
index 696f9b29b81..469db48e5b1 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -3034,6 +3034,44 @@ class GatewayRunner:
if agent is not _AGENT_PENDING_SENTINEL
}
+ @staticmethod
+ def _agent_has_active_subagents(running_agent: Any) -> bool:
+ """Return True when *running_agent* is currently driving subagents
+ via the ``delegate_task`` tool.
+
+ Background (#30170): ``AIAgent.interrupt()`` cascades through the
+ parent's ``_active_children`` list and calls ``interrupt()`` on
+ every child synchronously, which aborts in-flight subagent work
+ and produces a fallback cascade with no actionable signal.
+ Demoting ``busy_input_mode='interrupt'`` to ``queue`` semantics
+ whenever this helper returns True protects subagent work from
+ conversational follow-ups while leaving the explicit ``/stop``
+ path (which goes through ``_interrupt_and_clear_session``)
+ untouched. Safe-by-default: returns False on any attribute or
+ lock error so a missing/broken parent never blocks the existing
+ interrupt path.
+ """
+ if running_agent is None or running_agent is _AGENT_PENDING_SENTINEL:
+ return False
+ children = getattr(running_agent, "_active_children", None)
+ # AIAgent always initialises this as a concrete list (see
+ # agent/agent_init.py). Reject anything that isn't a real
+ # collection — this guards against ``MagicMock()._active_children``
+ # auto-creating a truthy stub in tests and triggering the demotion
+ # against an agent that doesn't actually have subagents.
+ if not isinstance(children, (list, tuple, set)):
+ return False
+ if not children:
+ return False
+ lock = getattr(running_agent, "_active_children_lock", None)
+ try:
+ if lock is not None:
+ with lock:
+ return bool(children)
+ return bool(children)
+ except Exception:
+ return False
+
def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent) -> None:
adapter = self.adapters.get(event.source.platform)
if not adapter:
@@ -3105,6 +3143,25 @@ class GatewayRunner:
# queueing + interrupting. If the agent isn't running yet
# (sentinel) or lacks steer(), or the payload is empty, fall back
# to queue semantics so nothing is lost.
+ # #30170 — Subagent protection. ``AIAgent.interrupt()`` cascades
+ # to every entry in the parent's ``_active_children`` list and
+ # aborts in-flight ``delegate_task`` work. Demote ``interrupt``
+ # to ``queue`` when the parent is currently driving subagents so
+ # a conversational follow-up doesn't destroy minutes of subagent
+ # work. Explicit ``/stop`` and ``/new`` slash commands go through
+ # ``_interrupt_and_clear_session`` and are unaffected — the
+ # operator still has a way to force-cancel everything.
+ demoted_for_subagents = (
+ effective_mode == "interrupt"
+ and self._agent_has_active_subagents(running_agent)
+ )
+ if demoted_for_subagents:
+ logger.info(
+ "Demoting busy_input_mode 'interrupt' to 'queue' for session %s "
+ "because the running agent has active subagents (#30170)",
+ session_key,
+ )
+ effective_mode = "queue"
steered = False
if effective_mode == "steer":
steer_text = (event.text or "").strip()
@@ -3192,6 +3249,14 @@ class GatewayRunner:
f"⏩ Steered into current run{status_detail}. "
f"Your message arrives after the next tool call."
)
+ elif is_queue_mode and demoted_for_subagents:
+ # #30170 — explain the demotion so the user knows their
+ # follow-up didn't accidentally kill the subagent and
+ # discovers `/stop` as the explicit escape hatch.
+ message = (
+ f"⏳ Subagent working{status_detail} — your message is queued for "
+ f"when it finishes (use /stop to cancel everything)."
+ )
elif is_queue_mode:
message = (
f"⏳ Queued for the next turn{status_detail}. "
@@ -7246,6 +7311,22 @@ class GatewayRunner:
logger.debug("PRIORITY steer-fallback-to-queue for session %s", _quick_key)
self._queue_or_replace_pending_event(_quick_key, event)
return None
+ # #30170 — Subagent protection (PRIORITY path). Same rationale
+ # as ``_handle_active_session_busy_message``: an interrupt
+ # cascades through ``_active_children`` and aborts in-flight
+ # delegate_task work. Demote to queue semantics when the
+ # parent is currently driving subagents so a conversational
+ # follow-up doesn't destroy minutes of subagent progress.
+ # /stop reaches its dedicated handler above (line ~6771), so
+ # the operator still has a clean escape hatch.
+ if self._agent_has_active_subagents(running_agent):
+ logger.info(
+ "PRIORITY interrupt demoted to queue for session %s "
+ "because the running agent has active subagents (#30170)",
+ _quick_key,
+ )
+ self._queue_or_replace_pending_event(_quick_key, event)
+ return None
logger.debug("PRIORITY interrupt for session %s", _quick_key)
running_agent.interrupt(event.text)
# NOTE: self._pending_messages was write-only (never consumed).
From 737ee81167c66d88f166670751912b32ac1734ce Mon Sep 17 00:00:00 2001
From: xxxigm
Date: Fri, 22 May 2026 09:39:51 +0700
Subject: [PATCH 013/260] test(gateway): regression tests for #30170 subagent
interrupt protection
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
17 new tests in tests/gateway/test_subagent_protection_30170.py pin
down both the detection helper and the demotion behaviour:
* TestAgentHasActiveSubagents — 11 cases covering the precision and
defensiveness of _agent_has_active_subagents:
- returns False for None, _AGENT_PENDING_SENTINEL, and stub
agents that lack the _active_children attribute;
- returns False for an empty list (the steady state of an idle
AIAgent);
- returns True for one or many children;
- works when _active_children_lock is None (test stubs);
- rejects truthy MagicMock auto-attributes — this is the
regression-guard for "every MagicMock-based gateway test
suddenly demotes to queue mode" (which is how this was
originally found);
- accepts list/tuple/set as the children container.
* TestBusyHandlerDemotesInterruptForSubagents — 6 cases driving
_handle_active_session_busy_message directly:
- parent.interrupt is NOT called when subagents are active,
message is still merged into the pending queue;
- ack copy mentions "Subagent working", "queued", and the
/stop escape hatch — and does NOT mention "Interrupting";
- with no subagents, behaviour is byte-identical to the
pre-#30170 interrupt path (parent.interrupt called with the
user text, ack says "Interrupting");
- configured queue mode keeps its vanilla "Queued for the next
turn" ack (the #30170 demotion-specific copy must NOT fire);
- configured steer mode still routes to running_agent.steer()
even when subagents are active (the guard is interrupt-only);
- _AGENT_PENDING_SENTINEL does not trigger demotion.
Refs #30170.
---
.../gateway/test_subagent_protection_30170.py | 348 ++++++++++++++++++
1 file changed, 348 insertions(+)
create mode 100644 tests/gateway/test_subagent_protection_30170.py
diff --git a/tests/gateway/test_subagent_protection_30170.py b/tests/gateway/test_subagent_protection_30170.py
new file mode 100644
index 00000000000..365991de1eb
--- /dev/null
+++ b/tests/gateway/test_subagent_protection_30170.py
@@ -0,0 +1,348 @@
+"""Regression tests for #30170.
+
+#30170: Sending a message while ``delegate_task`` is running killed the
+subagent because the gateway always called ``running_agent.interrupt()``
+on the parent, which then cascaded synchronously through
+``AIAgent._active_children`` and aborted every in-flight subagent. The
+reporter (and the linked Phase-1 spec) asked for the gateway to demote
+``busy_input_mode='interrupt'`` to ``queue`` semantics whenever the
+parent is currently driving subagents, while leaving explicit ``/stop``
+and ``/new`` slash commands untouched.
+
+These tests pin down the gateway-side guard introduced for #30170:
+
+* ``GatewayRunner._agent_has_active_subagents`` correctly recognises
+ parents that own real children, without false-positives from a
+ ``MagicMock()._active_children`` auto-attribute, missing locks, or
+ the ``_AGENT_PENDING_SENTINEL`` placeholder.
+* ``_handle_active_session_busy_message`` demotes the interrupt mode to
+ queue semantics (no ``interrupt()`` call, message merged into the
+ pending queue, ack reflects the demotion) when the parent has active
+ subagents.
+* The ``queue`` and ``steer`` configured modes still behave exactly as
+ before — the guard is interrupt-only.
+"""
+
+from __future__ import annotations
+
+import sys
+import threading
+import time
+import types
+from typing import Any
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+# ──────────────────────────────────────────────────────────────────────
+# Minimal stubs so gateway imports cleanly (mirrors test_busy_session_ack)
+# ──────────────────────────────────────────────────────────────────────
+_tg = types.ModuleType("telegram")
+_tg.constants = types.ModuleType("telegram.constants")
+_ct = MagicMock()
+_ct.SUPERGROUP = "supergroup"
+_ct.GROUP = "group"
+_ct.PRIVATE = "private"
+_tg.constants.ChatType = _ct
+sys.modules.setdefault("telegram", _tg)
+sys.modules.setdefault("telegram.constants", _tg.constants)
+sys.modules.setdefault("telegram.ext", types.ModuleType("telegram.ext"))
+
+from gateway.platforms.base import ( # noqa: E402
+ MessageEvent,
+ MessageType,
+ SessionSource,
+ build_session_key,
+)
+from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL # noqa: E402
+
+
+# ──────────────────────────────────────────────────────────────────────
+# Builders (parallel to tests/gateway/test_busy_session_ack.py)
+# ──────────────────────────────────────────────────────────────────────
+def _make_event(text: str = "hello", chat_id: str = "123") -> MessageEvent:
+ source = SessionSource(
+ platform=MagicMock(value="telegram"),
+ chat_id=chat_id,
+ chat_type="private",
+ user_id="user1",
+ )
+ return MessageEvent(
+ text=text,
+ message_type=MessageType.TEXT,
+ source=source,
+ message_id="msg1",
+ )
+
+
+def _make_runner() -> GatewayRunner:
+ runner = object.__new__(GatewayRunner)
+ runner._running_agents = {}
+ runner._running_agents_ts = {}
+ runner._pending_messages = {}
+ runner._busy_ack_ts = {}
+ runner._draining = False
+ runner.adapters = {}
+ runner.config = MagicMock()
+ runner.session_store = None
+ runner.hooks = MagicMock()
+ runner.hooks.emit = AsyncMock()
+ runner.pairing_store = MagicMock()
+ runner.pairing_store.is_approved.return_value = True
+ runner._is_user_authorized = lambda _source: True
+ return runner
+
+
+def _make_adapter() -> MagicMock:
+ adapter = MagicMock()
+ adapter._pending_messages = {}
+ adapter._send_with_retry = AsyncMock()
+ adapter.config = MagicMock()
+ adapter.config.extra = {}
+ adapter.platform = MagicMock(value="telegram")
+ return adapter
+
+
+def _make_parent_with_subagents(
+ *, children: int = 1, with_lock: bool = True
+) -> MagicMock:
+ """A MagicMock shaped like an AIAgent that currently owns *children* subagents."""
+ parent = MagicMock()
+ parent._active_children = [MagicMock() for _ in range(children)]
+ parent._active_children_lock = threading.Lock() if with_lock else None
+ parent.get_activity_summary.return_value = {
+ "api_call_count": 7,
+ "max_iterations": 60,
+ "current_tool": "delegate_task",
+ }
+ return parent
+
+
+def _make_parent_no_subagents() -> MagicMock:
+ """A MagicMock shaped like an AIAgent that is NOT delegating."""
+ parent = MagicMock()
+ parent._active_children = []
+ parent._active_children_lock = threading.Lock()
+ parent.get_activity_summary.return_value = {
+ "api_call_count": 3,
+ "max_iterations": 60,
+ "current_tool": "terminal",
+ }
+ return parent
+
+
+# ──────────────────────────────────────────────────────────────────────
+# _agent_has_active_subagents
+# ──────────────────────────────────────────────────────────────────────
+class TestAgentHasActiveSubagents:
+ """The detection helper must be both precise and defensive."""
+
+ def test_returns_false_for_none(self) -> None:
+ assert GatewayRunner._agent_has_active_subagents(None) is False
+
+ def test_returns_false_for_pending_sentinel(self) -> None:
+ assert (
+ GatewayRunner._agent_has_active_subagents(_AGENT_PENDING_SENTINEL)
+ is False
+ )
+
+ def test_returns_false_when_attribute_missing(self) -> None:
+ """Production AIAgents always have _active_children, but the helper
+ must not blow up on test stubs or partial mocks."""
+
+ class StubAgent:
+ pass
+
+ assert GatewayRunner._agent_has_active_subagents(StubAgent()) is False
+
+ def test_returns_false_for_empty_list(self) -> None:
+ assert (
+ GatewayRunner._agent_has_active_subagents(_make_parent_no_subagents())
+ is False
+ )
+
+ def test_returns_true_for_single_child(self) -> None:
+ assert (
+ GatewayRunner._agent_has_active_subagents(_make_parent_with_subagents())
+ is True
+ )
+
+ def test_returns_true_for_many_children(self) -> None:
+ assert (
+ GatewayRunner._agent_has_active_subagents(
+ _make_parent_with_subagents(children=5)
+ )
+ is True
+ )
+
+ def test_works_without_lock(self) -> None:
+ """``_active_children_lock`` is optional in test stubs."""
+ assert (
+ GatewayRunner._agent_has_active_subagents(
+ _make_parent_with_subagents(with_lock=False)
+ )
+ is True
+ )
+
+ def test_rejects_truthy_non_collection_attribute(self) -> None:
+ """The MagicMock auto-attribute regression. ``MagicMock()._active_children``
+ is itself a truthy MagicMock — without the isinstance guard, the
+ helper would falsely report subagents on every test mock."""
+ parent = MagicMock() # no explicit _active_children setup
+ assert GatewayRunner._agent_has_active_subagents(parent) is False
+
+ @pytest.mark.parametrize(
+ "container",
+ [(MagicMock(),), {MagicMock()}, [MagicMock()]],
+ ids=["tuple", "set", "list"],
+ )
+ def test_accepts_list_tuple_set(self, container: Any) -> None:
+ parent = MagicMock()
+ parent._active_children = container
+ parent._active_children_lock = threading.Lock()
+ assert GatewayRunner._agent_has_active_subagents(parent) is True
+
+
+# ──────────────────────────────────────────────────────────────────────
+# _handle_active_session_busy_message — interrupt demotion
+# ──────────────────────────────────────────────────────────────────────
+class TestBusyHandlerDemotesInterruptForSubagents:
+ """The Phase-1 fix from #30170: parent.interrupt() must NOT fire when
+ the parent is currently driving subagents."""
+
+ @pytest.mark.asyncio
+ async def test_does_not_call_interrupt_when_subagents_active(self) -> None:
+ runner = _make_runner()
+ runner._busy_input_mode = "interrupt"
+ adapter = _make_adapter()
+ event = _make_event(text="follow up while subagent runs")
+ sk = build_session_key(event.source)
+ parent = _make_parent_with_subagents()
+ runner._running_agents[sk] = parent
+ runner.adapters[event.source.platform] = adapter
+
+ with patch("gateway.run.merge_pending_message_event") as merge_mock:
+ handled = await runner._handle_active_session_busy_message(event, sk)
+
+ assert handled is True
+ parent.interrupt.assert_not_called()
+ # Message must still be queued so it gets picked up on the next turn.
+ merge_mock.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_ack_explains_the_demotion(self) -> None:
+ """The user-visible ack must mention the subagent context AND
+ the `/stop` escape hatch so the operator can self-correct."""
+ runner = _make_runner()
+ runner._busy_input_mode = "interrupt"
+ adapter = _make_adapter()
+ event = _make_event(text="hi mid-delegation")
+ sk = build_session_key(event.source)
+ parent = _make_parent_with_subagents()
+ runner._running_agents[sk] = parent
+ runner._running_agents_ts[sk] = time.time() - 120
+ runner.adapters[event.source.platform] = adapter
+
+ with patch("gateway.run.merge_pending_message_event"):
+ await runner._handle_active_session_busy_message(event, sk)
+
+ adapter._send_with_retry.assert_called_once()
+ content = adapter._send_with_retry.call_args.kwargs.get("content", "")
+ assert "Subagent working" in content
+ assert "queued" in content.lower()
+ assert "/stop" in content
+ assert "Interrupting" not in content
+
+ @pytest.mark.asyncio
+ async def test_interrupt_still_fires_when_no_subagents(self) -> None:
+ """Regression-guard the other direction: with no subagents the
+ demotion must NOT trigger and behaviour must be byte-identical
+ to the pre-#30170 interrupt path."""
+ runner = _make_runner()
+ runner._busy_input_mode = "interrupt"
+ adapter = _make_adapter()
+ event = _make_event(text="please stop")
+ sk = build_session_key(event.source)
+ parent = _make_parent_no_subagents()
+ runner._running_agents[sk] = parent
+ runner.adapters[event.source.platform] = adapter
+
+ with patch("gateway.run.merge_pending_message_event"):
+ await runner._handle_active_session_busy_message(event, sk)
+
+ parent.interrupt.assert_called_once_with("please stop")
+ content = adapter._send_with_retry.call_args.kwargs.get("content", "")
+ assert "Interrupting" in content
+ assert "Subagent" not in content
+
+ @pytest.mark.asyncio
+ async def test_queue_mode_unchanged_with_subagents(self) -> None:
+ """Configured ``queue`` mode is already subagent-safe; the new
+ guard must not change its behaviour or its ack text."""
+ runner = _make_runner()
+ runner._busy_input_mode = "queue"
+ adapter = _make_adapter()
+ event = _make_event(text="queued during delegate")
+ sk = build_session_key(event.source)
+ parent = _make_parent_with_subagents()
+ runner._running_agents[sk] = parent
+ runner.adapters[event.source.platform] = adapter
+
+ with patch("gateway.run.merge_pending_message_event"):
+ await runner._handle_active_session_busy_message(event, sk)
+
+ parent.interrupt.assert_not_called()
+ content = adapter._send_with_retry.call_args.kwargs.get("content", "")
+ # The vanilla queue copy — NOT the #30170 "Subagent working" copy,
+ # because the user explicitly asked for queue mode.
+ assert "Queued for the next turn" in content
+ assert "respond once the current task finishes" in content
+ assert "Subagent working" not in content
+
+ @pytest.mark.asyncio
+ async def test_steer_mode_still_routes_through_running_agent_steer(
+ self,
+ ) -> None:
+ """Configured ``steer`` mode must reach ``running_agent.steer()``
+ even when subagents are active — the #30170 demotion is
+ interrupt-specific so it doesn't accidentally disable steer."""
+ runner = _make_runner()
+ runner._busy_input_mode = "steer"
+ adapter = _make_adapter()
+ event = _make_event(text="course-correct")
+ sk = build_session_key(event.source)
+ parent = _make_parent_with_subagents()
+ parent.steer = MagicMock(return_value=True)
+ runner._running_agents[sk] = parent
+ runner.adapters[event.source.platform] = adapter
+
+ with patch("gateway.run.merge_pending_message_event"):
+ await runner._handle_active_session_busy_message(event, sk)
+
+ parent.steer.assert_called_once_with("course-correct")
+ parent.interrupt.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_pending_sentinel_does_not_demote(self) -> None:
+ """The placeholder ``_AGENT_PENDING_SENTINEL`` is not a real
+ agent — the guard must not treat it as having subagents.
+ Otherwise we'd permanently queue messages for sessions that
+ haven't actually started running yet."""
+ runner = _make_runner()
+ runner._busy_input_mode = "interrupt"
+ adapter = _make_adapter()
+ event = _make_event(text="follow up before start")
+ sk = build_session_key(event.source)
+ runner._running_agents[sk] = _AGENT_PENDING_SENTINEL
+ runner.adapters[event.source.platform] = adapter
+
+ with patch("gateway.run.merge_pending_message_event"):
+ handled = await runner._handle_active_session_busy_message(event, sk)
+
+ assert handled is True
+ # Sentinel can't be interrupted (no .interrupt to call) — verify
+ # that the helper still returns the "interrupting" copy because
+ # demotion did NOT fire (and the sentinel branch in the real
+ # handler just skips the interrupt call silently).
+ content = adapter._send_with_retry.call_args.kwargs.get("content", "")
+ assert "Subagent working" not in content
From b62af47da8f1de5cfdbae423caaff5b64c060c9a Mon Sep 17 00:00:00 2001
From: alt-glitch
Date: Mon, 25 May 2026 13:08:55 +0000
Subject: [PATCH 014/260] chore: drop stale line-number reference in PRIORITY
path comment
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The cherry-pick comment referenced 'line ~6771' for the /stop handler,
but on current main the handler is at a different offset. Remove the
hard-coded line number — the 'above' reference is sufficient.
---
gateway/run.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/gateway/run.py b/gateway/run.py
index 469db48e5b1..b1d44e4e98d 100644
--- a/gateway/run.py
+++ b/gateway/run.py
@@ -7317,8 +7317,8 @@ class GatewayRunner:
# delegate_task work. Demote to queue semantics when the
# parent is currently driving subagents so a conversational
# follow-up doesn't destroy minutes of subagent progress.
- # /stop reaches its dedicated handler above (line ~6771), so
- # the operator still has a clean escape hatch.
+ # /stop reaches its dedicated handler above, so the operator
+ # still has a clean escape hatch.
if self._agent_has_active_subagents(running_agent):
logger.info(
"PRIORITY interrupt demoted to queue for session %s "
From 1d73d5faccec487b072ca17926fa9f7b157395ee Mon Sep 17 00:00:00 2001
From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com>
Date: Wed, 20 May 2026 21:36:32 +0300
Subject: [PATCH 015/260] fix(tts): prevent double [pause] in xAI auto speech
tags for multi-paragraph text
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
_apply_xai_auto_speech_tags runs two independent transformations:
1. paragraph breaks (\n\n) → " [pause] "
2. first-sentence boundary → " [pause] "
Both fired unconditionally, so multi-paragraph input produced
"Hello world. [pause] [pause] Second paragraph." — an unnatural
double pause in the TTS audio.
Guard the first-sentence substitution with _XAI_SPEECH_TAG_RE.search(clean):
if the paragraph pass already inserted a [pause] tag, skip the
first-sentence pass. Single-paragraph behavior is unchanged.
---
tools/tts_tool.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tools/tts_tool.py b/tools/tts_tool.py
index 0c0c7bd203c..69dea790dee 100644
--- a/tools/tts_tool.py
+++ b/tools/tts_tool.py
@@ -1078,7 +1078,8 @@ def _apply_xai_auto_speech_tags(text: str) -> str:
clean = re.sub(r"\n\s*\n+", " [pause] ", clean)
clean = re.sub(r"\s*\n\s*", " ", clean)
- clean = _XAI_FIRST_SENTENCE_RE.sub(r"\1 [pause] ", clean, count=1)
+ if not _XAI_SPEECH_TAG_RE.search(clean):
+ clean = _XAI_FIRST_SENTENCE_RE.sub(r"\1 [pause] ", clean, count=1)
clean = re.sub(r"\s{2,}", " ", clean).strip()
return clean
From 5caeb65a08a836defba9573368637e1a19af55ee Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Mon, 25 May 2026 13:33:45 -0700
Subject: [PATCH 016/260] test(tts): regression coverage for #29417
double-[pause] fix
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three new tests in tests/tools/test_tts_xai_speech_tags.py:
- multi_paragraph_emits_single_pause — the headline #29417 case.
Requires a first sentence of 12+ chars to hit the
_XAI_FIRST_SENTENCE_RE length floor; the trivial 'Hello.\\n\\nWorld.'
case dodged the bug by accident, which is why the PR's quoted
repro didn't reproduce. Uses the longer 'Welcome to the demo of
our new product line.\\n\\nIt has many features.' shape that
actually trips the bug.
- single_paragraph_still_gets_first_sentence_pause — sanity guard
that the fix only suppresses the first-sentence pass when a
paragraph pass injected [pause], so plain single-paragraph input
still gets its leading pause.
- single_newline_still_gets_first_sentence_pause — single newline
isn't a paragraph break, no [pause] from the paragraph pass, so
the first-sentence pause MUST still fire. Catches over-broad
fixes.
---
tests/tools/test_tts_xai_speech_tags.py | 47 +++++++++++++++++++++++++
1 file changed, 47 insertions(+)
diff --git a/tests/tools/test_tts_xai_speech_tags.py b/tests/tools/test_tts_xai_speech_tags.py
index 6ab72452ac7..37bde1c710a 100644
--- a/tests/tools/test_tts_xai_speech_tags.py
+++ b/tests/tools/test_tts_xai_speech_tags.py
@@ -25,6 +25,53 @@ def test_apply_xai_auto_speech_tags_preserves_all_documented_xai_tags():
assert _apply_xai_auto_speech_tags(text) == text
+def test_apply_xai_auto_speech_tags_multi_paragraph_emits_single_pause():
+ """Regression for #29417 — multi-paragraph input doubled the pause.
+
+ Pre-fix the paragraph substitution injected ``[pause]`` between
+ paragraphs, then the unconditional first-sentence substitution
+ added another one right after, producing ``[pause] [pause]`` in
+ the audio. The fix re-checks the tag-detection guard after the
+ paragraph pass.
+
+ Requires a first sentence of 12+ chars to hit the
+ ``_XAI_FIRST_SENTENCE_RE`` length floor — the trivial
+ ``"Hello.\\n\\nWorld."`` case dodged the bug by accident.
+ """
+ text = "Welcome to the demo of our new product line.\n\nIt has many features."
+ result = _apply_xai_auto_speech_tags(text)
+
+ # Exactly one [pause] between the paragraphs, not two.
+ assert result.count("[pause]") == 1, (
+ f"expected single [pause], got {result.count('[pause]')} in {result!r}"
+ )
+ assert result == (
+ "Welcome to the demo of our new product line. [pause] It has many features."
+ )
+
+
+def test_apply_xai_auto_speech_tags_single_paragraph_still_gets_first_sentence_pause():
+ """Sanity guard — the fix only suppresses the first-sentence pass when
+ a paragraph pass already injected ``[pause]``. Single-paragraph input
+ must still get its first-sentence pause.
+ """
+ text = "Welcome to the demo of our new product line. It has many features."
+ assert _apply_xai_auto_speech_tags(text) == (
+ "Welcome to the demo of our new product line. [pause] It has many features."
+ )
+
+
+def test_apply_xai_auto_speech_tags_single_newline_still_gets_first_sentence_pause():
+ """A single newline isn't a paragraph break — no ``[pause]`` injected by
+ the paragraph pass, so the first-sentence pause MUST still fire.
+ Guards against the fix being too greedy.
+ """
+ text = "Welcome to the demo of our new product line.\nIt has many features."
+ assert _apply_xai_auto_speech_tags(text) == (
+ "Welcome to the demo of our new product line. [pause] It has many features."
+ )
+
+
def test_generate_xai_tts_sends_auto_speech_tags_when_enabled(tmp_path, monkeypatch):
captured = {}
From 5671461c0c58050822284dc6ae32adaca09f6e24 Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Mon, 25 May 2026 14:48:53 -0700
Subject: [PATCH 017/260] =?UTF-8?q?feat(skills):=20add=20code-wiki=20skill?=
=?UTF-8?q?=20=E2=80=94=20closes=20#486=20(#32240)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(skills): add code-wiki skill — closes #486
Bundled skill at skills/software-development/code-wiki/ that generates
comprehensive documentation for any codebase: project overview, architecture
walkthrough with Mermaid flowchart, per-module deep-dives, class diagram,
sequence diagrams, getting-started guide, and (when applicable) API reference.
Output defaults to ~/.hermes/wikis// (external to repo, like
Google CodeWiki); in-repo output supported when user explicitly requests it.
Uses only existing Hermes tools (terminal, read_file, search_files,
write_file) — no Docker, no external services, no extra dependencies. Works
on local repos and GitHub URLs (shallow-clones to a temp dir). Bounded scope
defaults (depth 3, cap 10 modules) keep token cost reasonable on large repos.
* refactor(skills): move code-wiki to optional-skills
Per the 'when in doubt, optional' rule — wiki generation is a 'I want this
big thing right now' capability, not daily-driver behavior. Lines up with
finance/research/blockchain skills as install-on-demand rather than always
loaded.
Install via: hermes skills install official/software-development/code-wiki
---
.../software-development/code-wiki/SKILL.md | 445 +++++++++++++++++
.../code-wiki/templates/README.md | 31 ++
.../code-wiki/templates/architecture.md | 30 ++
.../code-wiki/templates/getting-started.md | 47 ++
.../code-wiki/templates/module.md | 38 ++
.../docs/reference/optional-skills-catalog.md | 1 +
.../software-development-code-wiki.md | 463 ++++++++++++++++++
website/sidebars.ts | 1 +
8 files changed, 1056 insertions(+)
create mode 100644 optional-skills/software-development/code-wiki/SKILL.md
create mode 100644 optional-skills/software-development/code-wiki/templates/README.md
create mode 100644 optional-skills/software-development/code-wiki/templates/architecture.md
create mode 100644 optional-skills/software-development/code-wiki/templates/getting-started.md
create mode 100644 optional-skills/software-development/code-wiki/templates/module.md
create mode 100644 website/docs/user-guide/skills/optional/software-development/software-development-code-wiki.md
diff --git a/optional-skills/software-development/code-wiki/SKILL.md b/optional-skills/software-development/code-wiki/SKILL.md
new file mode 100644
index 00000000000..93fde8a3d58
--- /dev/null
+++ b/optional-skills/software-development/code-wiki/SKILL.md
@@ -0,0 +1,445 @@
+---
+name: code-wiki
+description: "Generate wiki docs + Mermaid diagrams for any codebase."
+version: 0.1.0
+author: Teknium (teknium1), Hermes Agent
+license: MIT
+platforms: [linux, macos, windows]
+metadata:
+ hermes:
+ tags: [Documentation, Mermaid, Architecture, Diagrams, Wiki, Code-Analysis]
+ related_skills: [codebase-inspection, github-repo-management]
+---
+
+# Code Wiki Skill
+
+Generate a comprehensive wiki for any codebase — overview, architecture, per-module deep-dives, Mermaid class and sequence diagrams. Inspired by Google CodeWiki, but works on local repos, private repos, and any language. Uses only existing Hermes tools (`terminal`, `read_file`, `search_files`, `write_file`); no Docker, no external services, no extra dependencies.
+
+This skill produces **reference documentation** (what/how). It does not produce strategic narrative (why — that's a different skill).
+
+## When to Use
+
+- User says "document this codebase", "generate a wiki", "make architecture diagrams"
+- Onboarding to an unfamiliar repo and wants a structured reference
+- User points at a GitHub URL and asks for documentation
+- Need a stable artifact (markdown + Mermaid) that renders on GitHub
+
+Do NOT use this for:
+- Single-file or single-function documentation — just answer directly
+- API reference for one specific endpoint — use `read_file` and answer inline
+- Strategic "why does this exist" narrative — different skill, different purpose
+- Codebases the user is actively developing in this session — just answer questions as they come
+
+## Prerequisites
+
+- No env vars required.
+- `git` on PATH for repo SHA tracking and remote clones.
+- Optional: `pygount` for language-breakdown stats (see the `codebase-inspection` skill).
+
+## How to Run
+
+Invoke through the `terminal` tool from the target repo's root, then use `read_file` / `search_files` / `write_file` to produce the wiki. Default output location is `~/.hermes/wikis//`. Only write into the repo (`docs/wiki/`) when the user explicitly requests it.
+
+## Quick Reference
+
+| Step | Action |
+|---|---|
+| 1 | Resolve target — local cwd, given path, or `git clone --depth 50 ` to a temp dir |
+| 2 | Scan structure — `ls`, `find -maxdepth 3`, manifest files, README |
+| 3 | Pick 8–10 modules to document |
+| 4 | Write `README.md` (overview + module map) |
+| 5 | Write `architecture.md` with Mermaid flowchart |
+| 6 | Write per-module docs in `modules/` |
+| 7 | Write `diagrams/class-diagram.md` (Mermaid classDiagram) |
+| 8 | Write `diagrams/sequences.md` (Mermaid sequenceDiagram, 2–4 workflows) |
+| 9 | Write `getting-started.md` |
+| 10 | Write `api.md` if applicable, else skip |
+| 11 | Write `.codewiki-state.json` |
+| 12 | Report paths to user |
+
+## Procedure
+
+### 1. Resolve the target
+
+For a GitHub URL:
+
+```bash
+WIKI_TMP=$(mktemp -d)
+git clone --depth 50 "$WIKI_TMP/repo"
+cd "$WIKI_TMP/repo"
+REPO_SHA=$(git rev-parse HEAD)
+REPO_NAME=$(basename .git)
+```
+
+For a local path (or cwd if none given):
+
+```bash
+cd
+REPO_SHA=$(git rev-parse HEAD 2>/dev/null || echo "uncommitted")
+REPO_NAME=$(basename "$PWD")
+```
+
+Then set the output dir:
+
+```bash
+OUTPUT_DIR="$HOME/.hermes/wikis/$REPO_NAME"
+mkdir -p "$OUTPUT_DIR/modules" "$OUTPUT_DIR/diagrams"
+```
+
+### 2. Scan repo structure
+
+Use the `terminal` tool for the shell work, `read_file` for manifests:
+
+```bash
+# Shallow tree first
+ls -la
+
+# Deeper tree, noise filtered
+find . -type d \
+ -not -path '*/\.*' \
+ -not -path '*/node_modules*' \
+ -not -path '*/venv*' \
+ -not -path '*/__pycache__*' \
+ -not -path '*/dist*' \
+ -not -path '*/build*' \
+ -not -path '*/target*' \
+ -maxdepth 3 | sort
+
+# Language breakdown (skip if pygount unavailable)
+pygount --format=summary \
+ --folders-to-skip=".git,node_modules,venv,.venv,__pycache__,.cache,dist,build,target" \
+ . 2>/dev/null || true
+```
+
+Then `read_file` the relevant manifests (`package.json`, `pyproject.toml`, `setup.py`, `Cargo.toml`, `go.mod`, `pom.xml`, `build.gradle`) and the project README. Use `search_files target='files'` to find them rather than guessing names.
+
+### 3. Pick modules to document
+
+Cap initial pass at **8–10 modules**. Heuristics by language:
+
+- Python: top-level packages (dirs with `__init__.py`), plus subsystem dirs
+- JS/TS: `src/`, top-level workspace dirs
+- Rust: each crate in a workspace, or top-level `src/` dirs
+- Go: each top-level package directory
+- Mixed/unfamiliar: top-level directories that contain source code (not config, not tests)
+
+For very large repos, prioritize by:
+1. Imported-from count (a module imported by many is core)
+2. LOC (bigger modules usually warrant their own doc)
+3. Mentions in README / top-level docs
+
+State the module list to the user before generating per-module docs on big repos — gives them a chance to redirect.
+
+### 4. Write `README.md`
+
+`read_file` the actual project README plus the top 2–3 entry-point files. Then `write_file`:
+
+````markdown
+#
+
+
+
+## Key Concepts
+
+- **** —
+- **** —
+
+## Entry Points
+
+- [`path/to/main.py`]() —
+- [`path/to/cli.py`]() —
+
+## High-Level Architecture
+
+<2-3 sentences. Detail goes in architecture.md.>
+
+See [architecture.md](architecture.md).
+
+## Module Map
+
+| Module | Purpose |
+|---|---|
+| [``](modules/.md) | |
+
+## Getting Started
+
+See [getting-started.md](getting-started.md).
+````
+
+For link targets in local mode use relative paths. For cloned repos use `https://github.com///blob//` so links survive future commits.
+
+### 5. Write `architecture.md`
+
+````markdown
+# Architecture
+
+<2-3 paragraphs: shape of the system. What talks to what. Where data enters,
+where it exits, where state lives.>
+
+## Components
+
+- **** — <1-2 sentences>. See [`modules/.md`](modules/.md).
+
+## System Diagram
+
+```mermaid
+flowchart TD
+ User([User]) --> Entry[Entry Point]
+ Entry --> Core[Core Engine]
+ Core --> StorageA[(Database)]
+ Core --> ExternalAPI{{External API}}
+```
+
+## Data Flow
+
+1. **** — [``]()
+2. **** — [``]()
+
+## Key Design Decisions
+
+-
+````
+
+**Mermaid shape semantics:**
+- `[]` = component
+- `[()]` = database / storage
+- `{{}}` = external service
+- `(())` = entry point or terminal
+- `-->` = sync call, `-.->` = async/event
+
+Cap at ~20 nodes per diagram. Split into sub-diagrams if larger.
+
+### 6. Write per-module docs in `modules/`
+
+For each selected module, inspect its layout with `ls`, identify 3–5 most important files (by size, by being named `core.py` / `main.py` / `__init__.py`, by being imported a lot), then `read_file` those files (use `offset` / `limit` to read only what you need; prefer `search_files` for specific symbols).
+
+````markdown
+# Module: ``
+
+<1-2 sentence purpose.>
+
+## Responsibilities
+
+-
+-
+
+## Key Files
+
+- [`/`]() —
+
+## Public API
+
+
+
+## Internal Structure
+
+
+
+## Dependencies
+
+- **Used by:**
+- **Uses:**
+
+## Notable Patterns / Gotchas
+
+-
+````
+
+### 7. Write `diagrams/class-diagram.md`
+
+Pick the 5–10 most important classes/types. `read_file` them, then write:
+
+````markdown
+# Class Diagram
+
+## Core Types
+
+```mermaid
+classDiagram
+ class Agent {
+ +string name
+ +list~Tool~ tools
+ +chat(message) string
+ }
+ class Tool {
+ <>
+ +name string
+ +execute(args) any
+ }
+ Agent --> Tool : uses
+ Tool <|-- TerminalTool
+ Tool <|-- WebTool
+```
+
+## Notes
+
+
+````
+
+For languages without classes (Go, C, Rust): use the diagram for struct relationships, or skip class-diagram.md and explain it in prose in architecture.md. Don't force-fit.
+
+### 8. Write `diagrams/sequences.md`
+
+Pick 2–4 of the most important workflows. Trace each call path through the code (read entry point, follow function calls), then:
+
+````markdown
+# Sequence Diagrams
+
+## Workflow:
+
+<1 sentence describing what this does and when it runs.>
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant CLI
+ participant Agent
+ participant LLM
+ User->>CLI: types message
+ CLI->>Agent: chat(message)
+ Agent->>LLM: API call
+ LLM-->>Agent: response + tool_calls
+ Agent->>Agent: execute tools
+ Agent-->>CLI: final response
+```
+
+### Walkthrough
+
+1. **User input** — [`cli.py:HermesCLI.run_session`]()
+2. **Message dispatch** — [`run_agent.py:AIAgent.chat`]()
+````
+
+Don't invent participants. Every box must correspond to a real component the reader can find in the code.
+
+### 9. Write `getting-started.md`
+
+````markdown
+# Getting Started
+
+## Prerequisites
+
+
+
+## Installation
+
+```bash
+
+```
+
+## First Run
+
+```bash
+
+```
+
+## Common Workflows
+
+###
+
+
+## Configuration
+
+- `` —
+- Env var `` —
+
+## Where to Go Next
+
+- Architecture: [architecture.md](architecture.md)
+- Module reference: [README.md#module-map](README.md#module-map)
+````
+
+### 10. Write `api.md` (skip if not applicable)
+
+Only write this if the project is a library or API server. If it is:
+
+- Find the public API surface (`__init__.py` exports, OpenAPI specs, route handlers, exported types)
+- Document each public entry with signature, parameters, return type, one-line description
+- Group by category
+
+### 11. Write the state file
+
+```bash
+cat > "$OUTPUT_DIR/.codewiki-state.json" </:
+ README.md project overview, module map
+ architecture.md system architecture + flowchart
+ getting-started.md setup, first run, workflows
+ modules/ per-module deep-dives
+ diagrams/architecture.md Mermaid flowchart
+ diagrams/class-diagram.md Mermaid class diagram
+ diagrams/sequences.md Mermaid sequence diagrams
+```
+
+If you cloned to a temp dir, remind the user it can be removed (`rm -rf "$WIKI_TMP"`) after they've reviewed the wiki.
+
+## Scope Control
+
+Generating a full wiki for a 500K-LOC monorepo is wildly token-expensive. Default to bounded scope:
+
+- Initial scan: max depth 3 directories
+- Per-module docs: cap at 10 modules unless user expands scope
+- Per-file reads: prefer `search_files` for symbols + `read_file` with `offset`/`limit` over full reads
+- Skip vendored code (`vendor/`, `third_party/`, generated code, `_pb2.py`, `.min.js`)
+
+If the user says "do the whole thing exhaustively", believe them — but ballpark the cost first: "this repo has ~340 source files, comprehensive coverage will be expensive — confirm?"
+
+## Re-Run / Update
+
+If `.codewiki-state.json` already exists at the target path:
+
+- Read it for previous SHA and module list
+- If source SHA matches: ask user if they want to regenerate or skip
+- If SHA differs: offer to regenerate only modules with changed files (`git diff --name-only HEAD`)
+
+Full incremental-regeneration is a future enhancement — for now, regenerating the whole thing is acceptable.
+
+## Pitfalls
+
+- **Fabricating components.** Every diagram node and claimed function call must be in the source. `read_file` before writing. The single biggest failure mode for auto-generated docs is plausible-sounding fabrication.
+- **Generic AI prose.** "This module is responsible for..." is content-free. Say what the module actually does in domain-specific terms.
+- **Restating code as prose.** A module doc that says "the `process` function processes things by calling `process_item` on each item" is worse than just linking to the function.
+- **Mermaid > 50 nodes.** They don't render legibly. Split them.
+- **Documenting tests, generated code, or vendored deps as if they were product code.** Skip them.
+- **In-repo output without asking.** Default is `~/.hermes/wikis/`. Only write into the repo when the user explicitly requests it.
+- **Mermaid special chars need quotes:** `A["Tool / Agent"]` not `A[Tool / Agent]`. ` ` for line breaks inside a node.
+- **Nested code fences in SKILL.md.** When writing a markdown example that contains a Mermaid block, use 4-backtick outer fences so the 3-backtick inner ` ```mermaid ` doesn't close the outer. (This SKILL.md does it.)
+- **classDiagram generics** render as `~T~` (e.g. `List~Tool~`), not ``.
+- **GitHub Mermaid theme is fixed** — don't include `%%{init: ...}%%` blocks; they're stripped on render.
+
+## Verification
+
+After writing, verify:
+
+1. **Mermaid blocks balance** — opens equal closes per file:
+ ```bash
+ for f in "$OUTPUT_DIR"/diagrams/*.md "$OUTPUT_DIR"/architecture.md; do
+ opens=$(grep -c '^```mermaid' "$f")
+ total=$(grep -c '^```' "$f")
+ echo "$f: $opens mermaid blocks, $total total fences (expect total = opens*2)"
+ done
+ ```
+2. **All expected files exist** —
+ ```bash
+ ls "$OUTPUT_DIR"/{README.md,architecture.md,getting-started.md,.codewiki-state.json} \
+ "$OUTPUT_DIR"/modules/ "$OUTPUT_DIR"/diagrams/
+ ```
+3. **Module count matches what you intended** — `ls "$OUTPUT_DIR/modules" | wc -l` should equal the number of modules you committed to in Step 3.
+4. **No fabricated paths** — sanity-check 2–3 source links resolve to real files.
diff --git a/optional-skills/software-development/code-wiki/templates/README.md b/optional-skills/software-development/code-wiki/templates/README.md
new file mode 100644
index 00000000000..2fe65cea2e2
--- /dev/null
+++ b/optional-skills/software-development/code-wiki/templates/README.md
@@ -0,0 +1,31 @@
+# {{PROJECT_NAME}}
+
+{{ONE_PARAGRAPH_DESCRIPTION}}
+
+## Key Concepts
+
+- **{{CONCEPT_1}}** — {{ONE_LINE}}
+- **{{CONCEPT_2}}** — {{ONE_LINE}}
+- **{{CONCEPT_3}}** — {{ONE_LINE}}
+
+## Entry Points
+
+- [`{{PATH_1}}`]({{LINK_1}}) — {{WHAT_IT_DOES}}
+- [`{{PATH_2}}`]({{LINK_2}}) — {{WHAT_IT_DOES}}
+
+## High-Level Architecture
+
+{{TWO_TO_THREE_SENTENCES}}
+
+See [architecture.md](architecture.md) for the full picture.
+
+## Module Map
+
+| Module | Purpose |
+|---|---|
+| [`{{MODULE_1}}`](modules/{{MODULE_1}}.md) | {{ONE_LINE_PURPOSE}} |
+| [`{{MODULE_2}}`](modules/{{MODULE_2}}.md) | {{ONE_LINE_PURPOSE}} |
+
+## Getting Started
+
+See [getting-started.md](getting-started.md).
diff --git a/optional-skills/software-development/code-wiki/templates/architecture.md b/optional-skills/software-development/code-wiki/templates/architecture.md
new file mode 100644
index 00000000000..e737b2c9814
--- /dev/null
+++ b/optional-skills/software-development/code-wiki/templates/architecture.md
@@ -0,0 +1,30 @@
+# Architecture
+
+{{TWO_TO_THREE_PARAGRAPHS_SHAPE_OF_SYSTEM}}
+
+## Components
+
+- **{{COMPONENT_1}}** — {{ONE_TO_TWO_SENTENCES}} See [`modules/{{MODULE}}.md`](modules/{{MODULE}}.md).
+- **{{COMPONENT_2}}** — {{ONE_TO_TWO_SENTENCES}}
+
+## System Diagram
+
+```mermaid
+flowchart TD
+ User([User]) --> Entry[Entry Point]
+ Entry --> Core[Core Engine]
+ Core --> StorageA[(Database)]
+ Core --> ExternalAPI{{External API}}
+```
+
+## Data Flow
+
+1. **{{STEP_1}}** — [`{{FILE}}`]({{LINK}})
+2. **{{STEP_2}}** — [`{{FILE}}`]({{LINK}})
+3. **{{STEP_3}}** — [`{{FILE}}`]({{LINK}})
+
+## Key Design Decisions
+
+- {{DECISION_1}}
+- {{DECISION_2}}
+- {{DECISION_3}}
diff --git a/optional-skills/software-development/code-wiki/templates/getting-started.md b/optional-skills/software-development/code-wiki/templates/getting-started.md
new file mode 100644
index 00000000000..bbc66dbbe0b
--- /dev/null
+++ b/optional-skills/software-development/code-wiki/templates/getting-started.md
@@ -0,0 +1,47 @@
+# Getting Started
+
+## Prerequisites
+
+- {{LANGUAGE_RUNTIME_VERSION}}
+- {{DEPENDENCY}}
+
+## Installation
+
+```bash
+{{INSTALL_COMMANDS}}
+```
+
+## First Run
+
+```bash
+{{FIRST_RUN_COMMAND}}
+```
+
+You should see {{EXPECTED_OUTPUT}}.
+
+## Common Workflows
+
+### {{WORKFLOW_1}}
+
+```bash
+{{COMMANDS}}
+```
+
+### {{WORKFLOW_2}}
+
+```bash
+{{COMMANDS}}
+```
+
+## Configuration
+
+Key config files and settings:
+
+- `{{CONFIG_FILE}}` — {{WHAT_IT_CONTROLS}}
+- Env var `{{VAR}}` — {{WHAT_IT_CONTROLS}}
+
+## Where to Go Next
+
+- Architecture overview: [architecture.md](architecture.md)
+- Module reference: [README.md#module-map](README.md#module-map)
+- Diagrams: [diagrams/](diagrams/)
diff --git a/optional-skills/software-development/code-wiki/templates/module.md b/optional-skills/software-development/code-wiki/templates/module.md
new file mode 100644
index 00000000000..8494438f5b4
--- /dev/null
+++ b/optional-skills/software-development/code-wiki/templates/module.md
@@ -0,0 +1,38 @@
+# Module: `{{MODULE_NAME}}`
+
+{{ONE_TO_TWO_SENTENCE_PURPOSE}}
+
+## Responsibilities
+
+- {{BULLET_1}}
+- {{BULLET_2}}
+- {{BULLET_3}}
+
+## Key Files
+
+- [`{{PATH_1}}`]({{LINK_1}}) — {{WHAT_IT_DOES}}
+- [`{{PATH_2}}`]({{LINK_2}}) — {{WHAT_IT_DOES}}
+
+## Public API
+
+### `{{FUNCTION_NAME}}({{SIGNATURE}})`
+
+{{ONE_LINE_DESCRIPTION}}
+
+**Parameters:**
+- `{{PARAM}}` ({{TYPE}}) — {{DESCRIPTION}}
+
+**Returns:** {{TYPE}} — {{DESCRIPTION}}
+
+## Internal Structure
+
+{{HOW_THE_MODULE_IS_ORGANIZED}}
+
+## Dependencies
+
+- **Used by:** {{OTHER_MODULES}}
+- **Uses:** {{OTHER_MODULES_AND_LIBS}}
+
+## Notable Patterns / Gotchas
+
+- {{ANYTHING_NON_OBVIOUS}}
diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md
index 4671acdfa51..bd2c22eff3c 100644
--- a/website/docs/reference/optional-skills-catalog.md
+++ b/website/docs/reference/optional-skills-catalog.md
@@ -185,6 +185,7 @@ hermes skills uninstall
| Skill | Description |
|-------|-------------|
+| [**code-wiki**](/user-guide/skills/optional/software-development/software-development-code-wiki) | Generate wiki docs + Mermaid diagrams for any codebase. |
| [**rest-graphql-debug**](/user-guide/skills/optional/software-development/software-development-rest-graphql-debug) | Debug REST/GraphQL APIs: status codes, auth, schemas, repro. |
## web-development
diff --git a/website/docs/user-guide/skills/optional/software-development/software-development-code-wiki.md b/website/docs/user-guide/skills/optional/software-development/software-development-code-wiki.md
new file mode 100644
index 00000000000..7d41054deac
--- /dev/null
+++ b/website/docs/user-guide/skills/optional/software-development/software-development-code-wiki.md
@@ -0,0 +1,463 @@
+---
+title: "Code Wiki — Generate wiki docs + Mermaid diagrams for any codebase"
+sidebar_label: "Code Wiki"
+description: "Generate wiki docs + Mermaid diagrams for any codebase"
+---
+
+{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
+
+# Code Wiki
+
+Generate wiki docs + Mermaid diagrams for any codebase.
+
+## Skill metadata
+
+| | |
+|---|---|
+| Source | Optional — install with `hermes skills install official/software-development/code-wiki` |
+| Path | `optional-skills/software-development/code-wiki` |
+| Version | `0.1.0` |
+| Author | Teknium (teknium1), Hermes Agent |
+| License | MIT |
+| Platforms | linux, macos, windows |
+| Tags | `Documentation`, `Mermaid`, `Architecture`, `Diagrams`, `Wiki`, `Code-Analysis` |
+| Related skills | [`codebase-inspection`](/docs/user-guide/skills/bundled/github/github-codebase-inspection), [`github-repo-management`](/docs/user-guide/skills/bundled/github/github-github-repo-management) |
+
+## Reference: full SKILL.md
+
+:::info
+The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active.
+:::
+
+# Code Wiki Skill
+
+Generate a comprehensive wiki for any codebase — overview, architecture, per-module deep-dives, Mermaid class and sequence diagrams. Inspired by Google CodeWiki, but works on local repos, private repos, and any language. Uses only existing Hermes tools (`terminal`, `read_file`, `search_files`, `write_file`); no Docker, no external services, no extra dependencies.
+
+This skill produces **reference documentation** (what/how). It does not produce strategic narrative (why — that's a different skill).
+
+## When to Use
+
+- User says "document this codebase", "generate a wiki", "make architecture diagrams"
+- Onboarding to an unfamiliar repo and wants a structured reference
+- User points at a GitHub URL and asks for documentation
+- Need a stable artifact (markdown + Mermaid) that renders on GitHub
+
+Do NOT use this for:
+- Single-file or single-function documentation — just answer directly
+- API reference for one specific endpoint — use `read_file` and answer inline
+- Strategic "why does this exist" narrative — different skill, different purpose
+- Codebases the user is actively developing in this session — just answer questions as they come
+
+## Prerequisites
+
+- No env vars required.
+- `git` on PATH for repo SHA tracking and remote clones.
+- Optional: `pygount` for language-breakdown stats (see the `codebase-inspection` skill).
+
+## How to Run
+
+Invoke through the `terminal` tool from the target repo's root, then use `read_file` / `search_files` / `write_file` to produce the wiki. Default output location is `~/.hermes/wikis//`. Only write into the repo (`docs/wiki/`) when the user explicitly requests it.
+
+## Quick Reference
+
+| Step | Action |
+|---|---|
+| 1 | Resolve target — local cwd, given path, or `git clone --depth 50 ` to a temp dir |
+| 2 | Scan structure — `ls`, `find -maxdepth 3`, manifest files, README |
+| 3 | Pick 8–10 modules to document |
+| 4 | Write `README.md` (overview + module map) |
+| 5 | Write `architecture.md` with Mermaid flowchart |
+| 6 | Write per-module docs in `modules/` |
+| 7 | Write `diagrams/class-diagram.md` (Mermaid classDiagram) |
+| 8 | Write `diagrams/sequences.md` (Mermaid sequenceDiagram, 2–4 workflows) |
+| 9 | Write `getting-started.md` |
+| 10 | Write `api.md` if applicable, else skip |
+| 11 | Write `.codewiki-state.json` |
+| 12 | Report paths to user |
+
+## Procedure
+
+### 1. Resolve the target
+
+For a GitHub URL:
+
+```bash
+WIKI_TMP=$(mktemp -d)
+git clone --depth 50 "$WIKI_TMP/repo"
+cd "$WIKI_TMP/repo"
+REPO_SHA=$(git rev-parse HEAD)
+REPO_NAME=$(basename .git)
+```
+
+For a local path (or cwd if none given):
+
+```bash
+cd
+REPO_SHA=$(git rev-parse HEAD 2>/dev/null || echo "uncommitted")
+REPO_NAME=$(basename "$PWD")
+```
+
+Then set the output dir:
+
+```bash
+OUTPUT_DIR="$HOME/.hermes/wikis/$REPO_NAME"
+mkdir -p "$OUTPUT_DIR/modules" "$OUTPUT_DIR/diagrams"
+```
+
+### 2. Scan repo structure
+
+Use the `terminal` tool for the shell work, `read_file` for manifests:
+
+```bash
+# Shallow tree first
+ls -la
+
+# Deeper tree, noise filtered
+find . -type d \
+ -not -path '*/\.*' \
+ -not -path '*/node_modules*' \
+ -not -path '*/venv*' \
+ -not -path '*/__pycache__*' \
+ -not -path '*/dist*' \
+ -not -path '*/build*' \
+ -not -path '*/target*' \
+ -maxdepth 3 | sort
+
+# Language breakdown (skip if pygount unavailable)
+pygount --format=summary \
+ --folders-to-skip=".git,node_modules,venv,.venv,__pycache__,.cache,dist,build,target" \
+ . 2>/dev/null || true
+```
+
+Then `read_file` the relevant manifests (`package.json`, `pyproject.toml`, `setup.py`, `Cargo.toml`, `go.mod`, `pom.xml`, `build.gradle`) and the project README. Use `search_files target='files'` to find them rather than guessing names.
+
+### 3. Pick modules to document
+
+Cap initial pass at **8–10 modules**. Heuristics by language:
+
+- Python: top-level packages (dirs with `__init__.py`), plus subsystem dirs
+- JS/TS: `src/`, top-level workspace dirs
+- Rust: each crate in a workspace, or top-level `src/` dirs
+- Go: each top-level package directory
+- Mixed/unfamiliar: top-level directories that contain source code (not config, not tests)
+
+For very large repos, prioritize by:
+1. Imported-from count (a module imported by many is core)
+2. LOC (bigger modules usually warrant their own doc)
+3. Mentions in README / top-level docs
+
+State the module list to the user before generating per-module docs on big repos — gives them a chance to redirect.
+
+### 4. Write `README.md`
+
+`read_file` the actual project README plus the top 2–3 entry-point files. Then `write_file`:
+
+````markdown
+#
+
+
+
+## Key Concepts
+
+- **** —
+- **** —
+
+## Entry Points
+
+- [`path/to/main.py`](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/) —
+- [`path/to/cli.py`](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/) —
+
+## High-Level Architecture
+
+<2-3 sentences. Detail goes in architecture.md.>
+
+See [architecture.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/architecture.md).
+
+## Module Map
+
+| Module | Purpose |
+|---|---|
+| [``](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/modules/.md) | |
+
+## Getting Started
+
+See [getting-started.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/getting-started.md).
+````
+
+For link targets in local mode use relative paths. For cloned repos use `https://github.com///blob//` so links survive future commits.
+
+### 5. Write `architecture.md`
+
+````markdown
+# Architecture
+
+<2-3 paragraphs: shape of the system. What talks to what. Where data enters,
+where it exits, where state lives.>
+
+## Components
+
+- **** — <1-2 sentences>. See [`modules/.md`](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/modules/.md).
+
+## System Diagram
+
+```mermaid
+flowchart TD
+ User([User]) --> Entry[Entry Point]
+ Entry --> Core[Core Engine]
+ Core --> StorageA[(Database)]
+ Core --> ExternalAPI{{External API}}
+```
+
+## Data Flow
+
+1. **** — [``](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/)
+2. **** — [``](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/)
+
+## Key Design Decisions
+
+-
+````
+
+**Mermaid shape semantics:**
+- `[]` = component
+- `[()]` = database / storage
+- `{{}}` = external service
+- `(())` = entry point or terminal
+- `-->` = sync call, `-.->` = async/event
+
+Cap at ~20 nodes per diagram. Split into sub-diagrams if larger.
+
+### 6. Write per-module docs in `modules/`
+
+For each selected module, inspect its layout with `ls`, identify 3–5 most important files (by size, by being named `core.py` / `main.py` / `__init__.py`, by being imported a lot), then `read_file` those files (use `offset` / `limit` to read only what you need; prefer `search_files` for specific symbols).
+
+````markdown
+# Module: ``
+
+<1-2 sentence purpose.>
+
+## Responsibilities
+
+-
+-
+
+## Key Files
+
+- [`/`](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/) —
+
+## Public API
+
+
+
+## Internal Structure
+
+
+
+## Dependencies
+
+- **Used by:**
+- **Uses:**
+
+## Notable Patterns / Gotchas
+
+-
+````
+
+### 7. Write `diagrams/class-diagram.md`
+
+Pick the 5–10 most important classes/types. `read_file` them, then write:
+
+````markdown
+# Class Diagram
+
+## Core Types
+
+```mermaid
+classDiagram
+ class Agent {
+ +string name
+ +list~Tool~ tools
+ +chat(message) string
+ }
+ class Tool {
+ <>
+ +name string
+ +execute(args) any
+ }
+ Agent --> Tool : uses
+ Tool <|-- TerminalTool
+ Tool <|-- WebTool
+```
+
+## Notes
+
+
+````
+
+For languages without classes (Go, C, Rust): use the diagram for struct relationships, or skip class-diagram.md and explain it in prose in architecture.md. Don't force-fit.
+
+### 8. Write `diagrams/sequences.md`
+
+Pick 2–4 of the most important workflows. Trace each call path through the code (read entry point, follow function calls), then:
+
+````markdown
+# Sequence Diagrams
+
+## Workflow:
+
+<1 sentence describing what this does and when it runs.>
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant CLI
+ participant Agent
+ participant LLM
+ User->>CLI: types message
+ CLI->>Agent: chat(message)
+ Agent->>LLM: API call
+ LLM-->>Agent: response + tool_calls
+ Agent->>Agent: execute tools
+ Agent-->>CLI: final response
+```
+
+### Walkthrough
+
+1. **User input** — [`cli.py:HermesCLI.run_session`](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/)
+2. **Message dispatch** — [`run_agent.py:AIAgent.chat`](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/)
+````
+
+Don't invent participants. Every box must correspond to a real component the reader can find in the code.
+
+### 9. Write `getting-started.md`
+
+````markdown
+# Getting Started
+
+## Prerequisites
+
+
+
+## Installation
+
+```bash
+
+```
+
+## First Run
+
+```bash
+
+```
+
+## Common Workflows
+
+###
+
+
+## Configuration
+
+- `` —
+- Env var `` —
+
+## Where to Go Next
+
+- Architecture: [architecture.md](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/architecture.md)
+- Module reference: [README.md#module-map](https://github.com/NousResearch/hermes-agent/blob/main/optional-skills/software-development/code-wiki/README.md#module-map)
+````
+
+### 10. Write `api.md` (skip if not applicable)
+
+Only write this if the project is a library or API server. If it is:
+
+- Find the public API surface (`__init__.py` exports, OpenAPI specs, route handlers, exported types)
+- Document each public entry with signature, parameters, return type, one-line description
+- Group by category
+
+### 11. Write the state file
+
+```bash
+cat > "$OUTPUT_DIR/.codewiki-state.json" </:
+ README.md project overview, module map
+ architecture.md system architecture + flowchart
+ getting-started.md setup, first run, workflows
+ modules/ per-module deep-dives
+ diagrams/architecture.md Mermaid flowchart
+ diagrams/class-diagram.md Mermaid class diagram
+ diagrams/sequences.md Mermaid sequence diagrams
+```
+
+If you cloned to a temp dir, remind the user it can be removed (`rm -rf "$WIKI_TMP"`) after they've reviewed the wiki.
+
+## Scope Control
+
+Generating a full wiki for a 500K-LOC monorepo is wildly token-expensive. Default to bounded scope:
+
+- Initial scan: max depth 3 directories
+- Per-module docs: cap at 10 modules unless user expands scope
+- Per-file reads: prefer `search_files` for symbols + `read_file` with `offset`/`limit` over full reads
+- Skip vendored code (`vendor/`, `third_party/`, generated code, `_pb2.py`, `.min.js`)
+
+If the user says "do the whole thing exhaustively", believe them — but ballpark the cost first: "this repo has ~340 source files, comprehensive coverage will be expensive — confirm?"
+
+## Re-Run / Update
+
+If `.codewiki-state.json` already exists at the target path:
+
+- Read it for previous SHA and module list
+- If source SHA matches: ask user if they want to regenerate or skip
+- If SHA differs: offer to regenerate only modules with changed files (`git diff --name-only HEAD`)
+
+Full incremental-regeneration is a future enhancement — for now, regenerating the whole thing is acceptable.
+
+## Pitfalls
+
+- **Fabricating components.** Every diagram node and claimed function call must be in the source. `read_file` before writing. The single biggest failure mode for auto-generated docs is plausible-sounding fabrication.
+- **Generic AI prose.** "This module is responsible for..." is content-free. Say what the module actually does in domain-specific terms.
+- **Restating code as prose.** A module doc that says "the `process` function processes things by calling `process_item` on each item" is worse than just linking to the function.
+- **Mermaid > 50 nodes.** They don't render legibly. Split them.
+- **Documenting tests, generated code, or vendored deps as if they were product code.** Skip them.
+- **In-repo output without asking.** Default is `~/.hermes/wikis/`. Only write into the repo when the user explicitly requests it.
+- **Mermaid special chars need quotes:** `A["Tool / Agent"]` not `A[Tool / Agent]`. ` ` for line breaks inside a node.
+- **Nested code fences in SKILL.md.** When writing a markdown example that contains a Mermaid block, use 4-backtick outer fences so the 3-backtick inner ` ```mermaid ` doesn't close the outer. (This SKILL.md does it.)
+- **classDiagram generics** render as `~T~` (e.g. `List~Tool~`), not ``.
+- **GitHub Mermaid theme is fixed** — don't include `%%{init: ...}%%` blocks; they're stripped on render.
+
+## Verification
+
+After writing, verify:
+
+1. **Mermaid blocks balance** — opens equal closes per file:
+ ```bash
+ for f in "$OUTPUT_DIR"/diagrams/*.md "$OUTPUT_DIR"/architecture.md; do
+ opens=$(grep -c '^```mermaid' "$f")
+ total=$(grep -c '^```' "$f")
+ echo "$f: $opens mermaid blocks, $total total fences (expect total = opens*2)"
+ done
+ ```
+2. **All expected files exist** —
+ ```bash
+ ls "$OUTPUT_DIR"/{README.md,architecture.md,getting-started.md,.codewiki-state.json} \
+ "$OUTPUT_DIR"/modules/ "$OUTPUT_DIR"/diagrams/
+ ```
+3. **Module count matches what you intended** — `ls "$OUTPUT_DIR/modules" | wc -l` should equal the number of modules you committed to in Step 3.
+4. **No fabricated paths** — sanity-check 2–3 source links resolve to real files.
diff --git a/website/sidebars.ts b/website/sidebars.ts
index b0cd3a470fd..a3c41cbf205 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -589,6 +589,7 @@ const sidebars: SidebarsConfig = {
key: 'skills-optional-software-development',
collapsed: true,
items: [
+ 'user-guide/skills/optional/software-development/software-development-code-wiki',
'user-guide/skills/optional/software-development/software-development-rest-graphql-debug',
],
},
From 386f245d9d25e9084cea61046f588447957de683 Mon Sep 17 00:00:00 2001
From: teknium1 <127238744+teknium1@users.noreply.github.com>
Date: Mon, 25 May 2026 14:32:34 -0700
Subject: [PATCH 018/260] =?UTF-8?q?feat(skills):=20add=20optional=20openha?=
=?UTF-8?q?nds=20skill=20=E2=80=94=20closes=20#477?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds an optional autonomous-ai-agents skill that delegates coding tasks
to the OpenHands CLI (https://github.com/All-Hands-AI/OpenHands). Sits
alongside claude-code / codex / opencode and is the model-agnostic
option in that family — any LiteLLM-supported provider works.
This is a ground-truth rewrite of #19325 by @xzessmedia (Tim Koepsel).
The original PR's SKILL.md was drafted by the OpenHands agent itself and
hallucinated several flags that don't exist in the real CLI (\`--model\`,
\`--max-iterations\`, \`--workspace\`, \`--sandbox docker\`), pointed at
the wrong PyPI package (\`openhands-ai\`, which is the legacy V0 SDK),
and claimed native Windows support that the upstream docs explicitly
disclaim. Rather than cherry-pick and rewrite half the lines under
contributor authorship, the SKILL.md was rebuilt against a verified
install (\`uv tool install openhands --python 3.12\`) and a real
end-to-end \`--headless --json\` run against openrouter/openai/gpt-4o-mini.
Authorship credited via the \`author:\` frontmatter field and an
AUTHOR_MAP entry in scripts/release.py.
Changes:
- optional-skills/autonomous-ai-agents/openhands/SKILL.md (new)
- website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands.md (auto-gen)
- website/docs/reference/optional-skills-catalog.md (one new row)
- website/sidebars.ts (one new entry under Optional → Autonomous AI Agents)
- scripts/release.py (AUTHOR_MAP entry for xzessmedia)
Pitfalls documented in the SKILL came from running the tool, not from
the upstream README: LiteLLM bedrock/sagemaker stderr noise on every
invocation, banner spam (\`OPENHANDS_SUPPRESS_BANNER=1\` required),
\`--override-with-envs\` mandatory or the CLI ignores LLM_* env vars
entirely, the dashed-vs-undashed Conversation ID footgun for \`--resume\`,
LiteLLM model-slug double-prefix when going through OpenRouter.
---
.../autonomous-ai-agents/openhands/SKILL.md | 149 ++++++++++++++++
scripts/release.py | 1 +
.../docs/reference/optional-skills-catalog.md | 1 +
.../autonomous-ai-agents-openhands.md | 167 ++++++++++++++++++
website/sidebars.ts | 1 +
5 files changed, 319 insertions(+)
create mode 100644 optional-skills/autonomous-ai-agents/openhands/SKILL.md
create mode 100644 website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands.md
diff --git a/optional-skills/autonomous-ai-agents/openhands/SKILL.md b/optional-skills/autonomous-ai-agents/openhands/SKILL.md
new file mode 100644
index 00000000000..5fb51d3dc1f
--- /dev/null
+++ b/optional-skills/autonomous-ai-agents/openhands/SKILL.md
@@ -0,0 +1,149 @@
+---
+name: openhands
+description: Delegate coding to OpenHands CLI (model-agnostic, LiteLLM).
+version: 0.1.0
+author: Tim Koepsel (xzessmedia), Hermes Agent
+license: MIT
+platforms: [linux, macos]
+metadata:
+ hermes:
+ tags: [Coding-Agent, OpenHands, Model-Agnostic, LiteLLM]
+ related_skills: [claude-code, codex, opencode, hermes-agent]
+---
+
+# OpenHands CLI
+
+Delegate coding tasks to the [OpenHands CLI](https://github.com/All-Hands-AI/OpenHands) via the `terminal` tool. OpenHands is model-agnostic: any LiteLLM-supported provider (OpenAI, Anthropic, OpenRouter, DeepSeek, Ollama, vLLM, etc.).
+
+This skill is the headless-mode wrapper for batch / one-shot delegation. The interactive textual UI is not used from Hermes.
+
+## When to Use
+
+- User wants a coding task delegated to OpenHands specifically.
+- User wants a coding agent that can run on a non-Anthropic / non-OpenAI provider (DeepSeek, Qwen, Ollama, vLLM, Nous, etc.) — sibling skills `claude-code` and `codex` are tied to one vendor.
+- Multi-step file edits + shell commands inside a workspace.
+
+For Claude-native, prefer `claude-code`. For OpenAI-native, prefer `codex`. For Hermes-native subagents, use `delegate_task`.
+
+## Prerequisites
+
+1. Install upstream (requires Python 3.12+ and `uv`):
+
+ ```
+ terminal(command="uv tool install openhands --python 3.12")
+ ```
+
+ Verify: `openhands --version` (currently `OpenHands CLI 1.16.0` / `SDK v1.21.0` at time of writing).
+
+2. Pick a model and set env vars for `--override-with-envs`:
+
+ ```
+ export LLM_MODEL=openrouter/openai/gpt-4o-mini # or any LiteLLM slug
+ export LLM_API_KEY=$OPENROUTER_API_KEY
+ export LLM_BASE_URL=https://openrouter.ai/api/v1 # omit for native OpenAI
+ ```
+
+ `LLM_MODEL` uses LiteLLM's full slug. When the provider is OpenRouter the slug is doubly-prefixed: `openrouter//` (e.g. `openrouter/anthropic/claude-sonnet-4.5`). For native Anthropic: `anthropic/claude-sonnet-4-5`. For native OpenAI: `openai/gpt-4o-mini`.
+
+3. Suppress the startup banner so JSON output isn't preceded by ASCII art:
+
+ ```
+ export OPENHANDS_SUPPRESS_BANNER=1
+ ```
+
+## How to Run
+
+Always invoke through the `terminal` tool. Always pass `--headless --json --override-with-envs --exit-without-confirmation` for automation.
+
+### One-shot task
+
+```
+terminal(
+ command="OPENHANDS_SUPPRESS_BANNER=1 LLM_MODEL=openrouter/openai/gpt-4o-mini LLM_API_KEY=$OPENROUTER_API_KEY LLM_BASE_URL=https://openrouter.ai/api/v1 openhands --headless --json --override-with-envs --exit-without-confirmation -t 'Add error handling to all API calls in src/'",
+ workdir="/path/to/project",
+ timeout=600
+)
+```
+
+### Background for long tasks
+
+```
+terminal(command="", workdir="/path/to/project", background=true, notify_on_complete=true)
+process(action="poll", session_id="")
+process(action="log", session_id="")
+```
+
+### Resume a previous conversation
+
+OpenHands prints `Conversation ID: <32-hex>` and a `Hint: openhands --resume ` line at the end of each run. Use the dashed form to resume:
+
+```
+terminal(
+ command="OPENHANDS_SUPPRESS_BANNER=1 LLM_MODEL=... openhands --headless --json --override-with-envs --exit-without-confirmation --resume -t 'Now fix the bug you found'",
+ workdir="/path/to/project"
+)
+```
+
+## Real Flag List
+
+Verified against `openhands --help` (CLI 1.16.0). Anything not in this table is not a flag — pass it via env var or settings file.
+
+| Flag | Effect |
+|------|--------|
+| `--headless` | No UI, requires `-t` or `-f`. Auto-approves all actions (no `--llm-approve` in this mode). |
+| `--json` | JSONL event stream (requires `--headless`). |
+| `-t TEXT` | Task prompt. |
+| `-f PATH` | Read task from file. |
+| `--resume [ID]` | Resume conversation. No ID → list recent. |
+| `--last` | Resume most recent (with `--resume`). |
+| `--override-with-envs` | Apply `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` env vars. Without this, OpenHands uses `~/.openhands/settings.json` and ignores the env. |
+| `--exit-without-confirmation` | Don't show the "are you sure" exit dialog. |
+| `--always-approve` / `--yolo` | Auto-approve every action (default in `--headless`). |
+| `--llm-approve` | LLM-based security gate (interactive only — does NOT work in headless). |
+| `--version` / `-v` | Print version and exit. |
+
+**There is no `--model`, `--max-iterations`, `--workspace`, `--sandbox`, `--sandbox-type` flag.** Model is `LLM_MODEL`. Workspace is the `workdir` you pass to the `terminal` tool. Sandbox / runtime is the `RUNTIME` and `SANDBOX_VOLUMES` env vars.
+
+## JSON Event Schema
+
+With `--json --headless`, OpenHands emits JSONL — one JSON object per line, plus a handful of non-JSON status lines (`Initializing agent...`, `Agent is working`, `Agent finished`, the final summary box, `Goodbye!`, `Conversation ID:`, `Hint:`). Filter for lines starting with `{`.
+
+Top-level `kind` field discriminates events:
+
+- `MessageEvent` — user / agent text turn. `source` is `user` or `agent`.
+- `ActionEvent` — agent picked a tool. Read `tool_name` (`file_editor`, `terminal`, `finish`) and `action.kind` (`FileEditorAction`, `TerminalAction`, `FinishAction`).
+- `ObservationEvent` — tool result. `observation.is_error` is the success flag. `source` is `environment`.
+- `FinishAction` inside an `ActionEvent` carries the agent's final message in `action.message`.
+
+The cli prints all stderr from LiteLLM/Authlib first — see Pitfalls. Parse only stdout, line by line, ignoring lines that don't start with `{`.
+
+## Pitfalls
+
+- **LiteLLM warnings on every invocation.** The CLI prints `bedrock-runtime` and `sagemaker-runtime` warnings to stderr because `botocore` isn't installed. Plus an Authlib deprecation. These are noise, not failures. Pipe stderr to `/dev/null` or filter it out before showing the user.
+- **Banner spam.** Without `OPENHANDS_SUPPRESS_BANNER=1`, every run starts with a multi-line `+--+` ASCII box advertising the SDK. Always export it.
+- **`--override-with-envs` is mandatory for automation.** Without it, OpenHands ignores `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` and falls back to `~/.openhands/settings.json`. On a fresh install this file doesn't exist and the CLI hangs waiting for first-run setup.
+- **Model slug is LiteLLM's, not the provider's.** `openrouter/openai/gpt-4o-mini` works; `openai/gpt-4o-mini` while pointed at OpenRouter does not. `anthropic/claude-sonnet-4-5` (hyphen) is native Anthropic; `openrouter/anthropic/claude-sonnet-4.5` (dot) is via OpenRouter. Get it wrong → cryptic LiteLLM 400.
+- **`pip install openhands-ai` is the wrong package.** That's the legacy V0 SDK. The new CLI is `uv tool install openhands --python 3.12`. There is no maintained conda package.
+- **Resume ID format is fiddly.** The CLI ends with `Conversation ID: f46573d9cfdb45e492ca189bde40019b` (no dashes) and then a `Hint: openhands --resume f46573d9-cfdb-45e4-92ca-189bde40019b` (with dashes). Use the dashed form.
+- **Headless ignores `--llm-approve`.** If you pass it, you get an argparse error. Headless mode hardcodes always-approve.
+- **No Windows support upstream.** The OpenHands docs require WSL on Windows. This skill is gated `[linux, macos]` accordingly.
+- **`~/.openhands/conversations//` accumulates.** Each run persists a trajectory. Clean it up if running batches.
+- **Heavy install (~200 packages).** Use `uv tool install` (isolated venv) to avoid dependency conflicts with the active project.
+
+## Verification
+
+```
+terminal(
+ command="OPENHANDS_SUPPRESS_BANNER=1 LLM_MODEL=openrouter/openai/gpt-4o-mini LLM_API_KEY=$OPENROUTER_API_KEY LLM_BASE_URL=https://openrouter.ai/api/v1 openhands --headless --json --override-with-envs --exit-without-confirmation -t 'Print the string OPENHANDS_OK to stdout via the terminal tool.'",
+ workdir="/tmp",
+ timeout=120
+)
+```
+
+If the JSONL stream ends with a `FinishAction` whose `action.message` mentions `OPENHANDS_OK`, the install is working.
+
+## Related
+
+- [OpenHands GitHub](https://github.com/All-Hands-AI/OpenHands)
+- [OpenHands CLI command reference](https://docs.openhands.dev/openhands/usage/cli/command-reference)
+- Sibling skills: `claude-code` (Anthropic-only), `codex` (OpenAI-only), `opencode` (multi-provider via OpenCode), `hermes-agent` (Hermes subagents via `delegate_task`).
diff --git a/scripts/release.py b/scripts/release.py
index 2211c911838..7b04827b724 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -87,6 +87,7 @@ AUTHOR_MAP = {
"gaia@gaia.local": "jfuenmayor",
"jiahuigu@users.noreply.github.com": "Jiahui-Gu",
"openhands@all-hands.dev": "YLChen-007",
+ "3153586+xzessmedia@users.noreply.github.com": "xzessmedia",
"AdamPlatin123@outlook.com": "AdamPlatin123",
"32711803+waefrebeorn@users.noreply.github.com": "waefrebeorn",
"32869278+dusterbloom@users.noreply.github.com": "dusterbloom",
diff --git a/website/docs/reference/optional-skills-catalog.md b/website/docs/reference/optional-skills-catalog.md
index bd2c22eff3c..e70f52fe32f 100644
--- a/website/docs/reference/optional-skills-catalog.md
+++ b/website/docs/reference/optional-skills-catalog.md
@@ -33,6 +33,7 @@ hermes skills uninstall
|-------|-------------|
| [**blackbox**](/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox) | Delegate coding tasks to Blackbox AI CLI agent. Multi-model agent with built-in judge that runs tasks through multiple LLMs and picks the best result. Requires the blackbox CLI and a Blackbox AI API key. |
| [**honcho**](/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho) | Configure and use Honcho memory with Hermes -- cross-session user modeling, multi-profile peer isolation, observation config, dialectic reasoning, session summaries, and context budget enforcement. Use when setting up Honcho, troubleshoo... |
+| [**openhands**](/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands) | Delegate coding to OpenHands CLI (model-agnostic, LiteLLM). |
## blockchain
diff --git a/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands.md b/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands.md
new file mode 100644
index 00000000000..0e74f2573aa
--- /dev/null
+++ b/website/docs/user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands.md
@@ -0,0 +1,167 @@
+---
+title: "Openhands — Delegate coding to OpenHands CLI (model-agnostic, LiteLLM)"
+sidebar_label: "Openhands"
+description: "Delegate coding to OpenHands CLI (model-agnostic, LiteLLM)"
+---
+
+{/* This page is auto-generated from the skill's SKILL.md by website/scripts/generate-skill-docs.py. Edit the source SKILL.md, not this page. */}
+
+# Openhands
+
+Delegate coding to OpenHands CLI (model-agnostic, LiteLLM).
+
+## Skill metadata
+
+| | |
+|---|---|
+| Source | Optional — install with `hermes skills install official/autonomous-ai-agents/openhands` |
+| Path | `optional-skills/autonomous-ai-agents/openhands` |
+| Version | `0.1.0` |
+| Author | Tim Koepsel (xzessmedia), Hermes Agent |
+| License | MIT |
+| Platforms | linux, macos |
+| Tags | `Coding-Agent`, `OpenHands`, `Model-Agnostic`, `LiteLLM` |
+| Related skills | [`claude-code`](/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code), [`codex`](/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-codex), [`opencode`](/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-opencode), [`hermes-agent`](/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent) |
+
+## Reference: full SKILL.md
+
+:::info
+The following is the complete skill definition that Hermes loads when this skill is triggered. This is what the agent sees as instructions when the skill is active.
+:::
+
+# OpenHands CLI
+
+Delegate coding tasks to the [OpenHands CLI](https://github.com/All-Hands-AI/OpenHands) via the `terminal` tool. OpenHands is model-agnostic: any LiteLLM-supported provider (OpenAI, Anthropic, OpenRouter, DeepSeek, Ollama, vLLM, etc.).
+
+This skill is the headless-mode wrapper for batch / one-shot delegation. The interactive textual UI is not used from Hermes.
+
+## When to Use
+
+- User wants a coding task delegated to OpenHands specifically.
+- User wants a coding agent that can run on a non-Anthropic / non-OpenAI provider (DeepSeek, Qwen, Ollama, vLLM, Nous, etc.) — sibling skills `claude-code` and `codex` are tied to one vendor.
+- Multi-step file edits + shell commands inside a workspace.
+
+For Claude-native, prefer `claude-code`. For OpenAI-native, prefer `codex`. For Hermes-native subagents, use `delegate_task`.
+
+## Prerequisites
+
+1. Install upstream (requires Python 3.12+ and `uv`):
+
+ ```
+ terminal(command="uv tool install openhands --python 3.12")
+ ```
+
+ Verify: `openhands --version` (currently `OpenHands CLI 1.16.0` / `SDK v1.21.0` at time of writing).
+
+2. Pick a model and set env vars for `--override-with-envs`:
+
+ ```
+ export LLM_MODEL=openrouter/openai/gpt-4o-mini # or any LiteLLM slug
+ export LLM_API_KEY=$OPENROUTER_API_KEY
+ export LLM_BASE_URL=https://openrouter.ai/api/v1 # omit for native OpenAI
+ ```
+
+ `LLM_MODEL` uses LiteLLM's full slug. When the provider is OpenRouter the slug is doubly-prefixed: `openrouter//` (e.g. `openrouter/anthropic/claude-sonnet-4.5`). For native Anthropic: `anthropic/claude-sonnet-4-5`. For native OpenAI: `openai/gpt-4o-mini`.
+
+3. Suppress the startup banner so JSON output isn't preceded by ASCII art:
+
+ ```
+ export OPENHANDS_SUPPRESS_BANNER=1
+ ```
+
+## How to Run
+
+Always invoke through the `terminal` tool. Always pass `--headless --json --override-with-envs --exit-without-confirmation` for automation.
+
+### One-shot task
+
+```
+terminal(
+ command="OPENHANDS_SUPPRESS_BANNER=1 LLM_MODEL=openrouter/openai/gpt-4o-mini LLM_API_KEY=$OPENROUTER_API_KEY LLM_BASE_URL=https://openrouter.ai/api/v1 openhands --headless --json --override-with-envs --exit-without-confirmation -t 'Add error handling to all API calls in src/'",
+ workdir="/path/to/project",
+ timeout=600
+)
+```
+
+### Background for long tasks
+
+```
+terminal(command="", workdir="/path/to/project", background=true, notify_on_complete=true)
+process(action="poll", session_id="")
+process(action="log", session_id="")
+```
+
+### Resume a previous conversation
+
+OpenHands prints `Conversation ID: <32-hex>` and a `Hint: openhands --resume ` line at the end of each run. Use the dashed form to resume:
+
+```
+terminal(
+ command="OPENHANDS_SUPPRESS_BANNER=1 LLM_MODEL=... openhands --headless --json --override-with-envs --exit-without-confirmation --resume -t 'Now fix the bug you found'",
+ workdir="/path/to/project"
+)
+```
+
+## Real Flag List
+
+Verified against `openhands --help` (CLI 1.16.0). Anything not in this table is not a flag — pass it via env var or settings file.
+
+| Flag | Effect |
+|------|--------|
+| `--headless` | No UI, requires `-t` or `-f`. Auto-approves all actions (no `--llm-approve` in this mode). |
+| `--json` | JSONL event stream (requires `--headless`). |
+| `-t TEXT` | Task prompt. |
+| `-f PATH` | Read task from file. |
+| `--resume [ID]` | Resume conversation. No ID → list recent. |
+| `--last` | Resume most recent (with `--resume`). |
+| `--override-with-envs` | Apply `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` env vars. Without this, OpenHands uses `~/.openhands/settings.json` and ignores the env. |
+| `--exit-without-confirmation` | Don't show the "are you sure" exit dialog. |
+| `--always-approve` / `--yolo` | Auto-approve every action (default in `--headless`). |
+| `--llm-approve` | LLM-based security gate (interactive only — does NOT work in headless). |
+| `--version` / `-v` | Print version and exit. |
+
+**There is no `--model`, `--max-iterations`, `--workspace`, `--sandbox`, `--sandbox-type` flag.** Model is `LLM_MODEL`. Workspace is the `workdir` you pass to the `terminal` tool. Sandbox / runtime is the `RUNTIME` and `SANDBOX_VOLUMES` env vars.
+
+## JSON Event Schema
+
+With `--json --headless`, OpenHands emits JSONL — one JSON object per line, plus a handful of non-JSON status lines (`Initializing agent...`, `Agent is working`, `Agent finished`, the final summary box, `Goodbye!`, `Conversation ID:`, `Hint:`). Filter for lines starting with `{`.
+
+Top-level `kind` field discriminates events:
+
+- `MessageEvent` — user / agent text turn. `source` is `user` or `agent`.
+- `ActionEvent` — agent picked a tool. Read `tool_name` (`file_editor`, `terminal`, `finish`) and `action.kind` (`FileEditorAction`, `TerminalAction`, `FinishAction`).
+- `ObservationEvent` — tool result. `observation.is_error` is the success flag. `source` is `environment`.
+- `FinishAction` inside an `ActionEvent` carries the agent's final message in `action.message`.
+
+The cli prints all stderr from LiteLLM/Authlib first — see Pitfalls. Parse only stdout, line by line, ignoring lines that don't start with `{`.
+
+## Pitfalls
+
+- **LiteLLM warnings on every invocation.** The CLI prints `bedrock-runtime` and `sagemaker-runtime` warnings to stderr because `botocore` isn't installed. Plus an Authlib deprecation. These are noise, not failures. Pipe stderr to `/dev/null` or filter it out before showing the user.
+- **Banner spam.** Without `OPENHANDS_SUPPRESS_BANNER=1`, every run starts with a multi-line `+--+` ASCII box advertising the SDK. Always export it.
+- **`--override-with-envs` is mandatory for automation.** Without it, OpenHands ignores `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` and falls back to `~/.openhands/settings.json`. On a fresh install this file doesn't exist and the CLI hangs waiting for first-run setup.
+- **Model slug is LiteLLM's, not the provider's.** `openrouter/openai/gpt-4o-mini` works; `openai/gpt-4o-mini` while pointed at OpenRouter does not. `anthropic/claude-sonnet-4-5` (hyphen) is native Anthropic; `openrouter/anthropic/claude-sonnet-4.5` (dot) is via OpenRouter. Get it wrong → cryptic LiteLLM 400.
+- **`pip install openhands-ai` is the wrong package.** That's the legacy V0 SDK. The new CLI is `uv tool install openhands --python 3.12`. There is no maintained conda package.
+- **Resume ID format is fiddly.** The CLI ends with `Conversation ID: f46573d9cfdb45e492ca189bde40019b` (no dashes) and then a `Hint: openhands --resume f46573d9-cfdb-45e4-92ca-189bde40019b` (with dashes). Use the dashed form.
+- **Headless ignores `--llm-approve`.** If you pass it, you get an argparse error. Headless mode hardcodes always-approve.
+- **No Windows support upstream.** The OpenHands docs require WSL on Windows. This skill is gated `[linux, macos]` accordingly.
+- **`~/.openhands/conversations//` accumulates.** Each run persists a trajectory. Clean it up if running batches.
+- **Heavy install (~200 packages).** Use `uv tool install` (isolated venv) to avoid dependency conflicts with the active project.
+
+## Verification
+
+```
+terminal(
+ command="OPENHANDS_SUPPRESS_BANNER=1 LLM_MODEL=openrouter/openai/gpt-4o-mini LLM_API_KEY=$OPENROUTER_API_KEY LLM_BASE_URL=https://openrouter.ai/api/v1 openhands --headless --json --override-with-envs --exit-without-confirmation -t 'Print the string OPENHANDS_OK to stdout via the terminal tool.'",
+ workdir="/tmp",
+ timeout=120
+)
+```
+
+If the JSONL stream ends with a `FinishAction` whose `action.message` mentions `OPENHANDS_OK`, the install is working.
+
+## Related
+
+- [OpenHands GitHub](https://github.com/All-Hands-AI/OpenHands)
+- [OpenHands CLI command reference](https://docs.openhands.dev/openhands/usage/cli/command-reference)
+- Sibling skills: `claude-code` (Anthropic-only), `codex` (OpenAI-only), `opencode` (multi-provider via OpenCode), `hermes-agent` (Hermes subagents via `delegate_task`).
diff --git a/website/sidebars.ts b/website/sidebars.ts
index a3c41cbf205..a994e4e7fee 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -392,6 +392,7 @@ const sidebars: SidebarsConfig = {
items: [
'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox',
'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-honcho',
+ 'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-openhands',
],
},
{
From 263e008d6bebbf3c7f67eaff8c4d71429c9b361f Mon Sep 17 00:00:00 2001
From: Teknium <127238744+teknium1@users.noreply.github.com>
Date: Mon, 25 May 2026 14:51:41 -0700
Subject: [PATCH 019/260] feat(skills): add web-pentest optional skill (#32265)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds optional-skills/security/web-pentest/ — an authorized web app
penetration testing skill adapted from Shannon's methodology (concepts
only; AGPL-clean fresh implementation).
Phased: recon (read-only) → vuln analysis (delegate_task per OWASP
class) → proof-based exploitation → report.
Guardrails baked in:
- Authorization gate before first active scan (templates/authorization.md)
- Scope allowlist (scope.txt) consulted by recon-scan.sh and
documented as the rule for every active request
- Aux-client leakage warning (compression + title gen replay history;
payloads/creds must not enter chat verbatim)
- Bypass-exhaustion discipline before false-positive classification
- L3/L4 (proof-required) for reportable findings; L1/L2 listed as
candidates only
Closes #400. Supersedes #21845 (plugin-shaped proposal; skill-shaped is
cheaper and matches the existing optional-skills/security/ pattern).
---
optional-skills/security/web-pentest/SKILL.md | 333 ++++++++++++++++++
.../references/bypass-techniques.md | 133 +++++++
.../references/exploitation-techniques.md | 204 +++++++++++
.../references/scope-enforcement.md | 110 ++++++
.../web-pentest/references/vuln-taxonomy.md | 81 +++++
.../web-pentest/scripts/recon-scan.sh | 126 +++++++
.../web-pentest/templates/authorization.md | 69 ++++
.../templates/exploitation-queue.json | 34 ++
.../web-pentest/templates/pentest-report.md | 178 ++++++++++
9 files changed, 1268 insertions(+)
create mode 100644 optional-skills/security/web-pentest/SKILL.md
create mode 100644 optional-skills/security/web-pentest/references/bypass-techniques.md
create mode 100644 optional-skills/security/web-pentest/references/exploitation-techniques.md
create mode 100644 optional-skills/security/web-pentest/references/scope-enforcement.md
create mode 100644 optional-skills/security/web-pentest/references/vuln-taxonomy.md
create mode 100755 optional-skills/security/web-pentest/scripts/recon-scan.sh
create mode 100644 optional-skills/security/web-pentest/templates/authorization.md
create mode 100644 optional-skills/security/web-pentest/templates/exploitation-queue.json
create mode 100644 optional-skills/security/web-pentest/templates/pentest-report.md
diff --git a/optional-skills/security/web-pentest/SKILL.md b/optional-skills/security/web-pentest/SKILL.md
new file mode 100644
index 00000000000..1ea82f8f0a7
--- /dev/null
+++ b/optional-skills/security/web-pentest/SKILL.md
@@ -0,0 +1,333 @@
+---
+name: web-pentest
+description: |
+ Authorized web application penetration testing — reconnaissance, vulnerability
+ analysis, proof-based exploitation, and professional reporting. Adapts
+ Shannon's "No Exploit, No Report" methodology with hard guardrails for
+ scope, authorization, and aux-client leakage. Active testing against running
+ applications you own or have written authorization to test.
+platforms: [linux, macos]
+category: security
+triggers:
+ - "pentest [URL]"
+ - "pentest this app"
+ - "penetration test [URL]"
+ - "security test this web app"
+ - "test [URL] for vulnerabilities"
+ - "find vulns in [URL]"
+ - "OWASP test [URL]"
+toolsets:
+ - terminal
+ - web
+ - browser
+ - file
+ - delegation
+---
+
+# Web Application Penetration Testing
+
+A phased pentesting workflow for running web applications. Adapted from
+Shannon's pipeline (Keygraph, AGPL — concepts only, no code borrowed).
+Built around three rules:
+
+1. No exploit, no report — every finding requires reproducible evidence.
+2. Bounded scope — every active request goes against a target the operator
+ pre-declared. Off-scope hosts are refused.
+3. Bypass exhaustion before false-positive dismissal — a "blocked" payload
+ is not a clean bill of health until you've tried the bypass set.
+
+---
+
+## ⚠️ Hard Guardrails — Read Before Every Engagement
+
+Violating any of these invalidates the engagement and may be illegal.
+
+1. **Authorization gate.** Before the first active scan in a session, you
+ MUST confirm with the user, in writing, that they own or have written
+ authorization to test the target. Record the acknowledgement in
+ `engagement/authorization.md` (see template). No acknowledgement → no
+ active scanning. Reading public pages with `curl` is fine; sending
+ payloads is not.
+
+2. **Scope allowlist.** Maintain `engagement/scope.txt` — one hostname or
+ CIDR per line. Every `nmap`, `curl`, `whatweb`, browser navigation, or
+ payload-bearing request MUST be against an entry in scope. If a target
+ redirects you off-scope (3xx to a different host, a link in HTML),
+ STOP and confirm with the user before following.
+
+3. **No production systems without paper.** If the user hasn't told you
+ "yes, prod is in scope and I have written sign-off," assume not. Default
+ targets are staging, local docker, dedicated test instances.
+
+4. **Cloud metadata is off by default.** Do not probe `169.254.169.254`,
+ `metadata.google.internal`, `100.100.100.200`, `[fd00:ec2::254]`, or
+ equivalent unless the engagement explicitly includes SSRF-to-metadata
+ as a goal AND the target is one you control. The agent's browser tool
+ can reach these from inside your own infrastructure — don't.
+
+5. **Destructive payloads need approval.** SQLi payloads that DROP/DELETE,
+ filesystem-write SSTI, command injection with `rm`/`shutdown`/`mkfs`,
+ anything that mutates beyond a single test row → ASK FIRST. The
+ `approval.py` system catches some; don't rely on it alone.
+
+6. **Aux-client leakage risk (Hermes-specific).** This skill produces
+ sessions full of SQLi/XSS/RCE payloads, captured credentials, JWT
+ tokens. Hermes' compression and title-generation paths replay history
+ through the auxiliary client (often the main model). Anything sensitive
+ you write to the conversation can leave the box on the next compress.
+ Mitigation:
+ - Redact captured tokens/credentials to the LAST 6 CHARS before logging
+ them in any message. Full values go to `engagement/evidence/` files,
+ never into chat history.
+ - If the engagement is sensitive, set `auxiliary.title_generation.enabled: false`
+ in `~/.hermes/config.yaml` for the session.
+
+7. **Rate limit yourself.** Default 200ms between active requests against
+ any single host. The recon-scan.sh script enforces this. Don't bypass
+ it without operator approval.
+
+8. **Authority of the report.** This skill produces a security
+ assessment, not a "PASS." Even a clean run is "no exploitable issues
+ FOUND in scope X within time T using methods Y" — not "the application
+ is secure." Mirror that language in the report.
+
+---
+
+## Phase 0: Engagement Setup
+
+Before any scanning happens, create the engagement directory and
+authorization acknowledgement.
+
+```bash
+ENGAGEMENT=engagement-$(date +%Y%m%d-%H%M%S)
+mkdir -p "$ENGAGEMENT"/{evidence,findings,reports}
+cd "$ENGAGEMENT"
+```
+
+1. **Ask the user (verbatim):**
+ > "Confirm: (a) the target URL is [X], (b) you own this application
+ > or have written authorization to test it, and (c) the engagement
+ > may run for up to [N] hours starting now. Reply 'authorized' to
+ > proceed."
+
+2. **Wait for explicit `authorized` response.** Any other answer means STOP.
+
+3. **Record authorization** to `engagement/authorization.md` using the
+ template in `templates/authorization.md`. Include:
+ - Target URL(s) and IP(s)
+ - Authorization basis (ownership / written authz from $name)
+ - Engagement window
+ - Out-of-scope items (production, third-party services, etc.)
+ - Operator name (the user driving this session)
+
+4. **Build scope.txt:**
+ ```
+ localhost
+ 127.0.0.1
+ staging.example.com
+ 192.168.1.0/24 # internal lab only, with operator OK
+ ```
+
+5. **Read** `references/scope-enforcement.md` before issuing the first
+ active request — that doc has the host-extraction rules you apply
+ to every command/URL before it goes out.
+
+---
+
+## Phase 1: Pre-Recon (Code Analysis, optional)
+
+Skip if no source access (black-box engagement).
+
+If you have read access to the application source:
+
+1. **Map the architecture** — framework, routing, middleware stack
+2. **Inventory sinks** — every `execute(`, `os.system(`, `eval(`,
+ template render, file read/write, redirect target
+3. **Map auth** — session cookie vs JWT, OAuth flows, password reset,
+ privileged endpoints
+4. **Identify trust boundaries** — what's authenticated, what's not,
+ what comes from `request.*`
+5. **Backward taint** from each sink to a request source. Early-terminate
+ when proper sanitization is found (parameterized queries, allowlists,
+ `shlex.quote`, well-known escapers).
+
+Output: `evidence/pre-recon.md` — architecture map, sink inventory,
+suspected vulnerable code paths.
+
+This is OFFLINE work. No traffic to the target.
+
+---
+
+## Phase 2: Recon (Live, Read-Only)
+
+Maps the attack surface. All requests are GETs of public pages, no
+payloads yet. Still scope-bounded.
+
+1. **Verify scope.** Resolve every target hostname → IP. Confirm IPs are
+ in scope (avoids the "DNS points somewhere unexpected" trap).
+
+2. **Network surface** (only if scope permits port scanning):
+ ```bash
+ nmap -sT -T3 --top-ports 100 -oN evidence/nmap.txt $TARGET
+ ```
+ Use `-T3` (default), not `-T4/-T5`. Stealthier and avoids tripping
+ IDS/IPS in shared environments.
+
+3. **Tech fingerprint:**
+ ```bash
+ whatweb -v $TARGET_URL > evidence/whatweb.txt
+ curl -sIk $TARGET_URL > evidence/headers.txt
+ ```
+
+4. **Endpoint discovery:**
+ - Crawl the app with the browser tool (`browser_navigate`,
+ `browser_get_images`, follow links).
+ - Inspect `robots.txt`, `sitemap.xml`, `.well-known/*`.
+ - Use the developer tools network panel via browser tool to capture
+ XHR/fetch calls.
+
+5. **Auth surface:** Identify login, registration, password reset,
+ session cookie names, token formats. Do NOT send credentials yet —
+ just observe.
+
+6. **Correlate with pre-recon** (if you have source). For each
+ `evidence/pre-recon.md` finding, mark whether the live surface
+ confirms it's reachable.
+
+Output: `evidence/recon.md` — endpoints, technologies, auth model,
+input vectors.
+
+---
+
+## Phase 3: Vulnerability Analysis
+
+One delegate_task per vulnerability class. Each agent reads
+`evidence/recon.md` (+ `evidence/pre-recon.md` if present), produces
+`findings/-queue.json` using `templates/exploitation-queue.json`.
+
+Use `delegate_task` with these focused subagents (parallel where possible):
+
+| Class | Goal | Reference |
+|-------|------|-----------|
+| `injection` | SQLi, command, path traversal, SSTI, LFI/RFI, deserialization | `references/vuln-taxonomy.md` (slot types) |
+| `xss` | Reflected, stored, DOM-based | `references/vuln-taxonomy.md` (render contexts) |
+| `auth` | Login bypass, JWT confusion, session fixation, OAuth flaws | `references/exploitation-techniques.md` |
+| `authz` | IDOR, vertical/horizontal escalation, business logic | `references/exploitation-techniques.md` |
+| `ssrf` | Internal reachability, metadata, protocol smuggling | Skip metadata unless explicitly authorized |
+| `infra` | Misconfig, info disclosure, default creds, exposed admin | `references/exploitation-techniques.md` |
+
+Each queue entry has: id, vuln class, source (file:line if known),
+endpoint, parameter, slot type, suspected defense, verdict
+(`identified` / `partial` / `confirmed` / `critical`), witness payload,
+confidence (0-1), notes.
+
+The analysis phase doesn't send malicious payloads yet — it stages them.
+The exploitation phase actually fires them.
+
+---
+
+## Phase 4: Exploitation (Proof-Based, Conditional)
+
+Only run a sub-agent per class where the analysis queue has actionable
+entries (`identified` or `partial`).
+
+For each candidate:
+
+1. **Pre-send check** — host in scope? auth gate satisfied? payload
+ approved if destructive?
+2. **Send the witness payload** — minimal proof. SQLi: `' AND 1=1--`
+ then `' AND 1=2--`. XSS: a benign marker like
+ `
-
-"""
-
-
-def render_login_html() -> str:
- providers = list_providers()
- if not providers:
- return _EMPTY_HTML
-
- buttons = []
- for p in providers:
- buttons.append(
- f' '
- f'Sign in with {html.escape(p.display_name)}'
- )
- return _LOGIN_HTML_TEMPLATE.format(provider_buttons="\n".join(buttons))
-```
-
-**Step 2: Add the route.**
-
-In `hermes_cli/dashboard_auth/routes.py`, add at the top of the route list:
-
-```python
-from fastapi.responses import HTMLResponse
-from hermes_cli.dashboard_auth.login_page import render_login_html
-
-
-@router.get("/login", name="login")
-async def login_page(request: Request):
- return HTMLResponse(
- render_login_html(),
- headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
- )
-```
-
-**Step 3: Commit.**
-
-```bash
-git add hermes_cli/dashboard_auth/login_page.py hermes_cli/dashboard_auth/routes.py
-git commit -m "feat(dashboard-auth): server-rendered /login page listing providers"
-```
-
-### Task 3.5: Suppress `_SESSION_TOKEN` injection and harden `start_server` when gate is on
-
-**Objective:**
-1. When `auth_required is True`, `_serve_index` must NOT inject `window.__HERMES_SESSION_TOKEN__`. The post-login SPA reads identity from `/api/auth/me` instead.
-2. `start_server`'s SystemExit on `host != loopback && not allow_public` is replaced with "the auth gate engages" branching. If zero providers are registered AND gate would be active, fail closed (SystemExit with a clear message).
-3. Re-enable `proxy_headers=True` on uvicorn when gate is active so cookies see the real client scheme (`X-Forwarded-Proto`) behind Fly's TLS terminator.
-
-**Files:**
-- Modify: `hermes_cli/web_server.py` — `_serve_index` (~line 3676), `start_server` (~line 4514).
-
-**Step 1: Write failing tests.**
-
-Append to `tests/hermes_cli/test_dashboard_auth_gate.py`:
-
-```python
-def test_gated_index_does_not_inject_session_token(monkeypatch):
- web_server.app.state.auth_required = True
- web_server.app.state.bound_host = "0.0.0.0"
- try:
- client = TestClient(web_server.app)
- r = client.get("/login") # _serve_index isn't hit on /login
- # And confirm the SPA index itself (post-login) doesn't leak the token.
- # But / redirects to /login when no cookie, so we can't easily get to
- # _serve_index without an authenticated cookie. Verify the helper
- # directly:
- from hermes_cli.web_server import WEB_DIST
- if (WEB_DIST / "index.html").exists():
- # Construct a Request and call _serve_index directly via a
- # known-good path is fiddly; instead read the function source's
- # branch handling. For an integration test, use a logged-in
- # cookie via Stub provider and check the body of /.
- pass
- finally:
- web_server.app.state.auth_required = False
-
-
-def test_start_server_with_gate_and_no_providers_fails_closed(monkeypatch):
- from hermes_cli.dashboard_auth import clear_providers
- clear_providers()
- monkeypatch.setattr(web_server, "uvicorn", type("U", (), {"run": staticmethod(lambda *a, **k: None)}))
- with pytest.raises(SystemExit, match="no auth providers"):
- web_server.start_server(host="0.0.0.0", port=9119,
- open_browser=False, allow_public=False)
-
-
-def test_start_server_with_gate_and_provider_proceeds(monkeypatch):
- from hermes_cli.dashboard_auth import clear_providers, register_provider
- from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
- clear_providers()
- register_provider(StubAuthProvider())
- called = {}
- def fake_run(*a, **kw):
- called.update(kw)
- monkeypatch.setattr(web_server, "uvicorn", type("U", (), {"run": staticmethod(fake_run)}))
- web_server.start_server(host="0.0.0.0", port=9119,
- open_browser=False, allow_public=False)
- assert called["host"] == "0.0.0.0"
- # proxy_headers must be True in gated mode so X-Forwarded-Proto from Fly
- # is honoured for cookie Secure-flag decisions.
- assert called["proxy_headers"] is True
- clear_providers()
-```
-
-**Step 2: Modify `_serve_index`.**
-
-In `hermes_cli/web_server.py:3676`:
-
-```python
- def _serve_index(prefix: str = ""):
- html = _index_path.read_text()
- chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
-
- # When the OAuth gate is active, do NOT inject _SESSION_TOKEN — the
- # SPA reads identity from /api/auth/me using cookie auth instead.
- if getattr(app.state, "auth_required", False):
- bootstrap_script = (
- f""
- )
- else:
- bootstrap_script = (
- f'"
- )
- # ... rest of the function (asset-prefix rewrites) unchanged, but
- # replace ``token_script`` with ``bootstrap_script`` on the final
- # ``html.replace("", ...)`` call.
-```
-
-**Step 3: Modify `start_server`.**
-
-Replace the `_LOCALHOST` block (lines ~4528–4539) with:
-
-```python
- app.state.auth_required = should_require_auth(host, allow_public)
-
- if app.state.auth_required:
- from hermes_cli.dashboard_auth import list_providers
- if not list_providers():
- raise SystemExit(
- f"Refusing to bind dashboard to {host} — the auth gate is "
- f"required but no auth providers are registered. Install the "
- f"default Nous provider (plugins/dashboard-auth-nous) or another "
- f"DashboardAuthProvider plugin. Pass --insecure to skip the "
- f"auth gate (NOT recommended on untrusted networks)."
- )
- _log.info(
- "Dashboard binding to %s with OAuth auth gate enabled. "
- "Providers: %s", host,
- ", ".join(p.name for p in list_providers()),
- )
- elif host not in _LOCALHOST and allow_public:
- _log.warning(
- "Binding to %s with --insecure — the dashboard has no robust "
- "authentication. Only use on trusted networks.", host,
- )
-```
-
-The original `_LOCALHOST = (...)` constant tuple and the SystemExit at line 4530 are removed (the predicate has subsumed them).
-
-Update the `uvicorn.run` call (line 4583):
-
-```python
- uvicorn.run(
- app, host=host, port=port, log_level="warning",
- # Trust X-Forwarded-Proto / X-Forwarded-For ONLY when the auth gate
- # is engaged AND we're behind a known terminator (Fly.io). The
- # gateway never runs publicly without a terminator; in loopback
- # mode there's nothing to proxy.
- proxy_headers=bool(app.state.auth_required),
- )
-```
-
-**Step 4: Run, verify pass.**
-
-```bash
-scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_gate.py tests/hermes_cli/test_dashboard_auth_middleware.py -v
-```
-
-**Step 5: Commit.**
-
-```bash
-git add hermes_cli/web_server.py tests/hermes_cli/test_dashboard_auth_gate.py
-git commit -m "feat(dashboard-auth): suppress _SESSION_TOKEN injection in gated mode; fail-closed without providers"
-```
-
-### Phase 3 Exit Gate
-
-End-to-end test passes:
-
-```bash
-scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_gate.py tests/hermes_cli/test_dashboard_auth_middleware.py tests/hermes_cli/test_dashboard_auth_cookies.py tests/hermes_cli/test_dashboard_auth_stub_provider.py -v
-```
-
-Manual smoke test:
-1. `hermes dashboard --host 0.0.0.0 --port 9119` with the stub registered via a local `~/.hermes/plugins/dashboard-auth-stub/` plugin. Expect: server starts, listens on `0.0.0.0:9119`.
-2. Browser to `http://localhost:9119/` → 302 → `/login` → "Sign in with Stub IdP" button.
-3. Click → bounces through `/auth/login?provider=stub` → stub redirects to `/auth/callback?code=stub_code&state=…` → 302 → `/`.
-4. The SPA loads with cookies, hits `/api/auth/me`, displays "Logged in as Stub User".
-5. `~/.hermes/logs/dashboard-auth.log` contains `login_start` and `login_success` entries.
-
-Loopback regression test passes unchanged:
-
-```bash
-hermes dashboard
-# → http://127.0.0.1:9119 still works without any login, token still injected
-```
-
----
-
-## Phase 4 — Real Nous Provider Plugin (v2 — contract-compliant)
-
-> **Plan v2 rewrite.** This section was rewritten on 2026-05-21 after fetching the Portal's published contract (PR #180). The original draft (which assumed a static `hermes-dashboard` client_id, userinfo-fallback verification, and broad OIDC scopes) is preserved further below under "Phase 4 v1 (rejected — preserved for archeology)" for reviewer context.
-
-**Goal:** Ship `plugins/dashboard-auth-nous/` as a real `DashboardAuthProvider` that implements the Nous Portal authorization-code + PKCE flow per `nous-account-service/docs/agent-dashboard-oauth-contract.md`. Bundled with Hermes (lives under `plugins/`, auto-loaded). The plugin is a no-op unless the Portal-injected env vars are present, so it has zero impact on loopback-only / `--insecure` operators.
-
-**Dependencies:** Portal-side endpoints already exist in PR #180:
-- `GET /oauth/authorize` (authorization endpoint)
-- `POST /api/oauth/token` (token endpoint, accepts `grant_type=authorization_code`)
-- `GET /.well-known/jwks.json` (RS256 signing keys, RFC-7517 JWK Set, cache `public, max-age=300, stale-while-revalidate=300`)
-
-There is no userinfo endpoint and no refresh-token endpoint for the dashboard flow. Token lifetime is 900 seconds.
-
-### Task 4.1: Plugin skeleton + env-driven provider class
-
-**Objective:** A `plugins/dashboard-auth-nous/` directory that registers a `NousDashboardAuthProvider` ONLY when `HERMES_DASHBOARD_OAUTH_CLIENT_ID` is set. If unset (the common case for loopback / `--insecure` operators), the plugin loads but registers nothing — the gate then fails closed if it's actually engaged, which is correct.
-
-**Files:**
-- Create: `plugins/dashboard-auth-nous/plugin.yaml`
-- Create: `plugins/dashboard-auth-nous/__init__.py`
-- Create: `plugins/dashboard-auth-nous/provider.py`
-- Create: `plugins/dashboard-auth-nous/test_provider.py`
-
-**Step 1: Plugin manifest.**
-
-```yaml
-# plugins/dashboard-auth-nous/plugin.yaml
-name: dashboard-auth-nous
-version: 1.0.0
-description: "Default dashboard auth provider for Hermes — OAuth via Nous Portal (portal.nousresearch.com)."
-author: NousResearch
-kind: dashboard_auth
-pip_dependencies:
- - httpx
- - pyjwt[crypto]
-```
-
-**Step 2: Plugin entry point — conditional registration.**
-
-```python
-# plugins/dashboard-auth-nous/__init__.py
-"""Default dashboard-auth provider — Nous Portal OAuth.
-
-Auto-loaded by the plugin system at startup. Registers the provider only
-when the Portal-injected env vars are present; otherwise loads silently
-so loopback / --insecure operators don't see a spurious "auth misconfigured"
-warning every time they start the dashboard.
-"""
-import logging
-import os
-
-from plugins.dashboard_auth_nous.provider import NousDashboardAuthProvider
-
-_log = logging.getLogger(__name__)
-
-
-def register(ctx):
- """Plugin entry — called by the plugin loader.
-
- Required env vars (Portal injects these at Fly.io provisioning time):
- HERMES_DASHBOARD_OAUTH_CLIENT_ID — must be shape ``agent:{instance_id}``
- HERMES_DASHBOARD_PORTAL_URL — e.g. https://portal.nousresearch.com
-
- With both present, registers the provider. With either missing, logs a
- DEBUG note and returns silently — operator-owned dashboards binding to
- loopback (or running with --insecure) are not expected to set these.
- The gate-engagement layer fails closed if a public bind is attempted
- with zero providers registered, so the failure mode is already covered.
- """
- client_id = os.environ.get("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "").strip()
- portal_url = os.environ.get("HERMES_DASHBOARD_PORTAL_URL", "").strip()
-
- if not client_id or not portal_url:
- _log.debug(
- "dashboard-auth-nous: env vars missing "
- "(HERMES_DASHBOARD_OAUTH_CLIENT_ID=%r, HERMES_DASHBOARD_PORTAL_URL=%r); "
- "not registering provider.",
- bool(client_id), bool(portal_url),
- )
- return
-
- if not client_id.startswith("agent:"):
- _log.warning(
- "dashboard-auth-nous: HERMES_DASHBOARD_OAUTH_CLIENT_ID=%r does not "
- "match contract shape 'agent:{instance_id}'; not registering provider. "
- "Set this env var to the value provisioned by Nous Portal.",
- client_id,
- )
- return
-
- ctx.register_dashboard_auth_provider(
- NousDashboardAuthProvider(client_id=client_id, portal_url=portal_url)
- )
-```
-
-**Step 3: Provider implementation — contract-compliant.**
-
-```python
-# plugins/dashboard-auth-nous/provider.py
-"""NousDashboardAuthProvider — authorization-code + PKCE against Nous Portal.
-
-Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``
-(PR #180). Key contract points encoded here:
-
- - client_id is per-instance (``agent:{instance_id}``), injected at
- provisioning. Stored on ``self._client_id``; ``self._agent_instance_id``
- is the suffix used for defense-in-depth claim verification.
- - scope is ``agent_dashboard:access`` only.
- - redirect_uri is computed from request.url_for("auth_callback") under
- proxy_headers=True so Fly's TLS terminator's X-Forwarded-Proto / Host
- are honoured.
- - tokens are RS256-signed JWTs verified against ``/.well-known/jwks.json``;
- JWKS is cached for 5 minutes with stale-while-revalidate.
- - V1 has no refresh tokens — refresh_session always raises
- RefreshExpiredError so the middleware redirects to /auth/login.
-"""
-from __future__ import annotations
-
-import base64
-import hashlib
-import logging
-import secrets
-import time
-import urllib.parse
-from typing import Optional
-
-import httpx
-import jwt
-from jwt import PyJWKClient
-
-from hermes_cli.dashboard_auth.base import (
- DashboardAuthProvider,
- Session,
- LoginStart,
- InvalidCodeError,
- ProviderError,
- RefreshExpiredError,
-)
-
-_log = logging.getLogger(__name__)
-
-# Contract: ``agent_dashboard:access`` is the scope name for this flow.
-_SCOPE = "agent_dashboard:access"
-
-# Contract: tolerant treatment — if the claim is missing, warn and proceed;
-# if present and != 1, refuse. See OQ-C2.
-_EXPECTED_CONTRACT_VERSION = 1
-
-# Contract: JWKS cache lifetime (matches Portal's Cache-Control header).
-_JWKS_CACHE_SECONDS = 300
-
-
-def _b64url_no_pad(raw: bytes) -> str:
- return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
-
-
-class NousDashboardAuthProvider(DashboardAuthProvider):
- """Nous Portal OAuth via authorization-code + PKCE (S256)."""
-
- name = "nous"
- display_name = "Nous Research"
-
- def __init__(self, *, client_id: str, portal_url: str) -> None:
- if not client_id.startswith("agent:"):
- # Defense-in-depth — the plugin entry already filters, but the
- # provider should never be constructed with a malformed id.
- raise ValueError(
- f"client_id must match contract shape 'agent:{{instance_id}}', "
- f"got {client_id!r}"
- )
- self._client_id = client_id
- self._agent_instance_id = client_id[len("agent:"):]
- self._portal_url = portal_url.rstrip("/")
- self._jwks_url = f"{self._portal_url}/.well-known/jwks.json"
- self._authorize_url = f"{self._portal_url}/oauth/authorize"
- self._token_url = f"{self._portal_url}/api/oauth/token"
- # PyJWKClient handles cache + stale-while-revalidate semantics.
- self._jwks = PyJWKClient(
- self._jwks_url,
- cache_keys=True,
- lifespan=_JWKS_CACHE_SECONDS,
- )
-
- # ---------------- start_login -------------------------------------
-
- def start_login(self, *, redirect_uri: str) -> LoginStart:
- # Validate redirect_uri shape early to surface misconfiguration before
- # the user is bounced to Portal and gets an opaque error.
- parsed = urllib.parse.urlparse(redirect_uri)
- if parsed.scheme not in ("https", "http"):
- raise ProviderError(f"redirect_uri must be http(s), got {redirect_uri!r}")
- if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1"):
- raise ProviderError(
- f"redirect_uri may only use http:// for localhost/127.0.0.1, "
- f"got {redirect_uri!r}"
- )
-
- code_verifier = _b64url_no_pad(secrets.token_bytes(64)) # ~86 chars
- code_challenge = _b64url_no_pad(hashlib.sha256(code_verifier.encode()).digest())
- state = _b64url_no_pad(secrets.token_bytes(32))
-
- params = {
- "response_type": "code",
- "client_id": self._client_id,
- "redirect_uri": redirect_uri,
- "scope": _SCOPE,
- "state": state,
- "code_challenge": code_challenge,
- "code_challenge_method": "S256",
- }
- authorize_url = f"{self._authorize_url}?{urllib.parse.urlencode(params)}"
- return LoginStart(
- authorize_url=authorize_url,
- state=state,
- code_verifier=code_verifier,
- )
-
- # ---------------- complete_login ----------------------------------
-
- def complete_login(
- self,
- *,
- code: str,
- code_verifier: str,
- redirect_uri: str,
- ) -> Session:
- try:
- response = httpx.post(
- self._token_url,
- data={
- "grant_type": "authorization_code",
- "code": code,
- "redirect_uri": redirect_uri,
- "client_id": self._client_id,
- "code_verifier": code_verifier,
- },
- headers={"Accept": "application/json"},
- timeout=10.0,
- )
- except httpx.RequestError as exc:
- raise ProviderError(f"Portal token endpoint unreachable: {exc}") from exc
-
- if response.status_code == 400:
- # Contract: invalid_code, invalid_grant, redirect_uri_mismatch all
- # surface here.
- body = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
- error_code = body.get("error", "invalid_request")
- raise InvalidCodeError(f"Portal rejected code: {error_code}")
- if response.status_code != 200:
- raise ProviderError(
- f"Portal token endpoint returned {response.status_code}: "
- f"{response.text[:200]}"
- )
-
- payload = response.json()
- access_token = payload.get("access_token")
- if not access_token:
- raise ProviderError("Portal token response missing access_token")
- # Contract V1: no refresh token. If one is present, we deliberately
- # ignore it (forward-compat: a future Portal can issue one without
- # forcing us to ship a new Hermes version).
- token_type = payload.get("token_type", "").lower()
- if token_type and token_type != "bearer":
- raise ProviderError(f"unexpected token_type={token_type!r}")
-
- claims = self._verify_jwt(access_token)
- return self._session_from_claims(access_token, claims)
-
- # ---------------- refresh_session ---------------------------------
-
- def refresh_session(self, *, refresh_token: str) -> Session:
- # Contract V1 has no refresh tokens. The cookie machinery may still
- # call this if a future Portal change starts issuing them; for now
- # we always force re-auth.
- raise RefreshExpiredError(
- "Nous Portal does not issue refresh tokens in OAuth contract v1; "
- "user must re-authenticate."
- )
-
- # ---------------- verify_session (called per request) -------------
-
- def verify_session(self, *, access_token: str) -> Session:
- claims = self._verify_jwt(access_token)
- return self._session_from_claims(access_token, claims)
-
- # ---------------- internals --------------------------------------
-
- def _verify_jwt(self, access_token: str) -> dict:
- try:
- signing_key = self._jwks.get_signing_key_from_jwt(access_token)
- except jwt.PyJWKClientError as exc:
- raise ProviderError(f"JWKS lookup failed: {exc}") from exc
-
- try:
- claims = jwt.decode(
- access_token,
- signing_key.key,
- algorithms=["RS256"],
- # Contract: audience is the bare client_id for agent:* clients.
- audience=self._client_id,
- # Issuer is the Portal base URL. Pin it.
- issuer=self._portal_url,
- options={"require": ["exp", "iat", "aud", "iss", "sub"]},
- )
- except jwt.ExpiredSignatureError as exc:
- raise InvalidCodeError(f"access token expired: {exc}") from exc
- except jwt.InvalidTokenError as exc:
- raise ProviderError(f"access token verification failed: {exc}") from exc
-
- # Defense-in-depth: contract recommends verifying agent_instance_id
- # matches our configured client_id suffix. (Doc says all client_id-shaped
- # claims should be cross-checked.)
- token_instance_id = claims.get("agent_instance_id")
- if token_instance_id and token_instance_id != self._agent_instance_id:
- raise ProviderError(
- f"agent_instance_id mismatch: token={token_instance_id!r} "
- f"vs configured={self._agent_instance_id!r}"
- )
-
- # Tolerant contract-version check (see OQ-C2).
- contract_version = claims.get("oauth_contract_version")
- if contract_version is None:
- _log.warning(
- "Nous Portal token missing oauth_contract_version claim "
- "(contract says it should be %d); proceeding anyway.",
- _EXPECTED_CONTRACT_VERSION,
- )
- elif contract_version != _EXPECTED_CONTRACT_VERSION:
- raise ProviderError(
- f"unsupported oauth_contract_version={contract_version!r}, "
- f"expected {_EXPECTED_CONTRACT_VERSION}"
- )
-
- return claims
-
- def _session_from_claims(self, access_token: str, claims: dict) -> Session:
- # Contract V1 emits no email / display_name. We surface the user_id
- # (truncated) in the AuthWidget; Session keeps the fields for forward
- # compatibility but populates them with empty strings.
- user_id = str(claims.get("sub", ""))
- if not user_id:
- raise ProviderError("token missing 'sub' (user_id) claim")
- return Session(
- provider_name=self.name,
- user_id=user_id,
- email="",
- display_name="",
- access_token=access_token,
- refresh_token="", # contract V1: no refresh
- expires_at=int(claims["exp"]),
- extra={
- "org_id": claims.get("org_id"),
- "agent_instance_id": claims.get("agent_instance_id"),
- "scope": claims.get("scope"),
- },
- )
-```
-
-**Step 4: Tests.**
-
-The test suite covers four shapes:
-
-1. **Plugin registration gating** — env unset / malformed `client_id` → no registration.
-2. **`start_login` shape** — generates correct `code_verifier` (43-128 chars), `code_challenge` (S256 of verifier), and authorize URL with all required params.
-3. **`complete_login` happy path + error mapping** — httpx mocked. 200 with valid JWT → `Session`; 400 → `InvalidCodeError`; 500 → `ProviderError`; 200 without `access_token` → `ProviderError`.
-4. **`verify_session` token verification** — uses an RSA keypair generated in `conftest`; signs a JWT with the expected claims and verifies it round-trips. Negative cases: wrong `aud`, wrong `iss`, missing `sub`, `agent_instance_id` mismatch, `oauth_contract_version=2` rejection, missing `oauth_contract_version` warning.
-
-Skip `refresh_session` happy path — it has none; one test asserts `RefreshExpiredError` is always raised.
-
-### Task 4.2: Smoke test against staging Portal (`portal.rewbs.uk`)
-
-**Objective:** Manual end-to-end run against the staging Portal before considering Phase 4 done. Not a CI gate; the OAuth flow needs a real browser. Document the checklist:
-
-1. Provision a fake Fly app pointing to localhost (e.g. via `fly apps create` + DNS override) OR — easier — patch the Portal's `flyAppName → canonicalRedirectUri` to allow `http://localhost:8080/auth/callback`.
-2. Set `HERMES_DASHBOARD_OAUTH_CLIENT_ID=agent:{instance_id}`, `HERMES_DASHBOARD_PORTAL_URL=https://portal.rewbs.uk`.
-3. `hermes dashboard --host 0.0.0.0 --port 8080`.
-4. Open `http://localhost:8080/`. Expect bounce to `/login`. Click "Continue with Nous Research".
-5. Expect Portal `/oauth/authorize` page; sign in; consent.
-6. Expect redirect to `/auth/callback?code=…&state=…`; cookie set; redirect to `/`.
-7. Open dev tools; `/api/auth/me` returns user_id; `/api/pty` ticket-auth path works (Phase 5).
-8. Wait 900 s; expect 401 on next mutation; expect SPA to redirect to `/login` (Phase 6 v2).
-
-### Phase 4 v1 (rejected — preserved for archeology)
-
-The v1 draft below assumed (a) a static `hermes-dashboard` OAuth client, (b) `signing_mode=userinfo` with JWKS as a future upgrade, and (c) refresh tokens. All three were reversed by the contract; see Contract Anchor above.
-
-
-
-### Task 4.1: Plugin skeleton + provider class
-
-**Objective:** A `plugins/dashboard-auth-nous/` directory with `plugin.yaml`, `__init__.py` that imports and registers the provider, and `provider.py` that implements the OAuth dance.
-
-**Files:**
-- Create: `plugins/dashboard-auth-nous/plugin.yaml`
-- Create: `plugins/dashboard-auth-nous/__init__.py`
-- Create: `plugins/dashboard-auth-nous/provider.py`
-- Create: `plugins/dashboard-auth-nous/test_provider.py`
-
-**Step 1: Plugin manifest.**
-
-```yaml
-# plugins/dashboard-auth-nous/plugin.yaml
-name: dashboard-auth-nous
-version: 1.0.0
-description: "Default dashboard auth provider for Hermes — OAuth via Nous Portal (portal.nousresearch.com)."
-author: NousResearch
-kind: dashboard_auth
-pip_dependencies:
- - httpx
- - pyjwt[crypto]
-```
-
-**Step 2: Plugin entry point.**
-
-```python
-# plugins/dashboard-auth-nous/__init__.py
-"""Default dashboard-auth provider — Nous Portal OAuth.
-
-Auto-loaded by the plugin system at startup. Registers the provider into
-the dashboard-auth registry via the plugin context hook.
-"""
-from plugins.dashboard_auth_nous.provider import NousDashboardAuthProvider
-
-
-def register(ctx):
- """Plugin entry — called by the plugin loader.
-
- Honours these env vars (typically left unset; the defaults are correct
- for production Nous Portal):
-
- HERMES_DASHBOARD_AUTH_NOUS_PORTAL_URL — override portal base URL
- (default: https://portal.nousresearch.com)
- HERMES_DASHBOARD_AUTH_NOUS_CLIENT_ID — override OAuth client_id
- (default: hermes-dashboard)
- HERMES_DASHBOARD_AUTH_NOUS_SIGNING_MODE — "userinfo" (default) or "jwks"
- """
- ctx.register_dashboard_auth_provider(NousDashboardAuthProvider())
-```
-
-**Step 3: Provider implementation.**
-
-```python
-# plugins/dashboard-auth-nous/provider.py
-"""NousDashboardAuthProvider — authorization-code + PKCE against Nous Portal."""
-from __future__ import annotations
-
-import base64
-import hashlib
-import logging
-import os
-import secrets
-import time
-import urllib.parse
-from typing import Optional
-
-import httpx
-
-from hermes_cli.dashboard_auth.base import (
- DashboardAuthProvider,
- Session,
- LoginStart,
- InvalidCodeError,
- ProviderError,
- RefreshExpiredError,
-)
-
-_log = logging.getLogger(__name__)
-
-_DEFAULT_PORTAL_URL = "https://portal.nousresearch.com"
-_DEFAULT_CLIENT_ID = "hermes-dashboard"
-_DEFAULT_SIGNING_MODE = "userinfo" # or "jwks"
-
-# Scopes:
-# openid profile email — identity claims
-# inference:invoke tool:invoke — Hermes inherits these from Portal's default
-# scope set, so the agent can use them later.
-_SCOPE = "openid profile email inference:invoke tool:invoke"
-
-
-def _b64url_no_pad(raw: bytes) -> str:
- return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
-
-
-def _make_pkce_pair() -> tuple[str, str]:
- verifier = _b64url_no_pad(secrets.token_bytes(64))
- challenge = _b64url_no_pad(hashlib.sha256(verifier.encode()).digest())
- return verifier, challenge
-
-
-class NousDashboardAuthProvider(DashboardAuthProvider):
- name = "nous"
- display_name = "Nous Portal"
-
- def __init__(self):
- self._portal_url = (
- os.getenv("HERMES_DASHBOARD_AUTH_NOUS_PORTAL_URL")
- or _DEFAULT_PORTAL_URL
- ).rstrip("/")
- self._client_id = (
- os.getenv("HERMES_DASHBOARD_AUTH_NOUS_CLIENT_ID")
- or _DEFAULT_CLIENT_ID
- )
- self._signing_mode = (
- os.getenv("HERMES_DASHBOARD_AUTH_NOUS_SIGNING_MODE")
- or _DEFAULT_SIGNING_MODE
- )
- # Simple 60s memoisation for verify_session in userinfo mode so we
- # don't hammer Portal on every browser request.
- self._verify_cache: dict[str, tuple[int, Session]] = {}
-
- # ---- OAuth ---------------------------------------------------------
-
- def start_login(self, *, redirect_uri: str) -> LoginStart:
- verifier, challenge = _make_pkce_pair()
- state = _b64url_no_pad(secrets.token_bytes(24))
- params = {
- "response_type": "code",
- "client_id": self._client_id,
- "redirect_uri": redirect_uri,
- "scope": _SCOPE,
- "state": state,
- "code_challenge": challenge,
- "code_challenge_method": "S256",
- }
- auth_url = f"{self._portal_url}/oauth/authorize?" + urllib.parse.urlencode(params)
- return LoginStart(
- redirect_url=auth_url,
- cookie_payload={
- # Caller (routes.py) prepends `provider=nous;`
- "hermes_session_pkce": f"state={state};verifier={verifier}",
- },
- )
-
- def complete_login(
- self, *, code, state, code_verifier, redirect_uri,
- ) -> Session:
- token_url = f"{self._portal_url}/api/oauth/token"
- try:
- with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
- r = client.post(
- token_url,
- data={
- "grant_type": "authorization_code",
- "code": code,
- "code_verifier": code_verifier,
- "client_id": self._client_id,
- "redirect_uri": redirect_uri,
- },
- headers={"Accept": "application/json"},
- )
- except httpx.HTTPError as e:
- raise ProviderError(f"Portal token endpoint unreachable: {e}")
-
- if r.status_code == 400:
- raise InvalidCodeError(f"Portal rejected code: {r.text}")
- if r.status_code >= 500:
- raise ProviderError(f"Portal token endpoint returned {r.status_code}")
- if r.status_code != 200:
- raise InvalidCodeError(f"Portal token exchange failed: HTTP {r.status_code} {r.text}")
-
- body = r.json()
- return self._session_from_token_response(body)
-
- def verify_session(self, *, access_token: str) -> Optional[Session]:
- # Cache hit?
- now = int(time.time())
- cached = self._verify_cache.get(access_token)
- if cached and cached[0] > now:
- return cached[1]
-
- if self._signing_mode == "jwks":
- return self._verify_via_jwks(access_token)
- return self._verify_via_userinfo(access_token)
-
- def refresh_session(self, *, refresh_token: str) -> Session:
- token_url = f"{self._portal_url}/api/oauth/token"
- try:
- with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
- r = client.post(
- token_url,
- data={
- "grant_type": "refresh_token",
- "refresh_token": refresh_token,
- "client_id": self._client_id,
- },
- headers={"Accept": "application/json"},
- )
- except httpx.HTTPError as e:
- raise ProviderError(f"Portal refresh endpoint unreachable: {e}")
-
- if r.status_code in (400, 401):
- # Portal indicates the refresh token is dead.
- raise RefreshExpiredError(f"Portal rejected refresh: {r.text}")
- if r.status_code != 200:
- raise ProviderError(f"Portal refresh failed: HTTP {r.status_code} {r.text}")
-
- return self._session_from_token_response(r.json())
-
- def revoke_session(self, *, refresh_token: str) -> None:
- # Portal's existing API exposes revocation through Account Service's
- # /api/account/oauth/sessions delete-by-id route. The refresh token
- # itself isn't accepted as a revoke key. Best-effort: we POST it to
- # the token endpoint with grant_type=refresh_token, then discard the
- # response — this consumes the refresh and rotates it server-side,
- # which is sufficient for "this old refresh is now dead". A future
- # iteration can add a dedicated /api/oauth/revoke endpoint.
- try:
- with httpx.Client(timeout=httpx.Timeout(5.0)) as client:
- client.post(
- f"{self._portal_url}/api/oauth/token",
- data={
- "grant_type": "refresh_token",
- "refresh_token": refresh_token,
- "client_id": self._client_id,
- },
- )
- except httpx.HTTPError:
- # Best-effort; log but don't raise.
- _log.warning("dashboard-auth-nous: revoke best-effort call failed", exc_info=True)
-
- # ---- internals -----------------------------------------------------
-
- def _session_from_token_response(self, body: dict) -> Session:
- access_token = body.get("access_token", "")
- refresh_token = body.get("refresh_token", "")
- if not access_token or not refresh_token:
- raise ProviderError("Portal token response missing tokens")
-
- # Decode the JWT payload (no signature verification here — only used
- # to extract claims for the Session dataclass; signature verification
- # happens in verify_session).
- claims = self._decode_jwt_payload_unsafe(access_token)
- now = int(time.time())
- expires_at = int(claims.get("exp", now + 3600))
- return Session(
- user_id=str(claims.get("sub", "")),
- email=str(claims.get("email", "")),
- display_name=str(claims.get("name", "") or claims.get("email", "")),
- org_id=str(claims.get("org_id", "")),
- provider=self.name,
- expires_at=expires_at,
- access_token=access_token,
- refresh_token=refresh_token,
- )
-
- def _decode_jwt_payload_unsafe(self, token: str) -> dict:
- """Decode the JWT payload without verification. Used only to extract
- claims for the Session dataclass; signature verification is the job
- of ``verify_session``."""
- try:
- header_b64, payload_b64, _sig = token.split(".")
- padded = payload_b64 + "=" * (-len(payload_b64) % 4)
- import json
- return json.loads(base64.urlsafe_b64decode(padded))
- except Exception as e:
- raise ProviderError(f"Cannot decode JWT payload: {e}")
-
- def _verify_via_userinfo(self, access_token: str) -> Optional[Session]:
- """Use Portal's /api/oauth/account as a userinfo endpoint.
-
- Cached for 60 seconds keyed on the access_token. Returns None if
- Portal returns 401 (expired/invalid token).
- """
- try:
- with httpx.Client(timeout=httpx.Timeout(10.0)) as client:
- r = client.get(
- f"{self._portal_url}/api/oauth/account",
- headers={
- "Authorization": f"Bearer {access_token}",
- "Accept": "application/json",
- },
- )
- except httpx.HTTPError as e:
- raise ProviderError(f"Portal /api/oauth/account unreachable: {e}")
-
- if r.status_code == 401:
- return None
- if r.status_code >= 500:
- raise ProviderError(f"Portal /api/oauth/account returned {r.status_code}")
- if r.status_code != 200:
- return None
-
- body = r.json()
- # Cache the verified Session for 60 seconds.
- claims = self._decode_jwt_payload_unsafe(access_token)
- now = int(time.time())
- sess = Session(
- user_id=str(body.get("userId", claims.get("sub", ""))),
- email=str(body.get("email", claims.get("email", ""))),
- display_name=str(body.get("name", claims.get("name", "") or body.get("email", ""))),
- org_id=str(body.get("orgId", claims.get("org_id", ""))),
- provider=self.name,
- expires_at=int(claims.get("exp", now + 3600)),
- access_token=access_token,
- refresh_token="", # not returned on verify
- )
- self._verify_cache[access_token] = (now + 60, sess)
- return sess
-
- def _verify_via_jwks(self, access_token: str) -> Optional[Session]:
- """Verify the JWT signature against Portal's JWKS."""
- try:
- import jwt as _jwt
- from jwt import PyJWKClient
- except ImportError:
- raise ProviderError("pyjwt[crypto] not installed — falling back to userinfo")
-
- jwks_url = f"{self._portal_url}/.well-known/jwks.json"
- try:
- client = PyJWKClient(jwks_url)
- signing_key = client.get_signing_key_from_jwt(access_token)
- claims = _jwt.decode(
- access_token,
- signing_key.key,
- algorithms=["RS256"],
- audience=f"hermes-cli:{self._client_id}",
- issuer=self._portal_url,
- )
- except _jwt.ExpiredSignatureError:
- return None
- except _jwt.InvalidTokenError as e:
- _log.warning("dashboard-auth-nous: JWT validation failed: %s", e)
- return None
- except Exception as e:
- raise ProviderError(f"JWKS verify failed: {e}")
-
- now = int(time.time())
- sess = Session(
- user_id=str(claims.get("sub", "")),
- email=str(claims.get("email", "")),
- display_name=str(claims.get("name", "") or claims.get("email", "")),
- org_id=str(claims.get("org_id", "")),
- provider=self.name,
- expires_at=int(claims.get("exp", now + 3600)),
- access_token=access_token,
- refresh_token="",
- )
- self._verify_cache[access_token] = (now + 60, sess)
- return sess
-```
-
-**Step 4: Unit tests for the provider.**
-
-```python
-# plugins/dashboard-auth-nous/test_provider.py
-"""Unit tests for the Nous dashboard-auth provider. Mocks Portal endpoints."""
-import json
-import time
-import pytest
-import respx
-import httpx
-
-from hermes_cli.dashboard_auth.base import (
- assert_protocol_compliance, InvalidCodeError, ProviderError, RefreshExpiredError,
-)
-from plugins.dashboard_auth_nous.provider import NousDashboardAuthProvider
-
-
-def test_protocol_compliance():
- assert_protocol_compliance(NousDashboardAuthProvider) is None
-
-
-def test_start_login_returns_authorize_url():
- p = NousDashboardAuthProvider()
- ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
- assert ls.redirect_url.startswith("https://portal.nousresearch.com/oauth/authorize?")
- assert "response_type=code" in ls.redirect_url
- assert "client_id=hermes-dashboard" in ls.redirect_url
- assert "code_challenge_method=S256" in ls.redirect_url
- assert "state=" in ls.redirect_url
- assert "scope=" in ls.redirect_url
- # State+verifier go into the cookie payload
- pkce = ls.cookie_payload["hermes_session_pkce"]
- assert "state=" in pkce
- assert "verifier=" in pkce
-
-
-@respx.mock
-def test_complete_login_happy_path():
- p = NousDashboardAuthProvider()
- # Forge a JWT-shaped access_token: header.payload.sig with a real payload
- import base64
- payload = {
- "sub": "u_123", "email": "u@nous.com", "name": "U Nous",
- "org_id": "org_x", "exp": int(time.time()) + 3600,
- "aud": "hermes-cli:hermes-dashboard",
- }
- payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode()
- access_token = f"hdr.{payload_b64}.sig"
-
- respx.post("https://portal.nousresearch.com/api/oauth/token").mock(
- return_value=httpx.Response(200, json={
- "access_token": access_token, "refresh_token": "rt_xyz",
- "token_type": "Bearer", "expires_in": 3600,
- "scope": "openid profile email inference:invoke tool:invoke",
- })
- )
-
- sess = p.complete_login(code="auth_code", state="s", code_verifier="v",
- redirect_uri="https://x.fly.dev/auth/callback")
- assert sess.user_id == "u_123"
- assert sess.email == "u@nous.com"
- assert sess.display_name == "U Nous"
- assert sess.org_id == "org_x"
- assert sess.access_token == access_token
- assert sess.refresh_token == "rt_xyz"
- assert sess.provider == "nous"
-
-
-@respx.mock
-def test_complete_login_invalid_code_raises():
- p = NousDashboardAuthProvider()
- respx.post("https://portal.nousresearch.com/api/oauth/token").mock(
- return_value=httpx.Response(400, json={"error": "invalid_grant"})
- )
- with pytest.raises(InvalidCodeError):
- p.complete_login(code="bad", state="s", code_verifier="v",
- redirect_uri="https://x.fly.dev/auth/callback")
-
-
-@respx.mock
-def test_complete_login_portal_5xx_raises_provider_error():
- p = NousDashboardAuthProvider()
- respx.post("https://portal.nousresearch.com/api/oauth/token").mock(
- return_value=httpx.Response(503, json={"error": "service_unavailable"})
- )
- with pytest.raises(ProviderError):
- p.complete_login(code="c", state="s", code_verifier="v",
- redirect_uri="https://x.fly.dev/auth/callback")
-
-
-@respx.mock
-def test_verify_session_userinfo_mode_happy():
- p = NousDashboardAuthProvider()
- import base64
- payload = {"sub": "u1", "email": "u@x.com", "name": "U", "org_id": "o",
- "exp": int(time.time()) + 3600}
- token = f"hdr.{base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()}.sig"
- respx.get("https://portal.nousresearch.com/api/oauth/account").mock(
- return_value=httpx.Response(200, json={
- "userId": "u1", "email": "u@x.com", "name": "U", "orgId": "o",
- })
- )
- sess = p.verify_session(access_token=token)
- assert sess is not None
- assert sess.user_id == "u1"
-
-
-@respx.mock
-def test_verify_session_userinfo_401_returns_none():
- p = NousDashboardAuthProvider()
- import base64
- payload = {"sub": "u1", "exp": int(time.time()) + 3600}
- token = f"hdr.{base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()}.sig"
- respx.get("https://portal.nousresearch.com/api/oauth/account").mock(
- return_value=httpx.Response(401)
- )
- assert p.verify_session(access_token=token) is None
-
-
-@respx.mock
-def test_refresh_session_happy():
- p = NousDashboardAuthProvider()
- import base64
- payload = {"sub": "u1", "email": "u@x.com", "name": "U", "org_id": "o",
- "exp": int(time.time()) + 3600}
- new_token = f"hdr.{base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()}.sig"
- respx.post("https://portal.nousresearch.com/api/oauth/token").mock(
- return_value=httpx.Response(200, json={
- "access_token": new_token, "refresh_token": "rt_new",
- "token_type": "Bearer", "expires_in": 3600,
- })
- )
- sess = p.refresh_session(refresh_token="rt_old")
- assert sess.access_token == new_token
- assert sess.refresh_token == "rt_new"
-
-
-@respx.mock
-def test_refresh_session_expired_raises():
- p = NousDashboardAuthProvider()
- respx.post("https://portal.nousresearch.com/api/oauth/token").mock(
- return_value=httpx.Response(400, json={"error": "invalid_grant"})
- )
- with pytest.raises(RefreshExpiredError):
- p.refresh_session(refresh_token="rt_dead")
-```
-
-**Step 5: Run, verify pass.**
-
-```bash
-scripts/run_tests.sh plugins/dashboard-auth-nous/test_provider.py -v
-```
-
-Adds `respx` to the test deps if not already present.
-
-**Step 6: Commit.**
-
-```bash
-git add plugins/dashboard-auth-nous/
-git commit -m "feat(dashboard-auth-nous): default OAuth provider for Nous Portal (authcode + PKCE)"
-```
-
-### Task 4.2: Integrate plugin discovery for dashboard-auth
-
-**Objective:** Confirm the existing plugin loader picks up `plugins/dashboard-auth-nous/` on `hermes dashboard` startup. The plugin manager should already auto-discover any plugin with a `plugin.yaml`; this task verifies and locks that behavior with a test.
-
-**Files:**
-- Modify (only if needed): `hermes_cli/plugins.py` — confirm the loader scans `plugins/` for built-in plugins. (It already does — `plugins/memory/honcho/` and `plugins/image_gen/openai/` are auto-loaded.)
-- Test: `tests/hermes_cli/test_dashboard_auth_plugin_discovery.py`
-
-**Step 1: Test the discovery integration.**
-
-```python
-# tests/hermes_cli/test_dashboard_auth_plugin_discovery.py
-"""When the dashboard starts, the bundled Nous auth provider must auto-register."""
-from hermes_cli.dashboard_auth import clear_providers, get_provider
-from hermes_cli.plugins import PluginManager
-
-
-def test_bundled_nous_auth_plugin_is_discovered_and_registered(tmp_path, monkeypatch):
- clear_providers()
- # Use a real PluginManager pointed at the repo's plugins/ dir.
- mgr = PluginManager()
- mgr.discover_and_load()
- # Either the Nous provider OR no provider is acceptable in CI where
- # plugins might be opt-in; assert that if the plugin is in the registry,
- # it speaks the correct portal URL.
- nous = get_provider("nous")
- if nous is not None:
- assert nous.display_name == "Nous Portal"
- clear_providers()
-```
-
-**Step 2: Run.**
-
-```bash
-scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_plugin_discovery.py -v
-```
-
-**Step 3: Commit.**
-
-```bash
-git add tests/hermes_cli/test_dashboard_auth_plugin_discovery.py
-git commit -m "test(dashboard-auth): verify Nous provider auto-loads through plugin discovery"
-```
-
-### Phase 4 Exit Gate
-
-```bash
-scripts/run_tests.sh plugins/dashboard-auth-nous/ tests/hermes_cli/test_dashboard_auth_plugin_discovery.py -v
-```
-
-Integration smoke (requires staging Portal access — operator-run, not CI):
-
-1. Set `HERMES_DASHBOARD_AUTH_NOUS_PORTAL_URL=https://staging.portal.nousresearch.com`.
-2. `hermes dashboard --host 0.0.0.0 --port 9119`
-3. Visit `http://localhost:9119/login` → click "Sign in with Nous Portal" → redirected to staging Portal → approve → redirected back to `/`.
-4. `/api/auth/me` returns `{user_id, email, display_name, org_id, provider: "nous", expires_at}`.
-
-**Hard dependency on cross-repo work:** Phase 4 cannot pass the integration smoke until the Portal-side items in the Cross-Repo Coordination Checklist land. The unit tests (mocked Portal endpoints) pass independently.
-
----
-
-## Phase 5 — WebSocket Ticket Auth (`--tui` Support in Gated Mode)
-
-**Goal:** The PTY/WS endpoints (`/api/pty`, `/api/ws`, `/api/pub`, `/api/events`) currently authenticate via `?token=<_SESSION_TOKEN>`. In gated mode the SPA has cookies, not the token. This phase adds a `/api/auth/ws-ticket` endpoint that mints a short-lived single-use ticket from a valid cookie, and updates each WS endpoint to accept either the legacy token (loopback mode) OR a ticket (gated mode).
-
-### Task 5.1: Ticket store + mint endpoint
-
-**Objective:** A small in-memory ticket store with TTL + single-use semantics, and an authenticated endpoint that mints a ticket for the cookie's session.
-
-**Files:**
-- Create: `hermes_cli/dashboard_auth/ws_tickets.py`
-- Modify: `hermes_cli/dashboard_auth/routes.py` — add `/api/auth/ws-ticket`.
-- Create: `tests/hermes_cli/test_dashboard_auth_ws_tickets.py`
-
-**Step 1: Write failing test.**
-
-```python
-# tests/hermes_cli/test_dashboard_auth_ws_tickets.py
-import time
-import pytest
-from hermes_cli.dashboard_auth.ws_tickets import (
- mint_ticket, consume_ticket, TicketInvalid,
-)
-
-
-def test_mint_and_consume_round_trip():
- ticket = mint_ticket(user_id="u1", provider="nous")
- # Must be opaque token (urlsafe base64ish), reasonable length
- assert len(ticket) >= 32
- info = consume_ticket(ticket)
- assert info["user_id"] == "u1"
- assert info["provider"] == "nous"
-
-
-def test_ticket_is_single_use():
- ticket = mint_ticket(user_id="u1", provider="stub")
- consume_ticket(ticket)
- with pytest.raises(TicketInvalid, match="already consumed|unknown"):
- consume_ticket(ticket)
-
-
-def test_expired_ticket_rejected(monkeypatch):
- real_time = time.time
- t0 = real_time()
- monkeypatch.setattr("hermes_cli.dashboard_auth.ws_tickets.time.time",
- lambda: t0)
- ticket = mint_ticket(user_id="u1", provider="stub")
- # Jump forward 31 seconds (TTL = 30s)
- monkeypatch.setattr("hermes_cli.dashboard_auth.ws_tickets.time.time",
- lambda: t0 + 31)
- with pytest.raises(TicketInvalid, match="expired"):
- consume_ticket(ticket)
-
-
-def test_unknown_ticket_rejected():
- with pytest.raises(TicketInvalid, match="unknown"):
- consume_ticket("nope-never-minted")
-```
-
-**Step 2: Implement.**
-
-```python
-# hermes_cli/dashboard_auth/ws_tickets.py
-"""Short-lived single-use tickets for WS-upgrade auth in gated mode.
-
-Browsers cannot set Authorization on a WebSocket upgrade. In loopback
-mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because
-the token comes from the injected SPA script. In gated mode there is no
-injected token — the SPA gets a fresh ticket via the authenticated REST
-endpoint ``/api/auth/ws-ticket`` and passes that as ``?ticket=`` on the
-WS upgrade.
-
-Tickets are single-use, TTL = 30 seconds. In-memory; the dashboard is a
-single process so no distributed coordination is needed.
-"""
-from __future__ import annotations
-
-import secrets
-import threading
-import time
-from typing import Optional
-
-_TTL_SECONDS = 30
-_lock = threading.Lock()
-_tickets: dict[str, tuple[int, dict]] = {} # ticket -> (expires_at, info)
-
-
-class TicketInvalid(Exception):
- """Ticket missing, expired, or already consumed."""
-
-
-def mint_ticket(*, user_id: str, provider: str) -> str:
- """Generate a one-shot ticket bound to this user identity."""
- ticket = secrets.token_urlsafe(32)
- info = {"user_id": user_id, "provider": provider, "minted_at": int(time.time())}
- with _lock:
- _tickets[ticket] = (int(time.time()) + _TTL_SECONDS, info)
- _gc_expired_locked()
- return ticket
-
-
-def consume_ticket(ticket: str) -> dict:
- """Validate and consume. Raises TicketInvalid on missing/expired/used."""
- now = int(time.time())
- with _lock:
- entry = _tickets.pop(ticket, None)
- if entry is None:
- raise TicketInvalid(f"unknown ticket: {ticket[:8]}…")
- expires_at, info = entry
- if expires_at < now:
- raise TicketInvalid("expired")
- return info
-
-
-def _gc_expired_locked() -> None:
- now = int(time.time())
- expired = [t for t, (exp, _) in _tickets.items() if exp < now]
- for t in expired:
- _tickets.pop(t, None)
-```
-
-**Step 3: Add the mint endpoint.**
-
-In `hermes_cli/dashboard_auth/routes.py`:
-
-```python
-from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
-
-
-@router.post("/api/auth/ws-ticket", name="auth_ws_ticket")
-async def api_auth_ws_ticket(request: Request):
- """Mint a short-lived WS ticket for the authenticated session."""
- sess = getattr(request.state, "session", None)
- if sess is None:
- # Middleware should already have rejected, but check defensively.
- raise HTTPException(status_code=401, detail="Unauthorized")
- ticket = mint_ticket(user_id=sess.user_id, provider=sess.provider)
- audit_log(AuditEvent.WS_TICKET_MINTED, provider=sess.provider,
- user_id=sess.user_id, ip=_client_ip(request))
- return {"ticket": ticket, "ttl_seconds": 30}
-```
-
-**Step 4: Run, verify pass.**
-
-```bash
-scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_ws_tickets.py -v
-```
-
-**Step 5: Commit.**
-
-```bash
-git add hermes_cli/dashboard_auth/ws_tickets.py hermes_cli/dashboard_auth/routes.py tests/hermes_cli/test_dashboard_auth_ws_tickets.py
-git commit -m "feat(dashboard-auth): single-use WS tickets for cookie→ws bridge"
-```
-
-### Task 5.2: Update WS endpoints to accept tickets
-
-**Objective:** `/api/pty`, `/api/ws`, `/api/pub`, `/api/events` accept either `?token=<_SESSION_TOKEN>` (loopback) or `?ticket=` (gated). In gated mode the legacy token path is rejected.
-
-**Files:**
-- Modify: `hermes_cli/web_server.py:3520` (`/api/ws`), `:3562`, `:3591`, plus `/api/pty` (~line 3264).
-
-**Step 1: Write failing test.**
-
-Append to `tests/hermes_cli/test_dashboard_auth_middleware.py`:
-
-```python
-def test_ws_accepts_ticket_in_gated_mode(gated_app):
- # Authenticate via the stub round trip, then mint a ticket.
- r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
- state = r1.headers["location"].split("state=")[1]
- r2 = gated_app.get(f"/auth/callback?code=stub_code&state={state}",
- follow_redirects=False)
- assert r2.status_code == 302
-
- rt = gated_app.post("/api/auth/ws-ticket")
- assert rt.status_code == 200
- ticket = rt.json()["ticket"]
-
- # The PTY endpoint should accept ?ticket=... in gated mode.
- # Use the WS test client. Don't actually do the PTY handshake — the
- # auth gate is what we're testing.
- with gated_app.websocket_connect(f"/api/pty?ticket={ticket}") as ws:
- # Either we read a banner or the server closes cleanly because no
- # tty-write follows. Both prove the auth gate accepted the ticket.
- pass
-
-
-def test_ws_rejects_legacy_token_in_gated_mode(gated_app):
- # Even if you somehow knew the legacy _SESSION_TOKEN, gated mode
- # must NOT accept it.
- from hermes_cli import web_server
- with pytest.raises(Exception): # WSException / ConnectionClosed
- with gated_app.websocket_connect(
- f"/api/pty?token={web_server._SESSION_TOKEN}"
- ):
- pass
-
-
-def test_ws_rejects_consumed_ticket(gated_app):
- r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
- state = r1.headers["location"].split("state=")[1]
- gated_app.get(f"/auth/callback?code=stub_code&state={state}", follow_redirects=False)
- rt = gated_app.post("/api/auth/ws-ticket")
- ticket = rt.json()["ticket"]
-
- # First use — fine
- with gated_app.websocket_connect(f"/api/pty?ticket={ticket}"):
- pass
- # Second use — rejected (single-use)
- with pytest.raises(Exception):
- with gated_app.websocket_connect(f"/api/pty?ticket={ticket}"):
- pass
-```
-
-**Step 2: Update each WS endpoint.**
-
-Pattern for each (refactor the duplicated check into a helper):
-
-```python
-# In hermes_cli/web_server.py — add near the other helpers
-
-def _ws_auth_ok(ws: WebSocket, app_state) -> bool:
- """Validate WS auth in either loopback or gated mode.
-
- Returns True if the ws should be accepted. Caller is responsible for
- closing with the right code if False.
- """
- if getattr(app_state, "auth_required", False):
- ticket = ws.query_params.get("ticket", "")
- if not ticket:
- return False
- from hermes_cli.dashboard_auth.ws_tickets import consume_ticket, TicketInvalid
- try:
- consume_ticket(ticket)
- return True
- except TicketInvalid:
- return False
- token = ws.query_params.get("token", "")
- return hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
-```
-
-Then update each WS handler to use it:
-
-```python
-@app.websocket("/api/ws")
-async def gateway_ws(ws: WebSocket) -> None:
- if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
- await ws.close(code=4403)
- return
- if not _ws_auth_ok(ws, app.state):
- await ws.close(code=4401)
- return
- if not _ws_client_is_allowed(ws):
- await ws.close(code=4403)
- return
- from tui_gateway.ws import handle_ws
- await handle_ws(ws)
-```
-
-Same surgery for `/api/pty`, `/api/pub`, `/api/events`.
-
-**Step 3: SPA-side change.**
-
-The React SPA's WS client must, in `auth_required` mode, fetch a fresh ticket from `/api/auth/ws-ticket` before each connect rather than using `window.__HERMES_SESSION_TOKEN__`.
-
-Files:
-- Modify: `web/src/pages/ChatPage.tsx` — the xterm.js WebSocket connect.
-- Modify: `web/src/lib/api.ts` — add `getWsTicket()` typed wrapper.
-
-```typescript
-// web/src/lib/api.ts — add:
-export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: number }> {
- const r = await fetch('/api/auth/ws-ticket', { method: 'POST', credentials: 'include' });
- if (!r.ok) throw new Error(`ws-ticket: HTTP ${r.status}`);
- return r.json();
-}
-```
-
-```typescript
-// web/src/pages/ChatPage.tsx — replace token usage with:
-const ws_url = window.__HERMES_AUTH_REQUIRED__
- ? `/api/pty?ticket=${encodeURIComponent((await getWsTicket()).ticket)}`
- : `/api/pty?token=${encodeURIComponent(window.__HERMES_SESSION_TOKEN__)}`;
-```
-
-**Step 4: Run, verify pass.**
-
-```bash
-scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_middleware.py -v -k ws
-```
-
-**Step 5: Commit.**
-
-```bash
-git add hermes_cli/web_server.py web/src/pages/ChatPage.tsx web/src/lib/api.ts tests/hermes_cli/test_dashboard_auth_middleware.py
-git commit -m "feat(dashboard-auth): WS ticket auth for /api/pty + /api/ws + /api/pub + /api/events"
-```
-
-### Phase 5 Exit Gate
-
-`hermes dashboard --host 0.0.0.0 --port 9119 --tui` with the stub provider:
-1. Login as before.
-2. Visit `/chat` → xterm.js opens.
-3. The browser's network tab shows `POST /api/auth/ws-ticket` returning 200, immediately followed by `GET /api/pty?ticket=…` upgrading to WS.
-4. The TUI is interactive.
-
-Loopback `hermes dashboard --tui` keeps working without any of the above (legacy token path).
-
----
-
-## Phase 6 — 401-Triggered Re-Authentication (v2 — contract-compliant)
-
-> **Plan v2 rewrite.** The Portal contract V1 does not issue refresh tokens (see Contract Anchor C5). Silent-refresh machinery is replaced by a "401 → redirect to `/login`" UX. The v1 draft is preserved below as "Phase 6 v1 (rejected — preserved for archeology)" so reviewers can see the alternative we explored.
-
-**Goal:** When the access token in `hermes_session_at` expires (15-minute TTL per contract C6), the dashboard cleanly bounces the user back through `/oauth/authorize`. No silent refresh; no UX surprises beyond the single redirect. The behaviour must be identical whether the expiry is detected (a) at the gateway middleware (HTML navigation request) or (b) by an XHR / fetch from the SPA.
-
-### Design overview
-
-Two interception points handle expiry:
-
-1. **`gated_auth_middleware` (HTML navigation requests).** Already in place from Phase 3. When `verify_session` raises `InvalidCodeError("access token expired: …")`, the middleware:
- - clears the `hermes_session_at` cookie,
- - audit-logs `auth.session_expired`,
- - returns `RedirectResponse("/login?next={original_path}", status_code=303)`.
-2. **`/api/*` JSON endpoints.** A new sibling middleware (`gated_api_auth_middleware`) handles XHR fetches: instead of redirecting (which a `fetch()` call cannot follow into the OAuth dance), it returns `401 {"error": "session_expired", "login_url": "/login?next=…"}`. The SPA's global fetch wrapper notices the 401 and triggers a full-page navigation to `login_url`.
-
-This split is canonical OAuth UX — modern SPAs interpret 401 as "your session is gone" and any subsequent decision (re-auth flow choice, where to send the user) is conveyed in the body. The middleware does not return 302 to `/login` for API requests because (a) most browsers' fetch APIs swallow the redirect into the cross-origin OAuth flow opaquely, and (b) returning HTML in response to `Accept: application/json` confuses front-end frameworks.
-
-### Task 6.1: API auth middleware + `session_expired` envelope
-
-**Files:**
-- Modify: `hermes_cli/dashboard_auth/middleware.py` — add `gated_api_auth_middleware`.
-- Modify: `hermes_cli/web_server.py` — wire it in alongside `gated_auth_middleware`.
-- Add: `tests/hermes_cli/test_dashboard_auth_api_401.py`.
-
-**Behavior:**
-- Path matches `/api/*` and **not** the auth allowlist (`/api/auth/providers`, `/api/auth/login`, `/api/auth/callback`).
-- Reads `hermes_session_at` cookie; if absent → `401 {"error": "unauthenticated", "login_url": "/login"}`.
-- If present, calls the provider's `verify_session`. On any exception (`InvalidCodeError` / `ProviderError` / `RefreshExpiredError`) → clear cookie, audit-log, return `401 {"error": "session_expired", "login_url": "/login?next=/"}`. (`next=/` not `next={path}` for API calls — the user wasn't navigating to the API endpoint directly.)
-- On success, stash claims on `request.state.auth_session` and call the route.
-
-**Audit-log events added:**
-- `auth.api_unauthenticated` — `/api/*` request with no cookie.
-- `auth.api_session_expired` — `/api/*` request with expired/invalid cookie.
-
-**WebSocket endpoints (`/api/pty`, `/api/ws`) are NOT covered by this middleware.** They use the ticket-auth flow from Phase 5. A WS upgrade request with an expired access-token cookie should never reach `/api/auth/ws-ticket` (which is an HTTP POST covered by this middleware), so the SPA's ticket-fetch step is the natural failure point.
-
-### Task 6.2: SPA global 401 handler
-
-**Files:**
-- Modify: `dashboard/src/api/client.ts` (or wherever the central fetch wrapper lives — find via grep).
-- Add: `dashboard/src/api/__tests__/sessionExpired.test.ts`.
-
-**Behavior:**
-- Single shared `apiFetch(path, init)` helper used by all SPA code.
-- When `response.status === 401 && response.headers.get("content-type")?.startsWith("application/json")`:
- 1. Parse body.
- 2. If `body.error in ("unauthenticated", "session_expired")`, call `window.location.assign(body.login_url)`. Return a never-resolving promise so the caller's `.then` doesn't fire — the page is going away.
- 3. Otherwise, reject normally (the route returned a domain 401 like "monitor X is read-only for your role").
-- One small UX nicety: before the redirect, the helper sets `sessionStorage.setItem("hermes.lastLocation", window.location.pathname)` so the post-login redirect can land back where the user was. The `/auth/callback` handler reads this and, if it's a same-origin path, uses it as the `next=` value.
-
-### Task 6.3: Remove the `hermes_session_rt` cookie + refresh path
-
-**Files:**
-- Modify: `hermes_cli/dashboard_auth/cookies.py` — drop `set_refresh_cookie`, `clear_refresh_cookie`, `get_refresh_token`.
-- Modify: `hermes_cli/dashboard_auth/routes.py` — `/auth/callback` no longer writes a refresh cookie; `/auth/logout` no longer needs to clear it (it never existed).
-- Modify: `tests/hermes_cli/test_dashboard_auth_cookies.py` — drop the three RT tests; add a regression that `hermes_session_rt` is NOT a cookie name we emit.
-
-**Rationale:** Contract V1 does not issue refresh tokens, so persisting one is dead state. The provider's `refresh_session` raises `RefreshExpiredError` unconditionally; if we wired the middleware to call it, the cookie machinery would observe an empty `refresh_token` and the result would be the same as having no cookie. Keeping the cookie around is just attack surface.
-
-**Forward compatibility:** if Portal later starts issuing refresh tokens, the provider's `complete_login` already ignores them today (line `# Contract V1: no refresh token. If one is present, we deliberately ignore it`). To turn them on later, three things change: cookies.py grows the RT cookie back, provider sets `Session.refresh_token`, middleware adds a "near-expiry → refresh" branch in front of the expired branch. None of those changes break the V1 behavior; they're additive. Document this in the file header.
-
-### Task 6.4: Audit-log + observability
-
-**Files:**
-- Modify: `hermes_cli/dashboard_auth/audit.py` — extend `AuditEvent` enum with `API_UNAUTHENTICATED`, `API_SESSION_EXPIRED` (already covered above for completeness).
-- Modify: `docs/dashboard-auth-operations.md` (new in Phase 7) — document the redirect/401 split + how to read the audit log to debug "users keep getting logged out".
-
-### Phase 6 v1 (rejected — preserved for archeology)
-
-The v1 draft below implemented silent token refresh in the middleware: when the access token was within 60s of expiry, the provider's `refresh_session` would mint a new pair using the stored refresh token, and the user would never see a re-auth screen until the 30-day refresh token itself expired. This is the right design IF refresh tokens exist. They don't in V1 (contract C5), so the entire path is unimplementable. Reverted to 401-redirect UX above.
-
-
-
-### Task 6.1: Refresh helper + `/api/auth/refresh` endpoint
-
-**Objective:** A function the middleware calls when verify says "expired", and a manual `/api/auth/refresh` endpoint for the SPA to invoke proactively.
-
-**Files:**
-- Create: `hermes_cli/dashboard_auth/refresh.py`
-- Modify: `hermes_cli/dashboard_auth/middleware.py` — call refresh when verify returns None.
-- Modify: `hermes_cli/dashboard_auth/routes.py` — add `/api/auth/refresh`.
-
-**Step 1: Implement the refresh helper.**
-
-```python
-# hermes_cli/dashboard_auth/refresh.py
-"""Cookie-rotating refresh helper.
-
-The middleware calls ``maybe_refresh_session`` whenever ``verify_session``
-returned None and a refresh token is available. On success the response
-gets new ``hermes_session_at`` + ``hermes_session_rt`` cookies.
-"""
-from __future__ import annotations
-
-import logging
-import time
-from typing import Optional, Tuple
-
-from fastapi import Request
-from fastapi.responses import Response
-
-from hermes_cli.dashboard_auth import list_providers
-from hermes_cli.dashboard_auth.audit import audit_log, AuditEvent
-from hermes_cli.dashboard_auth.base import (
- Session, RefreshExpiredError, ProviderError,
-)
-from hermes_cli.dashboard_auth.cookies import (
- set_session_cookies, clear_session_cookies, detect_https,
-)
-
-_log = logging.getLogger(__name__)
-
-
-def attempt_refresh(*, refresh_token: str) -> Optional[Session]:
- """Try every provider until one accepts the refresh token.
-
- Returns the new Session on success. Returns None if every provider
- rejected the token. Raises ProviderError if at least one provider
- was unreachable AND none succeeded.
- """
- last_provider_error: Optional[ProviderError] = None
- for provider in list_providers():
- try:
- return provider.refresh_session(refresh_token=refresh_token)
- except RefreshExpiredError:
- # Token doesn't belong to this provider (or is truly dead); try next.
- continue
- except ProviderError as e:
- last_provider_error = e
- continue
- if last_provider_error:
- raise last_provider_error
- return None
-
-
-def apply_refresh_to_response(
- request: Request,
- response: Response,
- session: Session,
-) -> None:
- """Set the new session cookies on ``response``."""
- expires_in = max(60, session.expires_at - int(time.time()))
- set_session_cookies(
- response,
- access_token=session.access_token,
- refresh_token=session.refresh_token,
- access_token_expires_in=expires_in,
- use_https=detect_https(request),
- )
-```
-
-**Step 2: Wire silent refresh into the middleware.**
-
-In `hermes_cli/dashboard_auth/middleware.py`, after the `if session is None:` block, insert a refresh attempt BEFORE the bail-out:
-
-```python
- if session is None:
- # Silent refresh attempt — if we still have a refresh token, try it.
- if _rt:
- try:
- refreshed = attempt_refresh(refresh_token=_rt)
- except ProviderError as e:
- _log.warning("dashboard-auth refresh: provider unreachable: %s", e)
- audit_log(AuditEvent.REFRESH_FAILURE, reason="provider_unreachable",
- ip=_client_ip(request))
- return _unauth_response(path, reason="refresh_unreachable")
- if refreshed is not None:
- # Carry on with the refreshed session; the response handler
- # rotates the cookies on the way out via the wrapper below.
- request.state.session = refreshed
- request.state._session_just_refreshed = refreshed
- response = await call_next(request)
- apply_refresh_to_response(request, response, refreshed)
- audit_log(AuditEvent.REFRESH_SUCCESS,
- provider=refreshed.provider,
- user_id=refreshed.user_id,
- ip=_client_ip(request))
- return response
- audit_log(AuditEvent.REFRESH_FAILURE, reason="refresh_expired",
- ip=_client_ip(request))
- audit_log(AuditEvent.SESSION_VERIFY_FAILURE, reason="no_provider_recognises",
- ip=_client_ip(request))
- return _unauth_response(path, reason="invalid_or_expired_session")
-```
-
-Imports at the top of middleware.py:
-
-```python
-from hermes_cli.dashboard_auth.refresh import attempt_refresh, apply_refresh_to_response
-```
-
-**Step 3: Manual `/api/auth/refresh` endpoint.**
-
-In `hermes_cli/dashboard_auth/routes.py`:
-
-```python
-from hermes_cli.dashboard_auth.refresh import attempt_refresh, apply_refresh_to_response
-
-
-@router.post("/api/auth/refresh", name="auth_refresh")
-async def api_auth_refresh(request: Request):
- """SPA-triggered explicit refresh.
-
- Reads the refresh token from cookies, calls the provider, rotates the
- session cookies on the response. SPA uses this to extend a session
- proactively (e.g. when the user navigates back to a tab they left open
- for hours).
- """
- _at, rt = read_session_cookies(request)
- if not rt:
- raise HTTPException(status_code=401, detail="No refresh token in cookie")
- try:
- sess = attempt_refresh(refresh_token=rt)
- except ProviderError as e:
- audit_log(AuditEvent.REFRESH_FAILURE, reason="provider_unreachable",
- ip=_client_ip(request))
- raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
-
- if sess is None:
- # Refresh truly dead → clear cookies and tell SPA to re-login.
- resp = JSONResponse(
- {"detail": "Refresh expired; re-login required"},
- status_code=401,
- )
- clear_session_cookies(resp)
- audit_log(AuditEvent.REFRESH_FAILURE, reason="refresh_expired",
- ip=_client_ip(request))
- return resp
-
- audit_log(AuditEvent.REFRESH_SUCCESS,
- provider=sess.provider, user_id=sess.user_id,
- ip=_client_ip(request))
- resp = JSONResponse({
- "user_id": sess.user_id,
- "email": sess.email,
- "display_name": sess.display_name,
- "provider": sess.provider,
- "expires_at": sess.expires_at,
- })
- apply_refresh_to_response(request, resp, sess)
- return resp
-```
-
-**Step 4: Test.**
-
-```python
-# tests/hermes_cli/test_dashboard_auth_refresh.py
-"""Silent refresh and explicit /api/auth/refresh."""
-import time
-import pytest
-from fastapi.testclient import TestClient
-
-from hermes_cli import web_server
-from hermes_cli.dashboard_auth import clear_providers, register_provider
-from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE, SESSION_RT_COOKIE
-from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
-
-
-@pytest.fixture
-def gated_app():
- clear_providers()
- register_provider(StubAuthProvider())
- web_server.app.state.bound_host = "0.0.0.0"
- web_server.app.state.auth_required = True
- yield TestClient(web_server.app, base_url="https://gated.fly.dev")
- clear_providers()
- web_server.app.state.auth_required = False
-
-
-def _login(client) -> dict:
- r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
- state = r1.headers["location"].split("state=")[1]
- r2 = client.get(f"/auth/callback?code=stub_code&state={state}",
- follow_redirects=False)
- cookies = {}
- for raw in r2.headers.get_list("set-cookie"):
- name, _, rest = raw.partition("=")
- val = rest.split(";", 1)[0]
- cookies[name] = val
- return cookies
-
-
-def test_explicit_refresh_rotates_cookies(gated_app):
- cookies = _login(gated_app)
- old_at = cookies[SESSION_AT_COOKIE]
- old_rt = cookies[SESSION_RT_COOKIE]
- r = gated_app.post("/api/auth/refresh")
- assert r.status_code == 200
- new_at = next((c.split(";")[0].split("=", 1)[1]
- for c in r.headers.get_list("set-cookie")
- if c.startswith(f"{SESSION_AT_COOKIE}=")), None)
- new_rt = next((c.split(";")[0].split("=", 1)[1]
- for c in r.headers.get_list("set-cookie")
- if c.startswith(f"{SESSION_RT_COOKIE}=")), None)
- assert new_at and new_at != old_at
- assert new_rt and new_rt != old_rt
-
-
-def test_silent_refresh_on_expired_access_token():
- # Configure the stub provider with a very short TTL so the first /api/me
- # call sees an expired token, but the refresh succeeds.
- clear_providers()
- register_provider(StubAuthProvider(default_ttl=0))
- web_server.app.state.auth_required = True
- try:
- client = TestClient(web_server.app, base_url="https://gated.fly.dev")
- cookies = _login(client)
- # /api/auth/me with an expired AT must succeed because middleware
- # silently refreshes.
- r = client.get("/api/auth/me")
- assert r.status_code == 200
- # And the response carries rotated cookies.
- set_cookies = r.headers.get_list("set-cookie")
- assert any(c.startswith(f"{SESSION_AT_COOKIE}=") for c in set_cookies)
- finally:
- clear_providers()
- web_server.app.state.auth_required = False
-
-
-def test_refresh_without_rt_cookie_returns_401(gated_app):
- r = gated_app.post("/api/auth/refresh")
- assert r.status_code == 401
-
-
-def test_refresh_with_dead_token_clears_cookies(gated_app):
- gated_app.cookies.set(SESSION_RT_COOKIE, "garbage-refresh-token")
- r = gated_app.post("/api/auth/refresh")
- assert r.status_code == 401
- # Clearing cookies on a dead refresh
- set_cookies = r.headers.get_list("set-cookie")
- assert any(c.startswith(f"{SESSION_AT_COOKIE}=") and "Max-Age=0" in c for c in set_cookies)
-```
-
-**Step 5: Run, verify pass.**
-
-```bash
-scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_refresh.py -v
-```
-
-**Step 6: Commit.**
-
-```bash
-git add hermes_cli/dashboard_auth/refresh.py hermes_cli/dashboard_auth/middleware.py hermes_cli/dashboard_auth/routes.py tests/hermes_cli/test_dashboard_auth_refresh.py
-git commit -m "feat(dashboard-auth): silent refresh on expired AT + manual /api/auth/refresh"
-```
-
-### Phase 6 Exit Gate
-
-```bash
-scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_refresh.py -v
-```
-
-Manual: leave a dashboard tab open with the stub provider configured to issue 60-second access tokens. After 60 seconds the next page interaction is invisibly upgraded — no login redirect. The audit log shows `refresh_success` entries.
-
----
-
-## Phase 7 — SPA "Logged in as …" Widget, CLI Status, Documentation
-
-> **Plan v2 note.** Contract C4 means the JWT does NOT emit `email` or `display_name` claims, and contract C7 means there's no userinfo endpoint to fetch them from. The widget shown below was originally drafted as "Logged in as Foo "; the implementation surfaces a truncated `user_id` instead (`Logged in as usr_abc123…`). The `/api/auth/me` payload retains empty `email` / `display_name` fields for forward-compat with a future Portal userinfo endpoint (OQ-C1).
-
-**Goal:** User-visible polish — the gated dashboard surfaces the current identity, `hermes status` reports auth-gate state, and the docs site has a guide for VPS/Fly deployments.
-
-### Task 7.1: SPA sidebar identity widget (v2)
-
-**Objective:** A small "Logged in as Foo ⏻" widget at the top of the sidebar. Hits `/api/auth/me` on mount; the logout icon POSTs `/auth/logout`.
-
-**Files:**
-- Create: `web/src/components/AuthWidget.tsx`
-- Modify: `web/src/App.tsx` — mount the widget at the top of the sidebar.
-- Modify: `web/src/lib/api.ts` — add `getMe()` and `logout()`.
-
-```typescript
-// web/src/lib/api.ts — add
-export interface AuthMe {
- user_id: string;
- email: string;
- display_name: string;
- org_id: string;
- provider: string;
- expires_at: number;
-}
-
-export async function getAuthMe(): Promise {
- const r = await fetch('/api/auth/me', { credentials: 'include' });
- if (r.status === 401) return null;
- if (!r.ok) throw new Error(`auth/me: HTTP ${r.status}`);
- return r.json();
-}
-
-export async function logout(): Promise {
- const r = await fetch('/auth/logout', {
- method: 'POST',
- credentials: 'include',
- redirect: 'manual',
- });
- // Server returns 302 → /login. With redirect: 'manual', fetch resolves
- // with status 0 in browsers — we manually navigate.
- window.location.href = '/login';
-}
-```
-
-```typescript
-// web/src/components/AuthWidget.tsx
-import { useEffect, useState } from 'react';
-import { getAuthMe, logout, AuthMe } from '../lib/api';
-
-export function AuthWidget() {
- const [me, setMe] = useState(undefined);
-
- useEffect(() => {
- if (!window.__HERMES_AUTH_REQUIRED__) return;
- getAuthMe().then(setMe).catch(() => setMe(null));
- }, []);
-
- if (!window.__HERMES_AUTH_REQUIRED__) return null;
- if (me === undefined) return
Loading…
;
- if (me === null) return null;
-
- return (
-
-
-
{me.display_name}
-
{me.email}
-
-
-
- );
-}
-```
-
-In `web/src/App.tsx`, mount at the top of the sidebar:
-
-```typescript
-import { AuthWidget } from './components/AuthWidget';
-// ...
-
-```
-
-Add to `web/src/vite-env.d.ts` (or wherever globals are declared):
-
-```typescript
-interface Window {
- __HERMES_SESSION_TOKEN__?: string;
- __HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
- __HERMES_BASE_PATH__?: string;
- __HERMES_AUTH_REQUIRED__?: boolean;
-}
-```
-
-**Commit:**
-
-```bash
-git add web/
-git commit -m "feat(dashboard-auth): SPA auth widget showing logged-in identity + logout button"
-```
-
-### Task 7.2: `hermes status` integration
-
-**Objective:** `hermes status` reports whether the gateway/dashboard has an active OAuth gate.
-
-**Files:**
-- Modify: `hermes_cli/status.py` — extend the existing reporter.
-
-```python
-# In hermes_cli/status.py, add to the report block:
-
-# ... after the existing nous_logged_in line ...
-def _dashboard_auth_status() -> str:
- """Reports number of registered dashboard-auth providers."""
- try:
- from hermes_cli.dashboard_auth import list_providers
- providers = list_providers()
- except Exception:
- return "not available"
- if not providers:
- return "no auth providers registered (loopback only)"
- names = ", ".join(p.name for p in providers)
- return f"{len(providers)} provider(s): {names}"
-
-# Print in the report:
-print(f" dashboard auth providers: {_dashboard_auth_status()}")
-```
-
-**Commit:**
-
-```bash
-git add hermes_cli/status.py
-git commit -m "feat(status): report dashboard-auth provider availability"
-```
-
-### Task 7.3: Documentation
-
-**Objective:** A doc page covering the auth gate, how to deploy publicly, and how to write a custom provider plugin.
-
-**Files:**
-- Create: `website/docs/user-guide/features/dashboard-auth.md`
-- Modify: `website/sidebars.ts` — link the new page.
-
-Content sketch (full prose in the file):
-
-```markdown
-# Dashboard Authentication
-
-When `hermes dashboard` binds to a non-loopback host (any IP other than
-`127.0.0.1`, `localhost`, or `::1`) **without** `--insecure`, an OAuth
-authentication gate is automatically engaged. By default, sign-in uses
-your Nous Portal account.
-
-## When the gate is active
-
-| Command | Gate? |
-|---|---|
-| `hermes dashboard` | Off (loopback) |
-| `hermes dashboard --host 0.0.0.0` | On |
-| `hermes dashboard --host 0.0.0.0 --insecure` | Off (legacy escape hatch) |
-| `hermes dashboard --host 192.168.1.5` | On |
-| `hermes dashboard --host fly-app.fly.dev` | On |
-
-## Default sign-in: Nous Portal
-
-The default provider is bundled (`plugins/dashboard-auth-nous`) and needs
-zero configuration. Visiting the dashboard prompts a "Sign in with Nous
-Portal" button → OAuth redirect → back to your dashboard.
-
-## Logging out
-
-Click ⏻ in the sidebar widget, or `POST /auth/logout`. The browser cookies
-are cleared and the refresh token is best-effort revoked at Portal.
-
-## Audit log
-
-Every sign-in attempt, success, refresh, and logout is recorded at
-`$HERMES_HOME/logs/dashboard-auth.log` (JSON-lines).
-
-## Adding a custom auth provider
-
-(Plugin authoring guide — `DashboardAuthProvider` ABC, `register(ctx)`
-example, link to `plugins/dashboard-auth-nous/` as the canonical template.)
-
-## Forcing the legacy no-auth behavior
-
-For trusted-network or testing scenarios, pass `--insecure`:
-
-```
-hermes dashboard --host 0.0.0.0 --insecure
-```
-
-Be aware: this exposes API keys and config without authentication. Only
-use on private LANs where you trust every device.
-```
-
-**Commit:**
-
-```bash
-git add website/docs/user-guide/features/dashboard-auth.md website/sidebars.ts
-git commit -m "docs(dashboard): document the OAuth auth gate + custom provider authoring"
-```
-
-### Phase 7 Exit Gate
-
-Visual confirmation:
-1. Gated dashboard renders the AuthWidget in the sidebar showing "Stub User · stub@example.test · ⏻".
-2. Clicking ⏻ logs out, clears cookies, redirects to `/login`.
-3. `hermes status` prints `dashboard auth providers: 1 provider(s): nous`.
-4. Docs site renders the new page; sidebar links work.
-
----
-
-## Risk Register
-
-| # | Risk | Likelihood | Impact | Mitigation |
-|---|---|---|---|---|
-| R1 | A misconfigured operator believes the gate is on when it isn't, exposes dashboard publicly | Med | High | `start_server` prints `OAuth auth gate enabled` to stdout at bind time. `hermes status` shows the active state. Audit log writes `dashboard binding to host: , gate: ` on every start. |
-| R2 | Refresh token in cookie is exfiltrated via XSS | Low | High | HttpOnly cookie prevents JS access. CSP headers on `/` (added in Phase 7) restrict inline-script sources. The dashboard already escapes all user-supplied content; this plan doesn't add new XSS surface. |
-| R3 | OAuth redirect URI mismatch breaks the round-trip in Fly setups | Med | High | The Portal whitelists `*.fly.dev/auth/callback`; verify with each new Fly hostname. `audit_log` records `idp_error` events so misconfigs are visible. Operators with custom domains follow the Open Question #1 path (out of v1 scope but flagged). |
-| R4 | Portal JWKS rollout slips; userinfo mode hammers Portal with one network call per dashboard request | Med | Med | 60-second per-token cache in `_verify_via_userinfo` keeps the load to ~1 req/min/user. Add a metric/log if cache hit rate drops. |
-| R5 | The `hermes-dashboard` client_id is not yet registered on Portal at code-merge time | High | Med | Phase 4 ships with the userinfo fallback and unit tests use respx mocks — code merges and CI passes without Portal. Operator-run smoke test (Phase 4 exit gate) gates the actual release. |
-| R6 | Browsers reject cookies because Fly TLS terminates HTTPS but uvicorn sees HTTP without `proxy_headers` | High | High | `start_server` re-enables `proxy_headers=True` when gate is active. `detect_https` reads from `request.url.scheme` which honors `X-Forwarded-Proto` when proxy_headers is True. Tested in Phase 3.5. |
-| R7 | Memory leak in `_verify_cache` for the userinfo mode | Low | Low | LRU-bounded to access tokens still in valid cookies; tokens are 1-hour-TTL so the dict size is bounded by simultaneous-user count. If telemetry shows growth, swap to `lru_cache` with explicit max=10000. |
-| R8 | Stub provider accidentally leaks into a production build | Low | High | Lives under `tests/`, not `plugins/` or `hermes_cli/`. The plugin discovery scanner doesn't traverse `tests/`. CI assertion: `grep -r StubAuthProvider plugins/ hermes_cli/` returns nothing. |
-| R9 | Loopback regression: existing dashboards stop accepting the injected token | Med | High | Phase 0's harness pins current behavior. Every subsequent phase reruns it. Pre-merge: full `scripts/run_tests.sh` against the whole suite. |
-| R10 | Single-user-only assumption is violated by a future feature change | Low | Med | The session model treats every cookie as authoritative for the dashboard process; there's no per-user UI state. If multi-user is ever needed, audit `/api/sessions`, `/api/config`, and the PTY bridge — each currently writes to single shared state. Flagged in Open Questions #2. |
-
-## Rollout
-
-Phases 0–3 land first as one unit (the gate + stub-driven E2E). After merge, the gate is OFF by default for everyone (loopback unchanged) and OPT-IN for non-loopback (operator must pass --insecure to bypass, or install a provider plugin).
-
-Phases 4–7 land in sequence as the Portal cross-repo work completes. Phase 4 is the first user-visible step; Phases 5–6–7 are quality-of-life improvements that don't change correctness.
-
-### Pre-merge checklist for each phase
-
-- [ ] All new tests pass
-- [ ] Loopback regression harness from Phase 0 still passes
-- [ ] No new errors at `WARNING` or higher in `agent.log` / `gateway.log` when starting a loopback dashboard
-- [ ] Manual smoke test (Phase exit gate) walked by the implementer
-- [ ] `hermes status` output unchanged (until Phase 7's intentional addition)
-
-### Pre-release checklist for Phase 4
-
-- [ ] Portal team confirms `hermes-dashboard` client_id is registered in `OAUTH_CLIENT_PRODUCT_CONTEXT_MAP`
-- [ ] Portal team confirms `https://*.fly.dev/auth/callback` is in the redirect-URI whitelist for `hermes-dashboard`
-- [ ] Portal team confirms `GET /oauth/authorize` route is live
-- [ ] Portal team confirms `POST /api/oauth/token` accepts `grant_type=authorization_code` with PKCE
-- [ ] Portal team confirms access token includes `email`, `email_verified`, `name` claims
-- [ ] Operator walks the end-to-end flow against staging Portal once
-- [ ] Operator confirms `~/.hermes/logs/dashboard-auth.log` records `login_success` event with correct user_id + email
-- [ ] Operator confirms refresh works by setting access-token TTL to 60s and leaving the tab open for 90s
-
-## Verification Strategy
-
-| Layer | How |
-|---|---|
-| Provider protocol | Unit tests in `tests/hermes_cli/test_dashboard_auth_provider_base.py` — every provider plugin must call `assert_protocol_compliance` in its own tests. |
-| Cookies | Unit tests in `tests/hermes_cli/test_dashboard_auth_cookies.py` cover HttpOnly/Secure/SameSite/Max-Age semantics. |
-| Middleware | Behavioral tests in `tests/hermes_cli/test_dashboard_auth_middleware.py` exercise gated vs loopback modes with the stub provider. |
-| Real provider | `plugins/dashboard-auth-nous/test_provider.py` uses respx to mock Portal endpoints. Real-Portal smoke is operator-run. |
-| End-to-end | The Phase 3 exit gate is a full browser round trip with the stub. Phase 4 exit gate is the same against staging Portal. |
-| Regression | Phase 0's harness is rerun by every subsequent phase as part of its exit gate. |
-| Audit log | Tests in `test_dashboard_auth_audit.py` confirm event types, JSON format, and token redaction. |
-| WS auth | Tests in `test_dashboard_auth_middleware.py::test_ws_*` cover ticket mint/consume/expire across loopback and gated. |
-
-## Timeline (rough)
-
-Each phase is independently shippable. A focused engineer can land:
-
-| Phase | Effort |
-|---|---|
-| 0 | ½ day |
-| 1 | 1 day |
-| 2 | ½ day |
-| 3 | 2 days |
-| 4 | 1 day Hermes side + cross-repo coordination (variable) |
-| 5 | 1 day |
-| 6 | 1 day |
-| 7 | 1 day |
-
-Total: ~7–8 working days on the Hermes side. Cross-repo Portal work is on top of that and gates Phase 4's actual usefulness.
-
-## Files Changed Summary
-
-New:
-- `hermes_cli/dashboard_auth/__init__.py`
-- `hermes_cli/dashboard_auth/base.py`
-- `hermes_cli/dashboard_auth/registry.py`
-- `hermes_cli/dashboard_auth/audit.py`
-- `hermes_cli/dashboard_auth/cookies.py`
-- `hermes_cli/dashboard_auth/middleware.py`
-- `hermes_cli/dashboard_auth/routes.py`
-- `hermes_cli/dashboard_auth/login_page.py`
-- `hermes_cli/dashboard_auth/ws_tickets.py`
-- `hermes_cli/dashboard_auth/refresh.py`
-- `plugins/dashboard-auth-nous/plugin.yaml`
-- `plugins/dashboard-auth-nous/__init__.py`
-- `plugins/dashboard-auth-nous/provider.py`
-- `plugins/dashboard-auth-nous/test_provider.py`
-- `web/src/components/AuthWidget.tsx`
-- `website/docs/user-guide/features/dashboard-auth.md`
-- 8 test files under `tests/hermes_cli/`
-
-Modified:
-- `hermes_cli/web_server.py` — add `should_require_auth`, register two middlewares, update `_serve_index`/`start_server`/WS endpoints
-- `hermes_cli/plugins.py` — add `register_dashboard_auth_provider` method
-- `hermes_cli/status.py` — report dashboard-auth state
-- `web/src/App.tsx`, `web/src/lib/api.ts`, `web/src/pages/ChatPage.tsx`, `web/src/vite-env.d.ts`
-- `website/sidebars.ts`
-
-Cross-repo (nous-account-service):
-- `src/server/oauth/access-token-issuer.ts` — register `hermes-dashboard` client_id
-- New: `src/app/oauth/authorize/page.tsx` (or equivalent)
-- `src/app/api/oauth/token/route.ts` — accept `grant_type=authorization_code`
-- `src/server/oauth/access-token-issuer.ts` — add `email`/`name` claims for `profile email` scope
-- Portal config — whitelist `https://*.fly.dev/auth/callback`
-
From 61dcc33893ac2d5f6a30848aee3e3324b3e3bb4f Mon Sep 17 00:00:00 2001
From: Ben
Date: Tue, 26 May 2026 12:00:13 +1000
Subject: [PATCH 111/260] feat(dashboard-auth): config.yaml as canonical
surface for dashboard.oauth
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per AGENTS.md, ~/.hermes/.env is reserved for API keys / secrets and
config.yaml is the surface for non-secret configuration. The Nous
Portal plugin previously read HERMES_DASHBOARD_OAUTH_CLIENT_ID and
HERMES_DASHBOARD_PORTAL_URL from the environment only, which forced
local-dev / on-prem operators to put non-secret per-instance
configuration in .env — violating the convention.
Add dashboard.oauth.{client_id,portal_url} to DEFAULT_CONFIG and have
the plugin resolve each setting with env-overrides-config precedence:
1. Env var when set to a non-empty value (Fly.io platform-secret
injection — what pushes per-deploy client_ids without baking
them into the image).
2. config.yaml entry (canonical surface for local dev / on-prem).
3. Plugin default (no provider registered when client_id is empty;
portal_url defaults to https://portal.nousresearch.com).
Empty env values are explicitly treated as unset so a provisioned-but-
not-populated Fly secret can't accidentally shadow a valid config.yaml
entry with an empty string — operators would otherwise lose the gate.
Implementation:
- hermes_cli/config.py: add dashboard.oauth.{client_id,portal_url}
block to DEFAULT_CONFIG with full doc comment explaining the
override precedence and Fly.io rationale.
- plugins/dashboard_auth/nous/__init__.py: add _load_config_oauth_section,
_resolve_client_id, _resolve_portal_url helpers; replace the two
direct os.environ.get() calls in register() with the resolvers.
Update the skip-reason string to mention BOTH surfaces so an
operator looking at the fail-closed bind error knows config.yaml
is a valid alternative to the env var.
- plugins/dashboard_auth/nous/plugin.yaml: update description to
name both surfaces. requires_env stays pointing at the env var
name — it's metadata-only (not used by the plugin loader for
gating) so this is documentation/UX, not enforcement.
- cli-config.yaml.example: append commented dashboard.oauth block
with the same override rationale operators see in code.
- website/docs/user-guide/features/web-dashboard.md: rewrite the
'Default provider: Nous Research' section to lead with config.yaml,
present env vars as operator overrides (Fly.io's primary path).
Updated the example fail-closed bind error to match the new
skip-reason text.
Test coverage — new TestConfigYamlSource class (8 tests) pinning
every tier of the precedence chain:
- config-yaml-only path registers correctly
- both config-yaml fields (client_id + portal_url) honoured
- env var overrides config for client_id (Fly.io critical path)
- env var overrides config for portal_url
- empty env string does NOT shadow config (CI/Fly edge case)
- neither source set → skip with reason mentioning BOTH surfaces
- load_config() raising falls through to env-only path (resilience)
- non-dict oauth section falls through cleanly (typo resilience)
Mutation-tested: flipping the precedence to config-wins-over-env trips
exactly test_env_overrides_config_client_id while the other 7 stay
green, confirming the suite discriminates the order, not just the
sources.
This closes the last item in Teknium's PR review (PR #30156).
---
cli-config.yaml.example | 24 +++
hermes_cli/config.py | 17 ++
plugins/dashboard_auth/nous/__init__.py | 133 ++++++++++----
plugins/dashboard_auth/nous/plugin.yaml | 2 +-
.../dashboard_auth/test_nous_provider.py | 164 ++++++++++++++++++
.../docs/user-guide/features/web-dashboard.md | 48 +++--
6 files changed, 348 insertions(+), 40 deletions(-)
diff --git a/cli-config.yaml.example b/cli-config.yaml.example
index c119d0ac4b9..6f3c0a61d1f 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -1097,3 +1097,27 @@ display:
# - command: "~/.hermes/agent-hooks/log-orchestration.sh"
#
# hooks_auto_accept: false
+
+
+# =============================================================================
+# Web Dashboard
+# =============================================================================
+# OAuth gate configuration for `hermes dashboard --host `.
+# The bundled Nous Portal plugin reads these on startup; settings here are
+# the canonical surface. Each can be overridden by an environment variable:
+#
+# dashboard.oauth.client_id <- HERMES_DASHBOARD_OAUTH_CLIENT_ID
+# dashboard.oauth.portal_url <- HERMES_DASHBOARD_PORTAL_URL
+#
+# Env wins when set to a non-empty value. This is what Fly.io's platform-
+# secret injection uses to push per-deploy client_ids without needing to
+# bake a config.yaml into the image. Empty env values are treated as unset
+# so a provisioned-but-not-populated secret can't shadow a valid entry here.
+#
+# Local dev / on-prem deploys should typically set these via config.yaml
+# (the ~/.hermes/.env file is reserved for API keys and secrets).
+#
+# dashboard:
+# oauth:
+# client_id: "" # agent:{instance_id}; Portal provisions this at deploy
+# portal_url: "" # blank → default https://portal.nousresearch.com
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 54d3d960fb7..58f355bd43c 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -1180,6 +1180,23 @@ DEFAULT_CONFIG = {
# Set this to True to re-enable the surfaces with the understanding
# that the numbers are a local lower-bound estimate, not billing.
"show_token_analytics": False,
+ # OAuth gate configuration (engaged when ``--host`` is set and
+ # ``--insecure`` is not). The bundled Nous Portal plugin reads
+ # both keys at startup; they are the canonical surface for these
+ # settings. Each can be overridden by an environment variable —
+ # ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` and
+ # ``HERMES_DASHBOARD_PORTAL_URL`` respectively — and the env var
+ # wins when set to a non-empty value. The override path is what
+ # Fly.io's platform-secret injection uses to push the per-deploy
+ # client_id at provisioning time without operators needing to
+ # touch config.yaml. Local dev / non-Fly deploys can set either
+ # surface; missing values fall through to the plugin's defaults
+ # (no provider registered when ``client_id`` is empty;
+ # ``portal_url`` defaults to https://portal.nousresearch.com).
+ "oauth": {
+ "client_id": "", # agent:{instance_id} — Portal provisions this
+ "portal_url": "", # blank → use plugin default (production Portal)
+ },
},
# Privacy settings
diff --git a/plugins/dashboard_auth/nous/__init__.py b/plugins/dashboard_auth/nous/__init__.py
index e434fbfb054..c9d4b744cf0 100644
--- a/plugins/dashboard_auth/nous/__init__.py
+++ b/plugins/dashboard_auth/nous/__init__.py
@@ -2,20 +2,31 @@
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``
(PR #180). The plugin auto-loads (bundled, kind=backend) but only registers
-its provider when the Portal-injected env var is present, so loopback /
-``--insecure`` operators are unaffected.
+its provider when a client_id is configured — either via ``config.yaml`` or
+via the Portal-injected env var — so loopback / ``--insecure`` operators
+are unaffected.
-Required env var (Portal injects at Fly.io provisioning):
+Configuration surfaces (env wins over config.yaml when set non-empty):
- HERMES_DASHBOARD_OAUTH_CLIENT_ID — shape ``agent:{agent_instance_id}``
+ ``config.yaml`` — canonical surface::
-Optional env var:
+ dashboard:
+ oauth:
+ client_id: agent:{agent_instance_id} # required
+ portal_url: https://portal.example # optional
- HERMES_DASHBOARD_PORTAL_URL — defaults to
- ``https://portal.nousresearch.com``
- (production Portal). Override only
- for staging (``portal.rewbs.uk``)
- or a custom deployment.
+ Environment overrides — used by Fly.io's platform-secret injection so
+ per-deploy values don't need to bake into ``config.yaml``:
+
+ HERMES_DASHBOARD_OAUTH_CLIENT_ID — shape ``agent:{agent_instance_id}``
+ HERMES_DASHBOARD_PORTAL_URL — defaults to
+ ``https://portal.nousresearch.com``
+ (production Portal). Override only
+ for staging (``portal.rewbs.uk``)
+ or a custom deployment.
+
+Empty env var values are treated as unset so a provisioned-but-not-populated
+Fly secret can't shadow a valid config.yaml entry.
Key contract points encoded here:
@@ -442,40 +453,104 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
# ---------------------------------------------------------------------------
+def _load_config_oauth_section() -> dict:
+ """Return the ``dashboard.oauth`` block from ``config.yaml`` if it
+ exists and is a dict; otherwise an empty dict.
+
+ Robust to (a) load_config() raising (malformed YAML, IO error,
+ config.yaml absent — common in fresh installs), (b) the
+ ``dashboard`` key being absent or non-dict, and (c) the ``oauth``
+ sub-key being present but not a dict (user typo). Each shape falls
+ through to ``{}`` so register() can rely on `.get(...)` access.
+ """
+ try:
+ from hermes_cli.config import cfg_get, load_config
+
+ cfg = load_config()
+ except Exception as exc: # noqa: BLE001 — broad catch is intentional
+ logger.debug(
+ "dashboard-auth-nous: load_config() raised %s; "
+ "falling back to env-only configuration",
+ exc,
+ )
+ return {}
+ section = cfg_get(cfg, "dashboard", "oauth", default=None)
+ return section if isinstance(section, dict) else {}
+
+
+def _resolve_client_id() -> str:
+ """Resolve the OAuth client_id with env-overrides-config precedence.
+
+ Order:
+ 1. ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var (when non-empty
+ after strip — empty values are treated as unset so a
+ provisioned-but-not-populated Fly secret can't shadow a valid
+ config.yaml entry).
+ 2. ``dashboard.oauth.client_id`` in ``config.yaml``.
+ 3. Empty string — signals "no client_id configured" to the caller.
+ """
+ env = os.environ.get("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "").strip()
+ if env:
+ return env
+ cfg_value = _load_config_oauth_section().get("client_id", "")
+ return str(cfg_value).strip()
+
+
+def _resolve_portal_url() -> str:
+ """Resolve the Portal URL with env-overrides-config precedence.
+
+ Order:
+ 1. ``HERMES_DASHBOARD_PORTAL_URL`` env var (non-empty after strip).
+ 2. ``dashboard.oauth.portal_url`` in ``config.yaml``.
+ 3. :data:`_DEFAULT_PORTAL_URL` (production Portal).
+ """
+ env = os.environ.get("HERMES_DASHBOARD_PORTAL_URL", "").strip()
+ if env:
+ return env
+ cfg_value = str(
+ _load_config_oauth_section().get("portal_url", "")
+ ).strip()
+ return cfg_value or _DEFAULT_PORTAL_URL
+
+
def register(ctx) -> None:
"""Plugin entry — called by the plugin loader at startup.
- Registers ``NousDashboardAuthProvider`` only when
- ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` is set (the Portal injects this
- at Fly.io provisioning). ``HERMES_DASHBOARD_PORTAL_URL`` defaults to
- production; override only for staging or custom deployments.
+ Registers ``NousDashboardAuthProvider`` only when a client_id is
+ configured (either via ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var
+ or via ``dashboard.oauth.client_id`` in ``config.yaml``). The env
+ var wins when set non-empty — Fly.io's platform-secret injection
+ pushes the per-deploy value through this path.
When skipping, writes a short human-readable reason to the module-
level :data:`LAST_SKIP_REASON` so the dashboard's fail-closed branch
can surface "Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …" instead of the
- bare "no providers registered" the gate would otherwise emit.
+ bare "no providers registered" the gate would otherwise emit. The
+ reason mentions BOTH configuration surfaces so operators don't
+ guess wrong about which one to populate.
- Operator-owned dashboards (loopback / ``--insecure``) leave the env
- var unset, so this plugin is a no-op for them. The gate-engagement
- layer (``hermes_cli.web_server.should_require_auth`` + the fail-
- closed check in ``start_server``) handles the "public bind with zero
- providers" case independently.
+ Operator-owned dashboards (loopback / ``--insecure``) leave both
+ surfaces unset, so this plugin is a no-op for them. The gate-
+ engagement layer (``hermes_cli.web_server.should_require_auth`` +
+ the fail-closed check in ``start_server``) handles the "public bind
+ with zero providers" case independently.
"""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
- client_id = os.environ.get("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "").strip()
- portal_url = (
- os.environ.get("HERMES_DASHBOARD_PORTAL_URL", "").strip()
- or _DEFAULT_PORTAL_URL
- )
+ client_id = _resolve_client_id()
+ portal_url = _resolve_portal_url()
if not client_id:
LAST_SKIP_REASON = (
- "HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set. The Nous Portal "
- "provisions this env var (shape 'agent:{instance_id}') when it "
- "deploys a Hermes Agent instance — set it to your provisioned "
- "client id, or pass --insecure to skip the OAuth gate entirely."
+ "HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set (and "
+ "dashboard.oauth.client_id in config.yaml is empty). The "
+ "Nous Portal provisions this env var (shape "
+ "'agent:{instance_id}') when it deploys a Hermes Agent "
+ "instance — set it to your provisioned client id (either "
+ "as an env var or under dashboard.oauth.client_id in "
+ "config.yaml), or pass --insecure to skip the OAuth gate "
+ "entirely."
)
logger.debug("dashboard-auth-nous: %s", LAST_SKIP_REASON)
return
diff --git a/plugins/dashboard_auth/nous/plugin.yaml b/plugins/dashboard_auth/nous/plugin.yaml
index 3fd8858a00e..c395c0c9165 100644
--- a/plugins/dashboard_auth/nous/plugin.yaml
+++ b/plugins/dashboard_auth/nous/plugin.yaml
@@ -1,6 +1,6 @@
name: nous
version: 1.0.0
-description: "Dashboard auth provider — OAuth 2.0 (authorization-code + PKCE) against Nous Portal. Auto-activates when HERMES_DASHBOARD_OAUTH_CLIENT_ID is set (Portal injects this at Fly.io provisioning). HERMES_DASHBOARD_PORTAL_URL is optional and defaults to https://portal.nousresearch.com."
+description: "Dashboard auth provider — OAuth 2.0 (authorization-code + PKCE) against Nous Portal. Auto-activates when a client_id is configured via either dashboard.oauth.client_id in config.yaml (canonical surface) or HERMES_DASHBOARD_OAUTH_CLIENT_ID env var (operator override; Portal injects this at Fly.io provisioning). dashboard.oauth.portal_url / HERMES_DASHBOARD_PORTAL_URL are optional and default to https://portal.nousresearch.com."
author: NousResearch
kind: backend
requires_env:
diff --git a/tests/plugins/dashboard_auth/test_nous_provider.py b/tests/plugins/dashboard_auth/test_nous_provider.py
index 92806f15fb8..f6fc6fca42c 100644
--- a/tests/plugins/dashboard_auth/test_nous_provider.py
+++ b/tests/plugins/dashboard_auth/test_nous_provider.py
@@ -234,6 +234,170 @@ class TestPluginRegister:
assert registered._portal_url == "https://portal.nousresearch.com"
+# ---------------------------------------------------------------------------
+# Plugin entry point: config.yaml + env-override precedence
+# ---------------------------------------------------------------------------
+
+
+class TestConfigYamlSource:
+ """``dashboard.oauth.{client_id,portal_url}`` in ``config.yaml`` is the
+ canonical surface for these settings. ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``
+ and ``HERMES_DASHBOARD_PORTAL_URL`` are operator overrides that win when
+ set — this is the contract Fly.io's platform-secret injection relies on,
+ and the contract that lets local devs experiment without setting env
+ vars.
+
+ Each test pins exactly one tier of the precedence chain so a regression
+ that flips the order is caught:
+
+ env (when truthy) > config.yaml (when truthy) > plugin default
+ """
+
+ @pytest.fixture
+ def patch_config(self, monkeypatch):
+ """Yield a callable that replaces ``hermes_cli.config.load_config``
+ with a stub returning the given dict. Tests pass the intended
+ ``dashboard.oauth`` block; the stub returns the wrapping structure."""
+
+ def _set(oauth_block: Dict[str, Any] | None) -> None:
+ cfg = {}
+ if oauth_block is not None:
+ cfg = {"dashboard": {"oauth": oauth_block}}
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config", lambda: cfg
+ )
+
+ return _set
+
+ def test_config_yaml_only_client_id_registers(self, patch_config, monkeypatch):
+ """No env var, only config.yaml — plugin reads from config and
+ registers successfully. This is the path Teknium's review pushed
+ for (".env is for secrets only")."""
+ monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
+ monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
+ patch_config({"client_id": "agent:from-config"})
+ ctx = MagicMock()
+ nous_plugin.register(ctx)
+ ctx.register_dashboard_auth_provider.assert_called_once()
+ registered = ctx.register_dashboard_auth_provider.call_args.args[0]
+ assert registered._client_id == "agent:from-config"
+ # Defaults to production portal URL when neither config nor env
+ # specifies one.
+ assert registered._portal_url == "https://portal.nousresearch.com"
+
+ def test_config_yaml_client_id_and_portal_url(self, patch_config, monkeypatch):
+ monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
+ monkeypatch.delenv("HERMES_DASHBOARD_PORTAL_URL", raising=False)
+ patch_config({
+ "client_id": "agent:from-config",
+ "portal_url": "https://staging.portal.example",
+ })
+ ctx = MagicMock()
+ nous_plugin.register(ctx)
+ registered = ctx.register_dashboard_auth_provider.call_args.args[0]
+ assert registered._client_id == "agent:from-config"
+ assert registered._portal_url == "https://staging.portal.example"
+
+ def test_env_overrides_config_client_id(self, patch_config, monkeypatch):
+ """Env wins. Critical for Fly.io: the Portal injects
+ HERMES_DASHBOARD_OAUTH_CLIENT_ID at deploy time and we MUST
+ honour it even if a stale config.yaml ships in the image."""
+ monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:from-env")
+ patch_config({"client_id": "agent:from-config"})
+ ctx = MagicMock()
+ nous_plugin.register(ctx)
+ registered = ctx.register_dashboard_auth_provider.call_args.args[0]
+ assert registered._client_id == "agent:from-env", (
+ "env var must override config.yaml — Fly secret injection "
+ "depends on this precedence"
+ )
+
+ def test_env_overrides_config_portal_url(self, patch_config, monkeypatch):
+ monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "agent:x")
+ monkeypatch.setenv(
+ "HERMES_DASHBOARD_PORTAL_URL", "https://env.portal.example",
+ )
+ patch_config({
+ "client_id": "agent:x",
+ "portal_url": "https://config.portal.example",
+ })
+ ctx = MagicMock()
+ nous_plugin.register(ctx)
+ registered = ctx.register_dashboard_auth_provider.call_args.args[0]
+ assert registered._portal_url == "https://env.portal.example"
+
+ def test_empty_env_string_does_not_shadow_config(
+ self, patch_config, monkeypatch
+ ):
+ """``HERMES_DASHBOARD_OAUTH_CLIENT_ID=`` (set but empty) is
+ common in CI/Fly when a secret is provisioned-but-not-populated.
+ It MUST NOT shadow a valid config.yaml value with an empty
+ string — operators would lose the gate."""
+ monkeypatch.setenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "")
+ patch_config({"client_id": "agent:from-config"})
+ ctx = MagicMock()
+ nous_plugin.register(ctx)
+ ctx.register_dashboard_auth_provider.assert_called_once()
+ registered = ctx.register_dashboard_auth_provider.call_args.args[0]
+ assert registered._client_id == "agent:from-config"
+
+ def test_neither_source_skips_with_helpful_reason(
+ self, patch_config, monkeypatch
+ ):
+ """Neither env nor config.yaml set — skip with a reason that
+ mentions BOTH surfaces so operators don't guess wrong about
+ which one to populate."""
+ monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
+ patch_config(None)
+ ctx = MagicMock()
+ nous_plugin.register(ctx)
+ ctx.register_dashboard_auth_provider.assert_not_called()
+ # Old behaviour: skip reason mentions the env var.
+ assert "HERMES_DASHBOARD_OAUTH_CLIENT_ID" in nous_plugin.LAST_SKIP_REASON
+ # New behaviour: skip reason ALSO mentions the config.yaml path
+ # so the user knows it's a valid alternative.
+ assert "dashboard.oauth.client_id" in nous_plugin.LAST_SKIP_REASON, (
+ f"skip reason omits the config.yaml surface — operators "
+ f"won't know it exists. got: {nous_plugin.LAST_SKIP_REASON!r}"
+ )
+
+ def test_config_yaml_load_failure_falls_through_cleanly(
+ self, monkeypatch
+ ):
+ """If load_config() raises (e.g. malformed YAML, IOError), the
+ plugin must not crash — it falls through to the env-only path
+ and either succeeds (if env is set) or surfaces the standard
+ 'not set' skip reason."""
+ monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
+
+ def _broken_load():
+ raise OSError("config.yaml not readable")
+
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config", _broken_load
+ )
+ ctx = MagicMock()
+ # Must not raise.
+ nous_plugin.register(ctx)
+ ctx.register_dashboard_auth_provider.assert_not_called()
+
+ def test_config_yaml_with_non_dict_oauth_section(
+ self, monkeypatch
+ ):
+ """cfg_get handles 'config has a string where a section was
+ expected' robustly. Verify the plugin inherits that resilience
+ so a malformed user config doesn't crash startup."""
+ monkeypatch.delenv("HERMES_DASHBOARD_OAUTH_CLIENT_ID", raising=False)
+ monkeypatch.setattr(
+ "hermes_cli.config.load_config",
+ lambda: {"dashboard": {"oauth": "wrong type"}},
+ )
+ ctx = MagicMock()
+ nous_plugin.register(ctx)
+ # Falls through to the no-env-and-no-config path.
+ ctx.register_dashboard_auth_provider.assert_not_called()
+
+
# ---------------------------------------------------------------------------
# start_login
# ---------------------------------------------------------------------------
diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md
index 635ded7d80f..44a1cfc897a 100644
--- a/website/docs/user-guide/features/web-dashboard.md
+++ b/website/docs/user-guide/features/web-dashboard.md
@@ -323,26 +323,45 @@ If the gate would engage but **no** `DashboardAuthProvider` is registered (no No
### Default provider: Nous Research
-The bundled `plugins/dashboard_auth/nous` plugin is **always installed** and auto-loaded. It auto-registers a `DashboardAuthProvider` named `nous` when the per-instance client ID is set:
+The bundled `plugins/dashboard_auth/nous` plugin is **always installed** and auto-loaded. It auto-registers a `DashboardAuthProvider` named `nous` when a client ID is configured.
-| Env var | Required? | Format | Provisioned by |
+#### Configuration
+
+The plugin reads from two surfaces, with the environment variable winning when set non-empty:
+
+**`config.yaml`** — the canonical surface:
+
+```yaml
+dashboard:
+ oauth:
+ client_id: agent:01HXYZ… # required to engage the gate
+ portal_url: https://portal.nousresearch.com # optional; defaults to production
+```
+
+**Environment variables** — operator overrides:
+
+| Env var | Overrides | Format | Provisioned by |
|---------|-----------|--------|----------------|
-| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | **yes** | `agent:{instance_id}` | Nous Portal at Fly.io provisioning time |
-| `HERMES_DASHBOARD_PORTAL_URL` | no | `https://portal.nousresearch.com` (default) | Portal — override only for staging or a custom deployment |
+| `HERMES_DASHBOARD_OAUTH_CLIENT_ID` | `dashboard.oauth.client_id` | `agent:{instance_id}` | Nous Portal at Fly.io provisioning time |
+| `HERMES_DASHBOARD_PORTAL_URL` | `dashboard.oauth.portal_url` | URL (default: `https://portal.nousresearch.com`) | Portal — override only for staging or a custom deployment |
-`HERMES_DASHBOARD_OAUTH_CLIENT_ID` is the only required variable; it's injected automatically when you deploy through the Nous Portal. The portal URL defaults to production, so the typical operator never touches it — set it explicitly only if you're pointing at staging (`portal.rewbs.uk`) or a custom Portal deployment.
+Per the Hermes Agent convention (`~/.hermes/.env` is for API keys / secrets only), **`config.yaml` is the recommended place to set these values** for local dev, on-prem, and any deployment you control directly. The environment-variable path exists so Fly.io's platform-secret injection can push per-deploy `client_id`s without anyone having to edit `config.yaml` inside the image — that's its primary purpose.
-If `HERMES_DASHBOARD_OAUTH_CLIENT_ID` is absent or malformed, the plugin reports the specific reason and the dashboard's fail-closed bind error tells you exactly what to fix:
+Empty environment values are treated as unset, so a provisioned-but-not-populated Fly secret can't accidentally shadow a valid `config.yaml` entry.
+
+If neither source provides a client_id, the plugin reports the specific reason and the dashboard's fail-closed bind error tells you exactly what to fix:
```
Refusing to bind dashboard to 0.0.0.0 — the OAuth auth gate engages on
non-loopback binds, but no auth providers are registered.
Bundled providers reported these issues:
- • nous: HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set. The Nous Portal
+ • nous: HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set (and
+ dashboard.oauth.client_id in config.yaml is empty). The Nous Portal
provisions this env var (shape 'agent:{instance_id}') when it
deploys a Hermes Agent instance — set it to your provisioned
- client id, or pass --insecure to skip the OAuth gate entirely.
+ client id (either as an env var or under dashboard.oauth.client_id
+ in config.yaml), or pass --insecure to skip the OAuth gate entirely.
Or pass --insecure to skip the auth gate (NOT recommended on untrusted
networks).
@@ -406,11 +425,20 @@ The login page lists all registered providers; multiple providers can be stacked
### Verifying the gate is on
```bash
-# Run the dashboard with the gate engaged (Fly.io shape).
-# HERMES_DASHBOARD_PORTAL_URL is optional — defaults to production.
+# Quick env-var path (Fly.io shape). HERMES_DASHBOARD_PORTAL_URL is
+# optional — defaults to production.
HERMES_DASHBOARD_OAUTH_CLIENT_ID=agent:test \
hermes dashboard --host 0.0.0.0
+# Or the equivalent via config.yaml (recommended for local dev / on-prem):
+#
+# dashboard:
+# oauth:
+# client_id: agent:test
+#
+# then just:
+hermes dashboard --host 0.0.0.0
+
# Hit /api/status to see the gate state:
curl -s http://127.0.0.1:9119/api/status | jq '.auth_required, .auth_providers'
# true
From 0af37ff27220c7a59008a82945ec9cec90971526 Mon Sep 17 00:00:00 2001
From: Ben
Date: Tue, 26 May 2026 12:09:42 +1000
Subject: [PATCH 112/260] style(dashboard-auth): redesign /login page to match
Nous design system
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The login page is the first surface the user sees on a gated dashboard
and shipped with off-the-shelf system fonts and a generic orange
accent that didn't match the React dashboard waiting on the other
side of the OAuth round trip. Apply the same visual language the SPA
uses (the @nous-research/ui package) so the auth flow feels like one
product, not two.
What changes (visual only — no functional changes):
Typography
- Body: Collapse (regular + bold), served from /fonts/ — the same
woff2 files the dashboard SPA loads via the design-system's
fonts.css.
- Display: Rules Compressed (regular + medium) for the brand
wordmark and the page heading.
- Brand chrome (heading, buttons, footer) uses the DS idiom:
uppercase + letter-spacing 0.2em (matching the DS Button class).
Colour
- Background: #170d02 (deep brown-black; --background-base in DS).
- Accent: #ffac02 (amber; --midground in DS).
- Foreground: #ffffff.
- Hairlines: color-mix() of the midground at 18% / 35%, mirroring
the DS "@theme inline" derived tokens.
Button surface
- Solid amber surface with dark text, no rounded corners (DS Button
is squared). Inset bevel — — directly mirrors the DS
Button SHADOW_DEFAULT (). :active uses filter:invert(1) which matches the DS
Button's .
Atmosphere
- Subtle 3px dither (repeating-conic-gradient at 4% midground) +
a midground radial glow at top — same idioms as the DS .dither
utility and the SPA's panel chrome.
- slide-up fade-in entrance animation matching DS @keyframes
slide-up (0.6s ease-out). Honours prefers-reduced-motion.
Brand wordmark
- 'NOUS · RESEARCH' above the card in Rules Compressed, amber,
0.32em tracking. Establishes ownership before the user squints
at the buttons.
Empty-state page
- The 'Sign-in unavailable' fallback (no providers registered)
got the same colour-token and typography treatment so the
misconfigured-deploy experience is also coherent.
Fonts are served from /fonts/*.woff2 — a path the dashboard-auth gate
already allowlists pre-auth (see _GATE_PUBLIC_PREFIXES in
middleware.py:42), so the login page renders with the brand typeface
without needing the React bundle loaded. The page is still entirely
static HTML+CSS with no JS — the original constraint (no SPA
dependency, no session token) is preserved.
The class="provider-btn" selector is unchanged — the existing test
suite extracts the anchor href via that class, and a regression that
renamed it would silently break tests/hermes_cli/test_dashboard_auth_401_reauth.py.
A docstring note on the module flags this so future visual tweaks
don't break the contract by accident.
Visual smoke-test: rendered both the happy path (multiple providers
listed) and the empty-state page in a browser and verified all five
DS criteria — brown-black bg, amber accent, uppercase wide-tracking
type, inset-bevel buttons, Nous · Research wordmark — render
correctly with no unstyled fallbacks. 208/208 dashboard-auth tests
remain green.
---
hermes_cli/dashboard_auth/login_page.py | 322 +++++++++++++++++++++---
1 file changed, 292 insertions(+), 30 deletions(-)
diff --git a/hermes_cli/dashboard_auth/login_page.py b/hermes_cli/dashboard_auth/login_page.py
index 2baab0fdcc6..74da4dbe2f0 100644
--- a/hermes_cli/dashboard_auth/login_page.py
+++ b/hermes_cli/dashboard_auth/login_page.py
@@ -3,6 +3,21 @@
No React, no JavaScript dependency. Listed providers come from the
registry; clicking a provider sends a GET to
``/auth/login?provider=``.
+
+Visual styling mirrors the Nous Research design system (the
+``@nous-research/ui`` package the React dashboard uses): the same
+``Collapse`` / ``Rules Compressed`` typeface, amber-on-dark colour
+tokens (``#170d02`` / ``#ffac02`` / ``#fff``), uppercase + wide-tracking
+brand chrome, and the inset-bevel button shadow. Fonts are served
+out of the SPA's ``/fonts/`` directory which the dashboard-auth gate
+already allowlists pre-auth (see ``_GATE_PUBLIC_PREFIXES`` in
+``middleware.py``), so the page renders without needing the React
+bundle loaded.
+
+Test-stable class names: the existing test suite extracts the
+``class="provider-btn"`` anchor href to walk the OAuth flow. That
+class name MUST NOT change without updating
+``tests/hermes_cli/test_dashboard_auth_401_reauth.py``.
"""
from __future__ import annotations
@@ -14,6 +29,9 @@ from hermes_cli.dashboard_auth import list_providers
# bundle, which we deliberately do NOT load here — the login page must
# not depend on the SPA build being present or on the injected session
# token.
+#
+# Single curly braces are placeholders for ``str.format``; CSS curlies
+# are doubled (``{{`` / ``}}``).
_LOGIN_HTML_TEMPLATE = """\
@@ -22,49 +40,229 @@ _LOGIN_HTML_TEMPLATE = """\
Sign in — Hermes Agent
-
Sign in to Hermes Agent
-
Choose a sign-in method to continue.
-
+
NousResearch
+
+
Sign in
+
Choose a sign-in method to continue to the Hermes Agent dashboard.