diff --git a/.dockerignore b/.dockerignore index f4a02484ebfe3..3c16d71b2263b 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/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c0e69bcf3d170..bdbea5c9c0547 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -28,8 +28,7 @@ permissions: contents: read # Concurrency: push/release runs are NEVER cancelled so every merge gets -# its own :main or release-tagged image. :latest is guarded separately -# by the move-latest job. PR runs reuse a PR-scoped group with +# its own image. PR runs reuse a PR-scoped group with # cancel-in-progress: true so rapid pushes to the same PR collapse to the # latest commit. concurrency: @@ -140,12 +139,6 @@ jobs: # Push amd64 by digest only (no tag). The merge job assembles the # tagged manifest list. `push-by-digest=true` is docker's recommended # pattern for multi-runner multi-platform builds. - # - # We apply the OCI revision label here (and again on arm64) because - # the move-latest job reads it off the linux/amd64 sub-manifest - # config of the floating tag to decide whether it's safe to advance. - # The label must be on each per-arch image — manifest lists themselves - # don't carry image config labels. - name: Push amd64 by digest id: push if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' @@ -258,30 +251,17 @@ jobs: # --------------------------------------------------------------------------- # Stitch both per-arch digests into a single tagged multi-arch manifest. # This is a registry-side operation — no building, no layer re-push — - # so it runs in ~30 seconds. On main pushes it produces :main; on - # releases it produces :. + # so it runs in ~30 seconds. # - # For main pushes the ancestor check runs BEFORE the manifest push so - # we never overwrite :main with an older commit. The top-level - # concurrency group (`docker-${{ github.ref }}` with - # `cancel-in-progress: false`) already serialises runs per ref; the - # ancestor check is defense-in-depth. + # On main pushes: tags both :main and :latest. + # On releases: tags :. # --------------------------------------------------------------------------- merge: if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') runs-on: ubuntu-latest needs: [build-amd64, build-arm64] timeout-minutes: 10 - outputs: - pushed_release_tag: ${{ steps.mark_release_pushed.outputs.pushed }} - release_tag: ${{ steps.tag.outputs.tag }} steps: - - name: Checkout code - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1000 - - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -298,86 +278,7 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # Read the git revision label off the current :main manifest, then - # use `git merge-base --is-ancestor` to check whether our commit is - # a descendant of it. If :main doesn't exist yet, or its label is - # missing, we treat that as "safe to publish". If another run - # already advanced :main past us (or diverged), we skip and leave - # it alone. - - name: Decide whether to move :main - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - id: main_check - run: | - set -euo pipefail - image=nousresearch/hermes-agent - - image_json=$( - docker buildx imagetools inspect "${image}:main" \ - --format '{{ json (index .Image "linux/amd64") }}' \ - 2>/dev/null || true - ) - - if [ -z "${image_json}" ]; then - echo "No existing :main (or inspect failed) — safe to publish." - echo "push_main=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - current_sha=$( - printf '%s' "${image_json}" \ - | jq -r '.config.Labels."org.opencontainers.image.revision" // ""' - ) - - if [ -z "${current_sha}" ]; then - echo "Registry :main has no revision label — safe to publish." - echo "push_main=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "Registry :main is at ${current_sha}" - echo "This run is at ${GITHUB_SHA}" - - if [ "${current_sha}" = "${GITHUB_SHA}" ]; then - echo ":main already points at our SHA — nothing to do." - echo "push_main=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then - git fetch --no-tags --prune origin \ - "+refs/heads/main:refs/remotes/origin/main" \ - || true - fi - - if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then - echo "Registry :main points at an unknown commit (${current_sha}); refusing to overwrite." - echo "push_main=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then - echo "Our commit is a descendant of :main — safe to advance." - echo "push_main=true" >> "$GITHUB_OUTPUT" - else - echo "Another run advanced :main past us (or diverged) — leaving it alone." - echo "push_main=false" >> "$GITHUB_OUTPUT" - fi - - # Compute the tag for this run. Main pushes tag directly as :main - # (no per-commit SHA tags); releases use the release tag name. - - name: Compute tag - id: tag - run: | - if [ "${{ github.event_name }}" = "release" ]; then - echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT" - else - echo "tag=main" >> "$GITHUB_OUTPUT" - fi - - # Gate the manifest push on the ancestor check for main pushes. - # For releases there is no gate — the check doesn't even run. - name: Create manifest list and push - if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true' working-directory: /tmp/digests run: | set -euo pipefail @@ -385,137 +286,26 @@ jobs: for digest_file in *; do args+=("${IMAGE_NAME}@sha256:${digest_file}") done - docker buildx imagetools create \ - -t "${IMAGE_NAME}:${TAG}" \ - "${args[@]}" + if [ "${{ github.event_name }}" = "release" ]; then + TAG="${{ github.event.release.tag_name }}" + docker buildx imagetools create \ + -t "${IMAGE_NAME}:${TAG}" \ + "${args[@]}" + else + docker buildx imagetools create \ + -t "${IMAGE_NAME}:main" \ + -t "${IMAGE_NAME}:latest" \ + "${args[@]}" + fi env: IMAGE_NAME: ${{ env.IMAGE_NAME }} - TAG: ${{ steps.tag.outputs.tag }} - name: Inspect image - if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true' - run: | - docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}" - env: - IMAGE_NAME: ${{ env.IMAGE_NAME }} - TAG: ${{ steps.tag.outputs.tag }} - - # Signal to move-latest that the release tag is live. - - name: Mark release tag pushed - id: mark_release_pushed - if: github.event_name == 'release' - run: echo "pushed=true" >> "$GITHUB_OUTPUT" - - # --------------------------------------------------------------------------- - # Move :latest to point at the release tag the merge job pushed. - # - # :latest is the floating tag that tracks the most recent stable release. - # Only `release: published` events advance it — never main pushes. - # - # We still run an ancestor check against the existing :latest so that a - # backport release on an older branch (e.g. patching v1.1.5 after v1.2.3 - # is out) doesn't drag :latest backwards. The check is the same shape - # as the ancestor check in the merge job for :main: read the OCI - # revision label off the current :latest, look up that commit in git, - # and only advance if our release commit is a strict descendant. - # --------------------------------------------------------------------------- - move-latest: - if: | - github.repository == 'NousResearch/hermes-agent' - && github.event_name == 'release' - && needs.merge.outputs.pushed_release_tag == 'true' - needs: merge - runs-on: ubuntu-latest - timeout-minutes: 10 - concurrency: - group: docker-move-latest - cancel-in-progress: false - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 1000 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - - name: Log in to Docker Hub - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Decide whether to move :latest - id: latest_check run: | - set -euo pipefail - image=nousresearch/hermes-agent - - image_json=$( - docker buildx imagetools inspect "${image}:latest" \ - --format '{{ json (index .Image "linux/amd64") }}' \ - 2>/dev/null || true - ) - - if [ -z "${image_json}" ]; then - echo "No existing :latest (or inspect failed) — safe to publish." - echo "push_latest=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - current_sha=$( - printf '%s' "${image_json}" \ - | jq -r '.config.Labels."org.opencontainers.image.revision" // ""' - ) - - if [ -z "${current_sha}" ]; then - echo "Registry :latest has no revision label — safe to publish." - echo "push_latest=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "Registry :latest is at ${current_sha}" - echo "This release is at ${GITHUB_SHA}" - - if [ "${current_sha}" = "${GITHUB_SHA}" ]; then - echo ":latest already points at our SHA — nothing to do." - echo "push_latest=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Make sure we have the :latest commit locally for merge-base. - # Releases can be cut from any branch, so fetch broadly. - if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then - git fetch --no-tags --prune origin \ - "+refs/heads/main:refs/remotes/origin/main" \ - || true - fi - - if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then - echo "Registry :latest points at an unknown commit (${current_sha}); refusing to overwrite." - echo "push_latest=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Our release SHA must be a descendant of the current :latest. - # Backport releases on older branches won't satisfy this and will - # be left alone — :latest stays on the newer release. - if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then - echo "Our release commit is a descendant of :latest — safe to advance." - echo "push_latest=true" >> "$GITHUB_OUTPUT" + if [ "${{ github.event_name }}" = "release" ]; then + docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}" else - echo "Existing :latest is newer than this release (likely a backport) — leaving it alone." - echo "push_latest=false" >> "$GITHUB_OUTPUT" + docker buildx imagetools inspect "${IMAGE_NAME}:main" fi - - # Retag the already-pushed release manifest as :latest. - - name: Move :latest to this release tag - if: steps.latest_check.outputs.push_latest == 'true' env: - RELEASE_TAG: ${{ needs.merge.outputs.release_tag }} - run: | - set -euo pipefail - image=nousresearch/hermes-agent - docker buildx imagetools create \ - --tag "${image}:latest" \ - "${image}:${RELEASE_TAG}" + IMAGE_NAME: ${{ env.IMAGE_NAME }} diff --git a/.gitignore b/.gitignore index 8bbe7235ee9f0..3c145df0e259c 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 @@ -74,4 +81,8 @@ website/static/api/skills-index.json models-dev-upstream/ hermes_cli/tui_dist/* hermes_cli/scripts/ -docs/superpowers/* \ No newline at end of file +docs/superpowers/* +# Working directory for the Hermes Agent's session state (~/.hermes/ at runtime; +# also created in-repo when an agent operates in this checkout). Plans, audit +# logs, and per-session caches are never artifacts of the codebase. +.hermes/ diff --git a/Dockerfile b/Dockerfile index be4e8848bb5aa..0a1ed56b47fa8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,12 @@ FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source +# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x +# which reached EOL in April 2026 — we copy node + npm + corepack from the +# upstream node:22 image instead so we can stay on a supported LTS without +# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used +# so the produced binary links against glibc 2.36, which runs cleanly on +# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major +# is a one-line ARG change; see #4977. +FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source FROM debian:13.4 # Disable Python stdout buffering to ensure logs are printed immediately @@ -17,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright # hermes process, the dashboard, and per-profile gateways. RUN apt-get update && \ apt-get install -y --no-install-recommends \ - build-essential curl nodejs npm python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \ + ca-certificates curl python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \ rm -rf /var/lib/apt/lists/* # ---------- s6-overlay install ---------- @@ -72,6 +80,18 @@ RUN useradd -u 10000 -m -d /opt/data hermes COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/ +# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS +# installs from the upstream image. npm and npx are recreated as symlinks +# because they're symlinks in the source image (and need to live on PATH). +# See node_source stage at the top of the file for the version-bump +# rationale (#4977). +COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/ +COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm +COPY --from=node_source /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/corepack +RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \ + ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \ + ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack + WORKDIR /opt/hermes # ---------- Layer-cached dependency install ---------- @@ -88,14 +108,15 @@ COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/ # `npm_config_install_links=false` forces npm to install `file:` deps as -# symlinks (the npm 10+ default) even on Debian's older bundled npm 9.x, -# which defaults to `install-links=true` and installs file deps as *copies*. -# The host-side package-lock.json is generated with a newer npm that uses -# symlinks, so an install-as-copy produces a hidden node_modules/.package-lock.json -# that permanently disagrees with the root lock on the @hermes/ink entry. -# That disagreement trips the TUI launcher's `_tui_need_npm_install()` -# check on every startup and triggers a runtime `npm install` that then -# fails with EACCES (node_modules/ is root-owned from build time). +# symlinks instead of copies. This is the default since npm 10+, which is +# what the image ships now (via the node:22 source stage). We set it +# explicitly anyway as defense-in-depth: the previous Debian-bundled npm +# 9.x defaulted to install-as-copy, which produced a hidden +# node_modules/.package-lock.json that permanently disagreed with the root +# lock on the @hermes/ink entry, tripped the TUI launcher's +# `_tui_need_npm_install()` check on every startup, and triggered a +# runtime `npm install` that then failed with EACCES. Keeping the env +# guards against a future regression if the source npm version changes. ENV npm_config_install_links=false RUN npm install --prefer-offline --no-audit && \ @@ -124,10 +145,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. @@ -179,7 +204,7 @@ COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/ # slots from $HERMES_HOME/profiles// after a container restart # (the /run/service/ scandir is tmpfs and wiped on restart). Phase 4. RUN mkdir -p /etc/cont-init.d && \ - printf '#!/bin/sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \ + printf '#!/command/with-contenv sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \ > /etc/cont-init.d/01-hermes-setup && \ chmod +x /etc/cont-init.d/01-hermes-setup COPY --chmod=0755 docker/cont-init.d/015-supervise-perms /etc/cont-init.d/015-supervise-perms diff --git a/agent/agent_init.py b/agent/agent_init.py index e20755c509194..6cfcb9f640b43 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -736,8 +736,8 @@ def init_agent( client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key) elif "default_headers" not in client_kwargs: # Fall back to profile.default_headers for providers that - # declare custom headers (e.g. Vercel AI Gateway attribution, - # Kimi User-Agent on non-kimi.com endpoints). + # declare custom headers (e.g. Kimi User-Agent on non-kimi.com + # endpoints). try: from providers import get_provider_profile as _gpf _ph = _gpf(agent.provider) @@ -1005,6 +1005,13 @@ def init_agent( # Track conversation messages for session logging agent._session_messages: List[Dict[str, Any]] = [] + # Responses encrypted reasoning replay state. Some OpenAI-compatible + # routes accept GPT-5 Responses requests but later reject replayed + # encrypted reasoning blobs (HTTP 400 ``invalid_encrypted_content``). + # When that happens we disable replay for the rest of the session and + # fall back to stateless continuity. See + # agent/conversation_loop.py's invalid_encrypted_content retry branch. + agent._codex_reasoning_replay_enabled = True agent._memory_write_origin = "assistant_tool" agent._memory_write_context = "foreground" diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index f0fbd0aa8c135..84691450b2c71 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1361,81 +1361,129 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo old_model = agent.model old_provider = agent.provider - # Clear the per-config context_length override so the new model's - # actual context window is resolved via get_model_context_length() - # instead of inheriting the stale value from the previous model. - agent._config_context_length = None - - # ── Swap core runtime fields ── - agent.model = new_model - agent.provider = new_provider - # Use new base_url when provided; only fall back to current when the - # new provider genuinely has no endpoint (e.g. native SDK providers). - # Without this guard the old provider's URL (e.g. Ollama's localhost - # address) would persist silently after switching to a cloud provider - # that returns an empty base_url string. - if base_url: - agent.base_url = base_url - agent.api_mode = api_mode - # Invalidate transport cache — new api_mode may need a different transport - if hasattr(agent, "_transport_cache"): - agent._transport_cache.clear() - if api_key: - agent.api_key = api_key - - # ── Build new client ── - if api_mode == "anthropic_messages": - from agent.anthropic_adapter import ( - build_anthropic_client, - resolve_anthropic_token, - _is_oauth_token, + # ── Snapshot all fields the swap+rebuild can mutate ── + # If the rebuild raises (bad API key, network error, build_anthropic_client + # failure, etc.) we restore these atomically so the agent isn't left with a + # new model/provider name paired with the OLD client — that mismatch causes + # HTTP 400s like "claude-sonnet-4-6 is not supported on openai-codex" on the + # next turn. Callers in cli.py / gateway/run.py / tui_gateway/server.py + # catch the re-raised exception and show the user a warning; without this + # rollback the warning is misleading because the swap partially succeeded. + # Use a sentinel so we can distinguish "attribute was unset" from + # "attribute was None" and skip the restore for genuinely-missing + # attributes (tests construct bare agents via __new__ without all fields). + _MISSING = object() + _snapshot = { + name: getattr(agent, name, _MISSING) + for name in ( + "model", + "provider", + "base_url", + "api_mode", + "api_key", + "client", + "_anthropic_client", + "_anthropic_api_key", + "_anthropic_base_url", + "_is_anthropic_oauth", + "_config_context_length", ) - # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. - # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own - # API key — falling back would send Anthropic credentials to third-party endpoints. - _is_native_anthropic = new_provider == "anthropic" - effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "") - - # MiniMax OAuth: swap static string for a per-request callable token - # provider so the rebuilt client survives 15-min token expiry. See - # the matching block in agent_init.py for the full rationale. - if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key: - try: - from hermes_cli.auth import build_minimax_oauth_token_provider - effective_key = build_minimax_oauth_token_provider() - except Exception as _mm_exc: # noqa: BLE001 - import logging as _logging - _logging.getLogger(__name__).warning( - "MiniMax OAuth: failed to install per-request token provider " - "on switch (%s); using static bearer.", - _mm_exc, - ) + } + # _client_kwargs is a dict — snapshot a shallow copy so mutating the + # live dict doesn't poison the rollback target. + _snapshot["_client_kwargs"] = dict(getattr(agent, "_client_kwargs", {}) or {}) - agent.api_key = effective_key - agent._anthropic_api_key = effective_key - agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None) - agent._anthropic_client = build_anthropic_client( - effective_key, agent._anthropic_base_url, - timeout=get_provider_request_timeout(agent.provider, agent.model), - ) - agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False - agent.client = None - agent._client_kwargs = {} - else: - effective_key = api_key or agent.api_key - effective_base = base_url or agent.base_url - agent._client_kwargs = { - "api_key": effective_key, - "base_url": effective_base, - } - _sm_timeout = get_provider_request_timeout(agent.provider, agent.model) - if _sm_timeout is not None: - agent._client_kwargs["timeout"] = _sm_timeout - agent.client = agent._create_openai_client( - dict(agent._client_kwargs), - reason="switch_model", - shared=True, - ) + try: + # Clear the per-config context_length override so the new model's + # actual context window is resolved via get_model_context_length() + # instead of inheriting the stale value from the previous model. + agent._config_context_length = None + + # ── Swap core runtime fields ── + agent.model = new_model + agent.provider = new_provider + # Use new base_url when provided; only fall back to current when the + # new provider genuinely has no endpoint (e.g. native SDK providers). + # Without this guard the old provider's URL (e.g. Ollama's localhost + # address) would persist silently after switching to a cloud provider + # that returns an empty base_url string. + if base_url: + agent.base_url = base_url + agent.api_mode = api_mode + # Invalidate transport cache — new api_mode may need a different transport + if hasattr(agent, "_transport_cache"): + agent._transport_cache.clear() + if api_key: + agent.api_key = api_key + + # ── Build new client ── + if api_mode == "anthropic_messages": + from agent.anthropic_adapter import ( + build_anthropic_client, + resolve_anthropic_token, + _is_oauth_token, + ) + # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. + # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own + # API key — falling back would send Anthropic credentials to third-party endpoints. + _is_native_anthropic = new_provider == "anthropic" + effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "") + + # MiniMax OAuth: swap static string for a per-request callable token + # provider so the rebuilt client survives 15-min token expiry. See + # the matching block in agent_init.py for the full rationale. + if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key: + try: + from hermes_cli.auth import build_minimax_oauth_token_provider + effective_key = build_minimax_oauth_token_provider() + except Exception as _mm_exc: # noqa: BLE001 + import logging as _logging + _logging.getLogger(__name__).warning( + "MiniMax OAuth: failed to install per-request token provider " + "on switch (%s); using static bearer.", + _mm_exc, + ) + + agent.api_key = effective_key + agent._anthropic_api_key = effective_key + agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None) + agent._anthropic_client = build_anthropic_client( + effective_key, agent._anthropic_base_url, + timeout=get_provider_request_timeout(agent.provider, agent.model), + ) + agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False + agent.client = None + agent._client_kwargs = {} + else: + effective_key = api_key or agent.api_key + effective_base = base_url or agent.base_url + agent._client_kwargs = { + "api_key": effective_key, + "base_url": effective_base, + } + _sm_timeout = get_provider_request_timeout(agent.provider, agent.model) + if _sm_timeout is not None: + agent._client_kwargs["timeout"] = _sm_timeout + agent.client = agent._create_openai_client( + dict(agent._client_kwargs), + reason="switch_model", + shared=True, + ) + except Exception: + # Rollback every mutated field to the pre-swap snapshot so the agent + # is left consistent (old model + old provider + old client) and the + # caller's exception handler can surface a meaningful warning. The + # exception is re-raised; cli.py / gateway/run.py / tui_gateway catch + # it and print "Agent swap failed; change applied to next session". + for _name, _value in _snapshot.items(): + if _value is _MISSING: + # Attribute did not exist before the swap — don't fabricate it. + continue + try: + setattr(agent, _name, _value) + except Exception: # noqa: BLE001 + pass + raise # ── Re-evaluate prompt caching ── agent._use_prompt_caching, agent._use_native_cache_layout = ( diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index e6d42dd21654b..233c299758c14 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -269,7 +269,6 @@ def _get_aux_model_for_provider(provider_id: str) -> str: "minimax-oauth": "MiniMax-M2.7-highspeed", "minimax-cn": "MiniMax-M2.7", "anthropic": "claude-haiku-4-5-20251001", - "ai-gateway": "google/gemini-3-flash", "opencode-zen": "gemini-3-flash", "opencode-go": "glm-5", "kilocode": "google/gemini-3-flash-preview", @@ -384,15 +383,6 @@ def build_nvidia_nim_headers(base_url: str | None) -> dict: return {} -# Vercel AI Gateway app attribution headers. HTTP-Referer maps to -# referrerUrl and X-Title maps to appName in the gateway's analytics. -from hermes_cli import __version__ as _HERMES_VERSION - -_AI_GATEWAY_HEADERS = { - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-Title": "Hermes Agent", - "User-Agent": f"HermesAgent/{_HERMES_VERSION}", -} # Nous Portal extra_body for product attribution. # Callers should pass this as extra_body in chat.completions.create() @@ -785,60 +775,53 @@ def _check_cancelled() -> None: pass try: - # Collect output items and text deltas during streaming — - # the Codex backend can return empty response.output from - # get_final_response() even when items were streamed. - collected_output_items: List[Any] = [] - collected_text_deltas: List[str] = [] - has_function_calls = False if total_timeout: timeout_timer = threading.Timer(float(total_timeout), _close_client_on_timeout) timeout_timer.daemon = True timeout_timer.start() _check_cancelled() - with self._client.responses.stream(**resp_kwargs) as stream: - for _event in stream: - _check_cancelled() - _etype = getattr(_event, "type", "") - if _etype == "response.output_item.done": - _done = getattr(_event, "item", None) - if _done is not None: - collected_output_items.append(_done) - elif "output_text.delta" in _etype: - _delta = getattr(_event, "delta", "") - if _delta: - collected_text_deltas.append(_delta) - elif "function_call" in _etype: - has_function_calls = True + + # Event-driven Responses streaming via the low-level + # ``responses.create(stream=True)`` path. The high-level + # ``responses.stream(...)`` helper does post-hoc typed + # reconstruction from ``response.completed.response.output``, + # which the chatgpt.com Codex backend has been observed to + # return as ``null`` (gpt-5.5, May 2026) — that crashes the SDK + # with ``TypeError: 'NoneType' object is not iterable``. + # Consuming raw events and assembling the final response + # ourselves from ``response.output_item.done`` makes us + # structurally immune to that drift. + from agent.codex_runtime import _consume_codex_event_stream + + stream_kwargs = dict(resp_kwargs) + stream_kwargs["stream"] = True + + def _on_each_event(_event: Any) -> None: + # Re-check timeout/cancellation per event, matching the + # cadence the old in-line ``_check_cancelled()`` used. _check_cancelled() - final = stream.get_final_response() - # Backfill empty output from collected stream events - _output = getattr(final, "output", None) - if isinstance(_output, list) and not _output: - if collected_output_items: - final.output = list(collected_output_items) - logger.debug( - "Codex auxiliary: backfilled %d output items from stream events", - len(collected_output_items), - ) - elif collected_text_deltas and not has_function_calls: - # Only synthesize text when no tool calls were streamed — - # a function_call response with incidental text should not - # be collapsed into a plain-text message. - assembled = "".join(collected_text_deltas) - final.output = [SimpleNamespace( - type="message", role="assistant", status="completed", - content=[SimpleNamespace(type="output_text", text=assembled)], - )] - logger.debug( - "Codex auxiliary: synthesized from %d deltas (%d chars)", - len(collected_text_deltas), len(assembled), - ) + event_stream = self._client.responses.create(**stream_kwargs) + try: + final = _consume_codex_event_stream( + event_stream, + model=resp_kwargs.get("model"), + on_event=_on_each_event, + ) + finally: + close_fn = getattr(event_stream, "close", None) + if callable(close_fn): + try: + close_fn() + except Exception: + pass + + if final is None: + raise RuntimeError("Codex auxiliary Responses stream did not return a final response") # Extract text and tool calls from the Responses output. - # Items may be SDK objects (attrs) or dicts (raw/fallback paths), - # so use a helper that handles both shapes. + # Items may be SimpleNamespace (raw-event path) or dicts + # (some legacy fallback paths), so handle both shapes. def _item_get(obj: Any, key: str, default: Any = None) -> Any: val = getattr(obj, key, None) if val is None and isinstance(obj, dict): @@ -865,9 +848,12 @@ def _item_get(obj: Any, key: str, default: Any = None) -> Any: resp_usage = getattr(final, "usage", None) if resp_usage: usage = SimpleNamespace( - prompt_tokens=getattr(resp_usage, "input_tokens", 0), - completion_tokens=getattr(resp_usage, "output_tokens", 0), - total_tokens=getattr(resp_usage, "total_tokens", 0), + prompt_tokens=getattr(resp_usage, "input_tokens", 0) + or (resp_usage.get("input_tokens", 0) if isinstance(resp_usage, dict) else 0), + completion_tokens=getattr(resp_usage, "output_tokens", 0) + or (resp_usage.get("output_tokens", 0) if isinstance(resp_usage, dict) else 0), + total_tokens=getattr(resp_usage, "total_tokens", 0) + or (resp_usage.get("total_tokens", 0) if isinstance(resp_usage, dict) else 0), ) except Exception as exc: if timed_out.is_set(): @@ -3613,8 +3599,7 @@ def _wrap_if_needed(client_obj, final_model_str: str, base_url_str: str = "", else: # Fall back to profile.default_headers for providers that declare # client-level attribution headers on their profile (e.g. GMI - # User-Agent for traffic identification, Vercel AI Gateway - # Referer/Title for analytics). + # User-Agent for traffic identification). try: from providers import get_provider_profile as _gpf_main _ph_main = _gpf_main(provider) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 8fe6bcd20cbc0..2b2883591a788 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -313,6 +313,13 @@ def _call(): and _elapsed > _ttfb_timeout and getattr(agent, "_codex_stream_last_event_ts", None) is None ): + _silent_hint: Optional[str] = None + _hint_fn = getattr(agent, "_codex_silent_hang_hint", None) + if callable(_hint_fn): + try: + _silent_hint = _hint_fn(model=api_kwargs.get("model")) + except Exception: + _silent_hint = None logger.warning( "Codex stream produced no bytes within TTFB cutoff " "(%.0fs > %.0fs, model=%s). Backend accepted the connection " @@ -320,11 +327,18 @@ def _call(): "loop can reconnect.", _elapsed, _ttfb_timeout, api_kwargs.get("model", "unknown"), ) - agent._emit_status( - f"⚠️ No first byte from provider in {int(_elapsed)}s " - f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). " - f"Reconnecting." - ) + if _silent_hint: + agent._emit_status( + f"⚠️ No first byte from provider in {int(_elapsed)}s " + f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). " + f"Reconnecting. {_silent_hint}" + ) + else: + agent._emit_status( + f"⚠️ No first byte from provider in {int(_elapsed)}s " + f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). " + f"Reconnecting." + ) try: _close_request_client_once("codex_ttfb_kill") except Exception: @@ -335,10 +349,16 @@ def _call(): # Wait briefly for the worker to notice the closed connection. t.join(timeout=2.0) if result["error"] is None and result["response"] is None: - result["error"] = TimeoutError( - f"Codex stream produced no bytes within {int(_elapsed)}s " - f"(TTFB threshold: {int(_ttfb_timeout)}s)" - ) + if _silent_hint: + result["error"] = TimeoutError( + f"Codex stream produced no bytes within {int(_elapsed)}s " + f"(TTFB threshold: {int(_ttfb_timeout)}s). {_silent_hint}" + ) + else: + result["error"] = TimeoutError( + f"Codex stream produced no bytes within {int(_elapsed)}s " + f"(TTFB threshold: {int(_ttfb_timeout)}s)" + ) break # Stale-call detector: kill the connection if no response @@ -507,6 +527,9 @@ def build_api_kwargs(agent, api_messages: list) -> dict: is_codex_backend=is_codex_backend, is_xai_responses=is_xai_responses, github_reasoning_extra=agent._github_models_reasoning_extra_body() if is_github_responses else None, + replay_encrypted_reasoning=bool( + getattr(agent, "_codex_reasoning_replay_enabled", True) + ), ) # ── chat_completions (default) ───────────────────────────────────── diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index 07ae5cc950689..230a6e613b10e 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -23,6 +23,38 @@ logger = logging.getLogger(__name__) +def _classify_responses_issuer( + *, + is_xai_responses: bool = False, + is_github_responses: bool = False, + is_codex_backend: bool = False, + base_url: Optional[str] = None, +) -> str: + """Stable identifier for the Responses endpoint that mints encrypted_content. + + ``reasoning.encrypted_content`` is sealed to the endpoint that issued it: + replaying a Codex-minted blob against xAI (or vice versa) deterministically + returns HTTP 400 ``invalid_encrypted_content``. Stamping the issuer on + persisted reasoning items and filtering at replay time lets a single + conversation switch models without poisoning history with un-decryptable + reasoning blocks. + """ + if is_xai_responses: + return "xai_responses" + if is_github_responses: + return "github_responses" + if is_codex_backend: + return "codex_backend" + if base_url: + return f"other:{base_url}" + return "other" + + +# Throttle the per-process cross-issuer skip warning so we don't flood logs +# when a long history contains many stale-issuer reasoning blocks. +_CROSS_ISSUER_WARN_EMITTED = False + + # Matches Codex/Harmony tool-call serialization that occasionally leaks into # assistant-message content when the model fails to emit a structured # ``function_call`` item. Accepts the common forms: @@ -248,6 +280,8 @@ def _chat_messages_to_responses_input( messages: List[Dict[str, Any]], *, is_xai_responses: bool = False, + replay_encrypted_reasoning: bool = True, + current_issuer_kind: Optional[str] = None, ) -> List[Dict[str, Any]]: """Convert internal chat-style messages to Responses input items. @@ -261,6 +295,27 @@ def _chat_messages_to_responses_input( integration). We now replay encrypted reasoning on every Responses transport (xAI, native Codex, custom relays) and let xAI tell us explicitly if a specific surface ever rejects a payload. + + ``replay_encrypted_reasoning`` is the per-session kill switch. Some + OpenAI-compatible relays accept the request but later reject the + replayed encrypted blob with HTTP 400 ``invalid_encrypted_content``; + when that happens the retry loop calls + ``AIAgent._disable_codex_reasoning_replay`` which both strips cached + items from the conversation history and threads ``replay_enabled=False`` + through this converter so subsequent turns send no reasoning items. + + ``current_issuer_kind`` enables a per-item cross-issuer guard. The + Responses API's ``encrypted_content`` blob is decryptable only by the + endpoint that minted it — replaying a Codex-issued blob against xAI + (or vice versa) always yields HTTP 400 ``invalid_encrypted_content`` + and breaks every subsequent turn in the same session. When this + argument is provided and a reasoning item carries an ``_issuer_kind`` + stamp from a different endpoint, the item is dropped from the replayed + input. Legacy items without a stamp are still replayed + (backwards-compatible). The two guards compose: + ``replay_encrypted_reasoning=False`` is the session-wide kill switch + (drops ALL replay); ``current_issuer_kind`` is the per-item filter + that runs only when replay is still enabled. """ items: List[Dict[str, Any]] = [] seen_item_ids: set = set() @@ -290,7 +345,11 @@ def _chat_messages_to_responses_input( # This applies to every Responses transport including # xAI — see _chat_messages_to_responses_input docstring # for the May 2026 reversal of the earlier xAI gate. - codex_reasoning = msg.get("codex_reasoning_items") + codex_reasoning = ( + msg.get("codex_reasoning_items") + if replay_encrypted_reasoning + else None + ) has_codex_reasoning = False if isinstance(codex_reasoning, list): for ri in codex_reasoning: @@ -298,11 +357,40 @@ def _chat_messages_to_responses_input( item_id = ri.get("id") if item_id and item_id in seen_item_ids: continue + # Cross-issuer guard: drop reasoning blocks that + # were minted by a different Responses endpoint. + # The current endpoint cannot decrypt foreign + # encrypted_content and would reject the whole + # request with HTTP 400 invalid_encrypted_content. + # Unstamped (legacy) items pass through. + item_issuer = ri.get("_issuer_kind") + if ( + current_issuer_kind is not None + and item_issuer is not None + and item_issuer != current_issuer_kind + ): + global _CROSS_ISSUER_WARN_EMITTED + if not _CROSS_ISSUER_WARN_EMITTED: + logger.warning( + "Dropping reasoning item minted by %s while " + "calling %s — encrypted_content is sealed to " + "its issuer. This happens when a session " + "switches model providers mid-conversation.", + item_issuer, current_issuer_kind, + ) + _CROSS_ISSUER_WARN_EMITTED = True + continue # Strip the "id" field — with store=False the # Responses API cannot look up items by ID and # returns 404. The encrypted_content blob is # self-contained for reasoning chain continuity. - replay_item = {k: v for k, v in ri.items() if k != "id"} + # Also strip the internal "_issuer_kind" stamp; + # it is a Hermes-side metadata key and not part + # of the Responses API schema. + replay_item = { + k: v for k, v in ri.items() + if k not in ("id", "_issuer_kind") + } items.append(replay_item) if item_id: seen_item_ids.add(item_id) @@ -825,6 +913,26 @@ def _preflight_codex_api_kwargs( elif "stream" in api_kwargs: raise ValueError("Codex Responses stream flag is only allowed in fallback streaming requests.") + # Safety-net sanitization for xAI Responses (#28490): defense-in-depth + # for the same slash-enum strip that ``chat_completion_helpers`` and + # ``auxiliary_client`` apply at request-build time. If a future code + # path forgets to sanitize before calling us, this catches the bypass + # so xAI doesn't 400 with ``Invalid arguments passed to the model`` + # (HuggingFace IDs like ``Qwen/Qwen3.5-0.8B`` from MCP tool schemas). + # + # Gated on the model name pattern because native Codex (OpenAI) DOES + # accept slash-containing enum values — stripping them there would + # silently degrade tool-schema constraints. xAI is the only + # Responses-API surface that rejects the shape. + model_name_for_provider_check = str(api_kwargs.get("model") or "").lower() + is_xai_model = model_name_for_provider_check.startswith(("grok-", "x-ai/grok-")) + if is_xai_model and normalized.get("tools"): + try: + from tools.schema_sanitizer import strip_slash_enum + normalized["tools"], _ = strip_slash_enum(normalized["tools"]) + except Exception: + pass # Best-effort — the caller-level sanitization should have handled it + unexpected = sorted(key for key in api_kwargs if key not in allowed_keys) if unexpected: raise ValueError( @@ -876,8 +984,18 @@ def _extract_responses_reasoning_text(item: Any) -> str: # Full response normalization # --------------------------------------------------------------------------- -def _normalize_codex_response(response: Any) -> tuple[Any, str]: - """Normalize a Responses API object to an assistant_message-like object.""" +def _normalize_codex_response( + response: Any, + *, + issuer_kind: Optional[str] = None, +) -> tuple[Any, str]: + """Normalize a Responses API object to an assistant_message-like object. + + ``issuer_kind`` (when provided) is stamped onto each reasoning item the + response yields, so future replays can detect when the active endpoint + differs from the one that minted the encrypted_content blob and drop + the item instead of triggering HTTP 400 invalid_encrypted_content. + """ output = getattr(response, "output", None) if not isinstance(output, list) or not output: # The Codex backend can return empty output when the answer was @@ -919,6 +1037,7 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]: has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"} saw_commentary_phase = False saw_final_answer_phase = False + saw_reasoning_item = False for item in output: item_type = getattr(item, "type", None) @@ -956,6 +1075,7 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]: raw_message_item["phase"] = normalized_phase message_items_raw.append(raw_message_item) elif item_type == "reasoning": + saw_reasoning_item = True reasoning_text = _extract_responses_reasoning_text(item) if reasoning_text: reasoning_parts.append(reasoning_text) @@ -965,7 +1085,19 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]: encrypted = getattr(item, "encrypted_content", None) if isinstance(encrypted, str) and encrypted: raw_item = {"type": "reasoning", "encrypted_content": encrypted} + # Stamp the issuer so future turns can detect when a + # model swap moved the conversation to an endpoint that + # cannot decrypt this blob — see _chat_messages_to_responses_input + # cross-issuer guard. + if issuer_kind: + raw_item["_issuer_kind"] = issuer_kind item_id = getattr(item, "id", None) + if isinstance(item_id, str) and item_id.startswith("rs_tmp_"): + logger.debug( + "Skipping transient Codex reasoning item during normalization: %s", + item_id, + ) + continue if isinstance(item_id, str) and item_id: raw_item["id"] = item_id # Capture summary — required by the API when replaying reasoning items @@ -1076,13 +1208,13 @@ def _normalize_codex_response(response: Any) -> tuple[Any, str]: finish_reason = "incomplete" elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase): finish_reason = "incomplete" - elif reasoning_items_raw and not final_text: - # Response contains only reasoning (encrypted thinking state) with - # no visible content or tool calls. The model is still thinking and - # needs another turn to produce the actual answer. Marking this as - # "stop" would send it into the empty-content retry loop which burns - # 3 retries then fails — treat it as incomplete instead so the Codex - # continuation path handles it correctly. + elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text: + # Response contains only reasoning (encrypted thinking state and/or + # human-readable summary) with no visible content or tool calls. The + # model is still thinking and needs another turn to produce the actual + # answer. Marking this as "stop" would send it into the empty-content + # retry loop which burns retries then fails — treat it as incomplete so + # the Codex continuation path handles it correctly. finish_reason = "incomplete" else: finish_reason = "stop" diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 8c5dff39bffee..e2bcbfc824b2e 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -174,281 +174,363 @@ def run_codex_app_server_turn( } +# --------------------------------------------------------------------------- +# Event-driven Responses streaming +# +# OpenAI ships its consumer Codex backend (chatgpt.com/backend-api/codex) on +# a different schedule from the openai Python SDK. The high-level +# ``client.responses.stream(...)`` helper reconstructs a typed Response from +# the terminal ``response.completed`` event's ``response.output`` field, and +# when that field drifts to ``null`` (gpt-5.5, May 2026) the SDK raises +# ``TypeError: 'NoneType' object is not iterable`` mid-iteration. +# +# We sidestep the whole class of failure by going one level lower: +# ``client.responses.create(stream=True)`` returns the raw AsyncIterable of +# SSE events, and we assemble the final response object purely from +# ``response.output_item.done`` events as they arrive. We never read +# ``response.completed.response.output`` for content reconstruction, so the +# backend can return ``null``, ``[]``, a string, or omit the field entirely +# and we don't care. +# +# This mirrors what the OpenClaw TS implementation does for the same backend +# and is structurally immune to the bug class rather than patched. +# --------------------------------------------------------------------------- + + +_TERMINAL_EVENT_TYPES = frozenset({ + "response.completed", + "response.incomplete", + "response.failed", +}) + + +def _event_field(event: Any, name: str, default: Any = None) -> Any: + """Field access that handles both attr-style (SDK objects) and dict (raw JSON) events.""" + value = getattr(event, name, None) + if value is None and isinstance(event, dict): + value = event.get(name, default) + return value if value is not None else default + + +def _raise_stream_error(event: Any) -> None: + """Raise a ``_StreamErrorEvent`` from a ``type=error`` SSE frame. + + Imported lazily so this module stays importable from places that don't + pull in ``run_agent`` (e.g. plugin code, doc tools). + """ + from run_agent import _StreamErrorEvent + message = (_event_field(event, "message", "") or "stream emitted error event").strip() + raise _StreamErrorEvent( + message, + code=_event_field(event, "code"), + param=_event_field(event, "param"), + ) + + +def _consume_codex_event_stream( + event_iter: Any, + *, + model: str, + on_text_delta=None, + on_reasoning_delta=None, + on_first_delta=None, + on_event=None, + interrupt_check=None, +) -> SimpleNamespace: + """Consume a Codex Responses SSE event stream and return a final response. + + The returned object is a ``SimpleNamespace`` shaped like the SDK's typed + ``Response`` for the fields downstream code actually reads: + + * ``output``: list of output items, assembled from ``response.output_item.done``. + For tool-call turns this contains the function_call items; for plain-text + turns it contains a synthesized ``message`` item built from streamed deltas + if no message item was emitted directly. + * ``output_text``: assembled text from ``response.output_text.delta`` deltas. + * ``usage``: copied from the terminal event's ``response.usage`` (when present). + * ``status``: ``completed`` / ``incomplete`` / ``failed`` (or ``completed`` if + the stream ended without a terminal frame but produced content). + * ``id``: ``response.id`` when present. + * ``incomplete_details``: passed through for ``response.incomplete`` frames. + * ``error``: passed through for ``response.failed`` frames. + * ``model``: from kwargs (the wire model name is not authoritative). + + Critically, we never read ``response.output`` from the terminal event for + content reconstruction — only ``usage``, ``status``, ``id``. That field + being ``null`` / ``[]`` / missing is fine. + + Callbacks: + + * ``on_text_delta(str)`` — fires per ``response.output_text.delta``, suppressed + once a function_call event is seen (so tool-call turns don't bleed text + into the chat). + * ``on_reasoning_delta(str)`` — fires per ``response.reasoning.*.delta``. + * ``on_first_delta()`` — one-shot, fires on the first text delta only. + * ``on_event(event)`` — fires for every event before any other processing. + Used for watchdog activity, debug logging, anything wire-shape-agnostic. + * ``interrupt_check()`` — returns True to break the loop early. + """ + collected_output_items: List[Any] = [] + collected_text_deltas: List[str] = [] + has_tool_calls = False + first_delta_fired = False + terminal_status: str = "completed" + terminal_usage: Any = None + terminal_response_id: str = None + terminal_incomplete_details: Any = None + terminal_error: Any = None + saw_terminal = False + + for event in event_iter: + if on_event is not None: + try: + on_event(event) + except (TimeoutError, InterruptedError): + # Control-flow signals from watchdog/cancellation hooks must + # propagate, not get swallowed as "debug noise". + raise + except Exception: + # Genuine bugs in third-party debug/log hooks shouldn't break + # stream consumption. + logger.debug("Codex stream on_event hook raised", exc_info=True) + if interrupt_check is not None and interrupt_check(): + break + + event_type = _event_field(event, "type", "") + if not isinstance(event_type, str): + event_type = "" + + # ``error`` SSE frames carry the provider's real failure reason + # (subscription / quota / model-not-available / rejected-reasoning-replay) + # but never appear in the terminal set. Surface them as a structured + # exception so the credential pool + error classifier see the body. + if event_type == "error": + _raise_stream_error(event) + + if "output_text.delta" in event_type or event_type == "response.output_text.delta": + delta_text = _event_field(event, "delta", "") + if delta_text: + collected_text_deltas.append(delta_text) + if not has_tool_calls: + if not first_delta_fired: + first_delta_fired = True + if on_first_delta is not None: + try: + on_first_delta() + except Exception: + logger.debug("Codex stream on_first_delta raised", exc_info=True) + if on_text_delta is not None: + try: + on_text_delta(delta_text) + except Exception: + logger.debug("Codex stream on_text_delta raised", exc_info=True) + continue + + if "function_call" in event_type: + has_tool_calls = True + # fall through — function_call items still get added on output_item.done + + if "reasoning" in event_type and "delta" in event_type: + reasoning_text = _event_field(event, "delta", "") + if reasoning_text and on_reasoning_delta is not None: + try: + on_reasoning_delta(reasoning_text) + except Exception: + logger.debug("Codex stream on_reasoning_delta raised", exc_info=True) + continue + + if event_type == "response.output_item.done": + done_item = _event_field(event, "item") + if done_item is not None: + collected_output_items.append(done_item) + continue + + if event_type in _TERMINAL_EVENT_TYPES: + saw_terminal = True + resp_obj = _event_field(event, "response") + if resp_obj is not None: + terminal_usage = getattr(resp_obj, "usage", None) + if terminal_usage is None and isinstance(resp_obj, dict): + terminal_usage = resp_obj.get("usage") + rid = getattr(resp_obj, "id", None) + if rid is None and isinstance(resp_obj, dict): + rid = resp_obj.get("id") + terminal_response_id = rid + rstatus = getattr(resp_obj, "status", None) + if rstatus is None and isinstance(resp_obj, dict): + rstatus = resp_obj.get("status") + if isinstance(rstatus, str): + terminal_status = rstatus + if event_type == "response.incomplete": + terminal_incomplete_details = getattr(resp_obj, "incomplete_details", None) + if terminal_incomplete_details is None and isinstance(resp_obj, dict): + terminal_incomplete_details = resp_obj.get("incomplete_details") + if event_type == "response.failed": + terminal_error = getattr(resp_obj, "error", None) + if terminal_error is None and isinstance(resp_obj, dict): + terminal_error = resp_obj.get("error") + if event_type == "response.completed": + terminal_status = terminal_status or "completed" + elif event_type == "response.incomplete": + terminal_status = terminal_status or "incomplete" + elif event_type == "response.failed": + terminal_status = terminal_status or "failed" + # Stop on terminal event. + break + + # Build the final output list. Prefer items observed via output_item.done; + # if none arrived but we streamed plain text deltas (no tool calls), synthesize + # a single message item so downstream normalization has something to work with. + if collected_output_items: + output = list(collected_output_items) + elif collected_text_deltas and not has_tool_calls: + assembled = "".join(collected_text_deltas) + output = [SimpleNamespace( + type="message", + role="assistant", + status="completed", + content=[SimpleNamespace(type="output_text", text=assembled)], + )] + else: + output = [] + + # If the stream ended without any terminal event AND produced no usable + # content (no items, no text deltas), surface that as a RuntimeError so + # callers can distinguish "stream truncated mid-flight / provider rejected + # the call" from "stream completed with empty body". This preserves the + # signal the SDK's high-level helper used to raise as + # ``RuntimeError("Didn't receive a `response.completed` event.")``. + if not saw_terminal and not output: + raise RuntimeError( + "Codex Responses stream did not emit a terminal response" + ) + assembled_text = "".join(collected_text_deltas) + + final = SimpleNamespace( + output=output, + output_text=assembled_text, + usage=terminal_usage, + status=terminal_status, + id=terminal_response_id, + model=model, + incomplete_details=terminal_incomplete_details, + error=terminal_error, + ) + return final + + +def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta=None): + """Execute one streaming Responses API request and return the final response. -def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta: callable = None): - """Execute one streaming Responses API request and return the final response.""" + Uses ``responses.create(stream=True)`` (low-level raw event iteration) + rather than the high-level ``responses.stream(...)`` helper. This makes + us structurally immune to backend drift in the ``response.completed`` + payload shape — we never let the SDK reconstruct a typed object from + the terminal event's ``output`` field. + """ import httpx as _httpx active_client = client or agent._ensure_primary_openai_client(reason="codex_stream_direct") max_stream_retries = 1 - has_tool_calls = False - first_delta_fired = False - # Accumulate streamed text so we can recover if get_final_response() - # returns empty output (e.g. chatgpt.com backend-api sends - # response.incomplete instead of response.completed). + # Accumulate streamed text so callers / compat shims can read it. agent._codex_streamed_text_parts: list = [] + + def _on_text_delta(text: str) -> None: + agent._codex_streamed_text_parts.append(text) + agent._fire_stream_delta(text) + + def _on_reasoning_delta(text: str) -> None: + agent._fire_reasoning_delta(text) + + def _on_event(event: Any) -> None: + # TTFB watchdog and activity touch — runs once per SSE event. + agent._codex_stream_last_event_ts = time.time() + agent._touch_activity("receiving stream response") + + def _interrupt_check() -> bool: + return bool(agent._interrupt_requested) + for attempt in range(max_stream_retries + 1): if agent._interrupt_requested: raise InterruptedError("Agent interrupted before Codex stream retry") - collected_output_items: list = [] + + stream_kwargs = dict(api_kwargs) + stream_kwargs["stream"] = True + try: - with active_client.responses.stream(**api_kwargs) as stream: - for event in stream: - # Mark stream activity for the TTFB watchdog in - # interruptible_api_call. The Codex backend can accept the - # connection but never emit a single event; this timestamp - # staying None tells the watchdog no bytes are flowing. - agent._codex_stream_last_event_ts = time.time() - agent._touch_activity("receiving stream response") - if agent._interrupt_requested: - break - event_type = getattr(event, "type", "") - # Fire callbacks on text content deltas (suppress during tool calls) - if "output_text.delta" in event_type or event_type == "response.output_text.delta": - delta_text = getattr(event, "delta", "") - if delta_text: - agent._codex_streamed_text_parts.append(delta_text) - if delta_text and not has_tool_calls: - if not first_delta_fired: - first_delta_fired = True - if on_first_delta: - try: - on_first_delta() - except Exception: - pass - agent._fire_stream_delta(delta_text) - # Track tool calls to suppress text streaming - elif "function_call" in event_type: - has_tool_calls = True - # Fire reasoning callbacks - elif "reasoning" in event_type and "delta" in event_type: - reasoning_text = getattr(event, "delta", "") - if reasoning_text: - agent._fire_reasoning_delta(reasoning_text) - # Collect completed output items — some backends - # (chatgpt.com/backend-api/codex) stream valid items - # via response.output_item.done but the SDK's - # get_final_response() returns an empty output list. - elif event_type == "response.output_item.done": - done_item = getattr(event, "item", None) - if done_item is not None: - collected_output_items.append(done_item) - # Log non-completed terminal events for diagnostics - elif event_type in {"response.incomplete", "response.failed"}: - resp_obj = getattr(event, "response", None) - status = getattr(resp_obj, "status", None) if resp_obj else None - incomplete_details = getattr(resp_obj, "incomplete_details", None) if resp_obj else None - logger.warning( - "Codex Responses stream received terminal event %s " - "(status=%s, incomplete_details=%s, streamed_chars=%d). %s", - event_type, status, incomplete_details, - sum(len(p) for p in agent._codex_streamed_text_parts), - agent._client_log_context(), - ) - final_response = stream.get_final_response() - # PATCH: ChatGPT Codex backend streams valid output items - # but get_final_response() can return an empty output list. - # Backfill from collected items or synthesize from deltas. - _out = getattr(final_response, "output", None) - if isinstance(_out, list) and not _out: - if collected_output_items: - final_response.output = list(collected_output_items) - logger.debug( - "Codex stream: backfilled %d output items from stream events", - len(collected_output_items), - ) - elif agent._codex_streamed_text_parts and not has_tool_calls: - assembled = "".join(agent._codex_streamed_text_parts) - final_response.output = [SimpleNamespace( - type="message", - role="assistant", - status="completed", - content=[SimpleNamespace(type="output_text", text=assembled)], - )] - logger.debug( - "Codex stream: synthesized output from %d text deltas (%d chars)", - len(agent._codex_streamed_text_parts), len(assembled), - ) - return final_response + event_stream = active_client.responses.create(**stream_kwargs) except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc: if attempt < max_stream_retries: logger.debug( - "Codex Responses stream transport failed (attempt %s/%s); retrying. %s error=%s", - attempt + 1, - max_stream_retries + 1, - agent._client_log_context(), - exc, + "Codex Responses stream connect failed (attempt %s/%s); retrying. %s error=%s", + attempt + 1, max_stream_retries + 1, + agent._client_log_context(), exc, ) continue - logger.debug( - "Codex Responses stream transport failed; falling back to create(stream=True). %s error=%s", - agent._client_log_context(), - exc, - ) - return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client) - except RuntimeError as exc: - err_text = str(exc) - missing_completed = "response.completed" in err_text - # The OpenAI SDK's Responses streaming state machine raises - # ``RuntimeError("Expected to have received `response.created` - # before ``")`` when the first SSE event from the - # server is anything other than ``response.created`` — and it - # discards the event's payload before we can read it. Three - # real-world backends emit a different first frame: - # - # * xAI on grok-4.x OAuth — sends ``error`` (issues - # reported around the May 2026 SuperGrok rollout when - # multi-turn conversations replay encrypted reasoning - # content the OAuth tier rejects) - # * codex-lb relays — send ``codex.rate_limits`` (#14634) - # * custom Responses relays — send ``response.in_progress`` - # (#8133) - # - # In all three cases the underlying byte stream is still - # readable: a non-stream ``responses.create(stream=True)`` - # fallback succeeds and surfaces the real provider error as - # a normal exception with body+status_code attached, which - # ``_summarize_api_error`` can then translate into a useful - # user-facing line. Treat ``response.created`` prelude - # errors the same way we already treat ``response.completed`` - # postlude errors. - prelude_error = ( - "Expected to have received `response.created`" in err_text - or "Expected to have received \"response.created\"" in err_text - ) - if (missing_completed or prelude_error) and attempt < max_stream_retries: - logger.debug( - "Responses stream %s (attempt %s/%s); retrying. %s", - "prelude rejected" if prelude_error else "closed before completion", - attempt + 1, - max_stream_retries + 1, - agent._client_log_context(), + raise + + try: + # Compatibility: some mocks/providers return a concrete response + # instead of an iterable. Pass it straight through. + if hasattr(event_stream, "output") and not hasattr(event_stream, "__iter__"): + return event_stream + + try: + final = _consume_codex_event_stream( + event_stream, + model=api_kwargs.get("model"), + on_text_delta=_on_text_delta, + on_reasoning_delta=_on_reasoning_delta, + on_first_delta=on_first_delta, + on_event=_on_event, + interrupt_check=_interrupt_check, ) - continue - if missing_completed or prelude_error: - logger.debug( - "Responses stream %s; falling back to create(stream=True). %s err=%s", - "rejected before response.created" if prelude_error else "did not emit response.completed", + except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc: + if attempt < max_stream_retries: + logger.debug( + "Codex Responses stream transport failed mid-iteration " + "(attempt %s/%s); retrying. %s error=%s", + attempt + 1, max_stream_retries + 1, + agent._client_log_context(), exc, + ) + continue + raise + + if final.status in {"incomplete", "failed"}: + logger.warning( + "Codex Responses stream terminal status=%s " + "(incomplete_details=%s, error=%s, streamed_chars=%d). %s", + final.status, final.incomplete_details, final.error, + sum(len(p) for p in agent._codex_streamed_text_parts), agent._client_log_context(), - err_text, ) - return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client) - raise + return final + finally: + close_fn = getattr(event_stream, "close", None) + if callable(close_fn): + try: + close_fn() + except Exception: + pass def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None): - """Fallback path for stream completion edge cases on Codex-style Responses backends.""" - active_client = client or agent._ensure_primary_openai_client(reason="codex_create_stream_fallback") - fallback_kwargs = dict(api_kwargs) - fallback_kwargs["stream"] = True - fallback_kwargs = agent._get_transport().preflight_kwargs(fallback_kwargs, allow_stream=True) - stream_or_response = active_client.responses.create(**fallback_kwargs) - - # Compatibility shim for mocks or providers that still return a concrete response. - if hasattr(stream_or_response, "output"): - return stream_or_response - if not hasattr(stream_or_response, "__iter__"): - return stream_or_response - - terminal_response = None - collected_output_items: list = [] - collected_text_deltas: list = [] - try: - for event in stream_or_response: - agent._touch_activity("receiving stream response") - event_type = getattr(event, "type", None) - if not event_type and isinstance(event, dict): - event_type = event.get("type") - - # ``error`` SSE frames carry the provider's real failure - # reason (subscription / quota / model-not-available / - # rejected-reasoning-replay) but never appear in the - # ``{completed, incomplete, failed}`` terminal set, so the - # raw loop below would silently consume them and end with - # "did not emit a terminal response". xAI in particular - # emits ``type=error`` as the FIRST frame for OAuth - # accounts whose Grok subscription is missing/exhausted — - # the SDK's stream helper raises ``RuntimeError(Expected - # to have received response.created before error)`` which - # the caller catches and routes here, expecting this - # fallback to surface the message. Synthesize an - # APIError-shaped exception so ``_summarize_api_error`` - # and the credential-pool entitlement detector see the - # real text instead of a generic RuntimeError. - if event_type == "error": - err_message = getattr(event, "message", None) - if not err_message and isinstance(event, dict): - err_message = event.get("message") - err_code = getattr(event, "code", None) - if not err_code and isinstance(event, dict): - err_code = event.get("code") - err_param = getattr(event, "param", None) - if not err_param and isinstance(event, dict): - err_param = event.get("param") - err_message = (err_message or "stream emitted error event").strip() - from run_agent import _StreamErrorEvent - raise _StreamErrorEvent(err_message, code=err_code, param=err_param) - - # Collect output items and text deltas for backfill - if event_type == "response.output_item.done": - done_item = getattr(event, "item", None) - if done_item is None and isinstance(event, dict): - done_item = event.get("item") - if done_item is not None: - collected_output_items.append(done_item) - elif event_type in {"response.output_text.delta",}: - delta = getattr(event, "delta", "") - if not delta and isinstance(event, dict): - delta = event.get("delta", "") - if delta: - collected_text_deltas.append(delta) - - if event_type not in {"response.completed", "response.incomplete", "response.failed"}: - continue - - terminal_response = getattr(event, "response", None) - if terminal_response is None and isinstance(event, dict): - terminal_response = event.get("response") - if terminal_response is not None: - # Backfill empty output from collected stream events - _out = getattr(terminal_response, "output", None) - if isinstance(_out, list) and not _out: - if collected_output_items: - terminal_response.output = list(collected_output_items) - logger.debug( - "Codex fallback stream: backfilled %d output items", - len(collected_output_items), - ) - elif collected_text_deltas: - assembled = "".join(collected_text_deltas) - terminal_response.output = [SimpleNamespace( - type="message", role="assistant", - status="completed", - content=[SimpleNamespace(type="output_text", text=assembled)], - )] - logger.debug( - "Codex fallback stream: synthesized from %d deltas (%d chars)", - len(collected_text_deltas), len(assembled), - ) - return terminal_response - finally: - close_fn = getattr(stream_or_response, "close", None) - if callable(close_fn): - try: - close_fn() - except Exception: - pass - - if terminal_response is not None: - return terminal_response - raise RuntimeError("Responses create(stream=True) fallback did not emit a terminal response.") + """Backward-compatible alias for the unified event-driven path. + Historically this was the fallback when the SDK's high-level + ``responses.stream(...)`` helper raised on shape drift. The primary + path now does exactly what the fallback did, so this just forwards. + Kept as a public symbol because tests and a small number of call sites + still reference it by name. + """ + return run_codex_stream(agent, api_kwargs, client=client) __all__ = [ "run_codex_app_server_turn", "run_codex_stream", "run_codex_create_stream_fallback", + "_consume_codex_event_stream", ] diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index abcb342d04f23..078c62771ede6 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -1019,6 +1019,7 @@ def run_conversation( nous_auth_retry_attempted=False copilot_auth_retry_attempted=False thinking_sig_retry_attempted = False + invalid_encrypted_content_retry_attempted = False image_shrink_retry_attempted = False multimodal_tool_content_retry_attempted = False oauth_1m_beta_retry_attempted = False @@ -2218,7 +2219,7 @@ def _stop_spinner(): print(f"{agent.log_prefix} Response: {_body_text}") print(f"{agent.log_prefix} Most likely: Portal OAuth expired, account out of credits, or agent key revoked.") print(f"{agent.log_prefix} Troubleshooting:") - print(f"{agent.log_prefix} • Re-authenticate: hermes login --provider nous") + print(f"{agent.log_prefix} • Re-authenticate: hermes auth add nous") print(f"{agent.log_prefix} • Check credits / billing: https://portal.nousresearch.com") print(f"{agent.log_prefix} • Verify stored credentials: {_dhh}/auth.json") print(f"{agent.log_prefix} • Switch providers temporarily: /model --provider openrouter") @@ -2296,6 +2297,49 @@ def _stop_spinner(): ) continue + # ── Invalid encrypted reasoning replay recovery ─────── + # OpenAI Responses API surfaces (and some compatible relays) + # return HTTP 400 ``invalid_encrypted_content`` when a + # replayed ``codex_reasoning_items`` blob from a previous + # turn fails verification (provider rotated the encryption + # key, the route doesn't actually persist reasoning state, + # etc.). Recovery: disable replay for the rest of the + # session, strip cached items from history, retry once. + # One-shot — if a second 400 fires we fall through to the + # normal retry/backoff path. Only fires for codex_responses + # mode with at least one assistant message that has cached + # ``codex_reasoning_items``; without replay state, the + # error is unrelated to our cache so the normal retry path + # handles it (the provider is rejecting something else). + if ( + classified.reason == FailoverReason.invalid_encrypted_content + and not invalid_encrypted_content_retry_attempted + and agent.api_mode == "codex_responses" + and bool(getattr(agent, "_codex_reasoning_replay_enabled", True)) + and any( + isinstance(_m, dict) + and _m.get("role") == "assistant" + and isinstance(_m.get("codex_reasoning_items"), list) + and _m.get("codex_reasoning_items") + for _m in messages + ) + ): + invalid_encrypted_content_retry_attempted = True + replay_stats = agent._disable_codex_reasoning_replay(messages) + agent._vprint( + f"{agent.log_prefix}⚠️ Encrypted reasoning replay was rejected by the provider — " + f"disabled replay and stripped {replay_stats['items']} item(s) from " + f"{replay_stats['messages']} message(s), retrying...", + force=True, + ) + logger.warning( + "%sInvalid encrypted reasoning recovery: disabled replay and stripped %d items from %d messages", + agent.log_prefix, + replay_stats["items"], + replay_stats["messages"], + ) + continue + # ── llama.cpp grammar-parse recovery ────────────────── # llama.cpp's ``json-schema-to-grammar`` converter rejects # regex escape classes (``\d``, ``\w``, ``\s``) and most diff --git a/agent/credential_sources.py b/agent/credential_sources.py index 6c0a081a47dd7..f99a758625746 100644 --- a/agent/credential_sources.py +++ b/agent/credential_sources.py @@ -240,11 +240,11 @@ def _clear_auth_store_provider(provider: str) -> bool: def _remove_nous_device_code(provider: str, removed) -> RemovalResult: """Nous OAuth lives in auth.json providers.nous — clear it and suppress. - We suppress in addition to clearing because nothing else stops the - user's next `hermes login` run from writing providers.nous again - before they decide to. Suppression forces them to go through - `hermes auth add nous` to re-engage, which is the documented re-add - path and clears the suppression atomically. + We suppress in addition to clearing because nothing else stops a future + `hermes auth add nous` (or any other path that writes providers.nous) + from re-seeding before the user has decided to. Suppression forces + them to go through `hermes auth add nous` to re-engage, which is the + documented re-add path and clears the suppression atomically. """ result = RemovalResult() if _clear_auth_store_provider(provider): diff --git a/agent/error_classifier.py b/agent/error_classifier.py index 0afcf66d445f4..a0726a4e02a12 100644 --- a/agent/error_classifier.py +++ b/agent/error_classifier.py @@ -50,6 +50,7 @@ class FailoverReason(enum.Enum): # Request format format_error = "format_error" # 400 bad request — abort or strip + retry + invalid_encrypted_content = "invalid_encrypted_content" # Responses replay blob rejected — strip replay state and retry multimodal_tool_content_unsupported = "multimodal_tool_content_unsupported" # Provider rejected list-type content in tool messages (e.g. Xiaomi MiMo) — downgrade to text and retry # Provider-specific @@ -865,6 +866,26 @@ def _classify_400( retryable=True, ) + # Invalid encrypted reasoning replay blob (OpenAI Responses API). Must be + # checked BEFORE context_overflow because some surfaces emit messages that + # contain context-like phrasing ("encrypted content … could not be + # verified") which could otherwise trip the context_overflow heuristics. + # ``error_msg`` is lowercased upstream — match accordingly. + error_code_lower = (error_code or "").lower() + if ( + error_code_lower == "invalid_encrypted_content" + or "invalid_encrypted_content" in error_msg + or ( + "encrypted content for item" in error_msg + and "could not be verified" in error_msg + ) + ): + return result_fn( + FailoverReason.invalid_encrypted_content, + retryable=True, + should_fallback=False, + ) + # Context overflow from 400 if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS): return result_fn( @@ -974,6 +995,13 @@ def _classify_by_error_code( should_compress=True, ) + if code_lower == "invalid_encrypted_content": + return result_fn( + FailoverReason.invalid_encrypted_content, + retryable=True, + should_fallback=False, + ) + return None @@ -1141,15 +1169,49 @@ def _extract_error_code(body: dict) -> str: """Extract an error code string from the response body.""" if not body: return "" + + def _code_from_payload(payload) -> str: + """Extract a code/type from a nested error payload dict (defensive).""" + if not isinstance(payload, dict): + return "" + payload_error = payload.get("error", {}) + if isinstance(payload_error, dict): + nested = payload_error.get("code") or payload_error.get("type") or "" + if isinstance(nested, str) and nested.strip() and nested.strip() != "400": + return nested.strip() + code = payload.get("code") or payload.get("error_code") or "" + if isinstance(code, (str, int)): + text = str(code).strip() + if text and text != "400": + return text + return "" + error_obj = body.get("error", {}) if isinstance(error_obj, dict): code = error_obj.get("code") or error_obj.get("type") or "" - if isinstance(code, str) and code.strip(): + if isinstance(code, str) and code.strip() and code.strip() != "400": return code.strip() + + # Some providers wrap the real JSON error body as a string inside + # error.message — peek into it for a nested code (e.g. Responses API + # surfaces ``invalid_encrypted_content`` this way). + message = error_obj.get("message") + if isinstance(message, str) and message.strip().startswith("{"): + import json + try: + inner = json.loads(message) + except (json.JSONDecodeError, TypeError): + inner = None + nested_code = _code_from_payload(inner) + if nested_code: + return nested_code + # Top-level code code = body.get("code") or body.get("error_code") or "" if isinstance(code, (str, int)): - return str(code).strip() + text = str(code).strip() + if text and text != "400": + return text return "" diff --git a/agent/google_oauth.py b/agent/google_oauth.py index 6f45c370f6caa..97a65349dfa3f 100644 --- a/agent/google_oauth.py +++ b/agent/google_oauth.py @@ -656,7 +656,7 @@ def get_valid_access_token(*, force_refresh: bool = False) -> str: creds = load_credentials() if creds is None: raise GoogleOAuthError( - "No Google OAuth credentials found. Run `hermes login --provider google-gemini-cli` first.", + "No Google OAuth credentials found. Run `hermes auth add google-gemini-cli` first.", code="google_oauth_not_logged_in", ) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 2e0e3a65128cc..fa21c837123ef 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -47,7 +47,7 @@ def _resolve_requests_verify() -> bool | str: _PROVIDER_PREFIXES: frozenset[str] = frozenset({ "openrouter", "nous", "openai-codex", "copilot", "copilot-acp", "gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", - "opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "novita", + "opencode-zen", "opencode-go", "kilocode", "alibaba", "novita", "qwen-oauth", "xiaomi", "arcee", @@ -59,7 +59,7 @@ def _resolve_requests_verify() -> bool | str: "glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot", "github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "ollama", - "stepfun", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen", + "stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen", "mimo", "xiaomi-mimo", "tencent", "tokenhub", "tencent-cloud", "tencentmaas", "arcee-ai", "arceeai", diff --git a/agent/models_dev.py b/agent/models_dev.py index 1249c6f197093..590f77806abf4 100644 --- a/agent/models_dev.py +++ b/agent/models_dev.py @@ -158,7 +158,6 @@ class ProviderInfo: "alibaba": "alibaba", "qwen-oauth": "alibaba", "copilot": "github-copilot", - "ai-gateway": "vercel", "opencode-zen": "opencode", "opencode-go": "opencode-go", "kilocode": "kilo", diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 01f9c37534db6..e52df477aac7e 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -678,7 +678,7 @@ def _strip_yaml_frontmatter(content: str) -> str: # misleading — the agent should only see the machine it can actually touch. _REMOTE_TERMINAL_BACKENDS = frozenset({ "docker", "singularity", "modal", "daytona", "ssh", - "vercel_sandbox", "managed_modal", + "managed_modal", }) @@ -692,7 +692,6 @@ def _strip_yaml_frontmatter(content: str) -> str: "modal": "a Modal sandbox (Linux)", "managed_modal": "a managed Modal sandbox (Linux)", "daytona": "a Daytona workspace (Linux)", - "vercel_sandbox": "a Vercel sandbox (Linux)", "ssh": "a remote host reached over SSH (likely Linux)", } @@ -806,7 +805,7 @@ def build_environment_hints() -> str: and a Windows-only note that `terminal` shells out to bash, not PowerShell). - For **remote / sandbox** terminal backends (docker, singularity, - modal, daytona, ssh, vercel_sandbox): host info is **suppressed** + modal, daytona, ssh): host info is **suppressed** because the agent's tools can't touch the host — only the backend matters. A live probe inside the backend reports its OS, user, $HOME, and cwd. Falls back to a static summary if the probe fails. diff --git a/agent/transports/codex.py b/agent/transports/codex.py index 970692c039478..9a4a3a4b93720 100644 --- a/agent/transports/codex.py +++ b/agent/transports/codex.py @@ -17,16 +17,39 @@ class ResponsesApiTransport(ProviderTransport): Wraps the functions extracted into codex_responses_adapter.py (PR 1). """ + # Issuer kind of the most recent build_kwargs / convert_messages call. + # Used as a fallback when normalize_response is invoked without an + # explicit ``issuer_kind`` kwarg, so reasoning items captured from a + # response are stamped with the endpoint that minted them. Plain class + # attribute default; mutated on the instance, not the class. + _last_issuer_kind: Optional[str] = None + @property def api_mode(self) -> str: return "codex_responses" + def _resolve_issuer_kind(self, params: Dict[str, Any]) -> str: + """Classify the current Responses endpoint from transport params.""" + from agent.codex_responses_adapter import _classify_responses_issuer + return _classify_responses_issuer( + is_xai_responses=bool(params.get("is_xai_responses")), + is_github_responses=bool(params.get("is_github_responses")), + is_codex_backend=bool(params.get("is_codex_backend")), + base_url=params.get("base_url"), + ) + def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any: """Convert OpenAI chat messages to Responses API input items.""" from agent.codex_responses_adapter import _chat_messages_to_responses_input + issuer = self._resolve_issuer_kind(kwargs) + self._last_issuer_kind = issuer return _chat_messages_to_responses_input( messages, is_xai_responses=bool(kwargs.get("is_xai_responses")), + replay_encrypted_reasoning=bool( + kwargs.get("replay_encrypted_reasoning", True) + ), + current_issuer_kind=issuer, ) def convert_tools(self, tools: List[Dict[str, Any]]) -> Any: @@ -79,6 +102,17 @@ def build_kwargs( is_github_responses = params.get("is_github_responses", False) is_codex_backend = params.get("is_codex_backend", False) is_xai_responses = params.get("is_xai_responses", False) + replay_encrypted_reasoning = bool( + params.get("replay_encrypted_reasoning", True) + ) + + # Resolve the issuing endpoint for this call. Stashed on the + # transport so normalize_response can stamp it onto reasoning + # items captured from the response, and passed to the input + # converter so foreign-issuer reasoning blocks in history are + # dropped before the API rejects them. + issuer_kind = self._resolve_issuer_kind(params) + self._last_issuer_kind = issuer_kind # Resolve reasoning effort reasoning_effort = "medium" @@ -100,6 +134,8 @@ def build_kwargs( "input": _chat_messages_to_responses_input( payload_messages, is_xai_responses=is_xai_responses, + replay_encrypted_reasoning=replay_encrypted_reasoning, + current_issuer_kind=issuer_kind, ), "tools": response_tools, "store": False, @@ -121,7 +157,9 @@ def build_kwargs( # replay them on subsequent turns for cross-turn coherence. # See agent/codex_responses_adapter._chat_messages_to_responses_input # for the May 2026 reversal of the earlier suppression gate. - kwargs["include"] = ["reasoning.encrypted_content"] + kwargs["include"] = ( + ["reasoning.encrypted_content"] if replay_encrypted_reasoning else [] + ) # xAI rejects `reasoning.effort` on grok-4 / grok-4-fast / grok-3 # / grok-code-fast / grok-4.20-0309-* with HTTP 400 even though # those models reason natively. Only send the effort dial when @@ -136,7 +174,9 @@ def build_kwargs( kwargs["reasoning"] = github_reasoning else: kwargs["reasoning"] = {"effort": reasoning_effort, "summary": "auto"} - kwargs["include"] = ["reasoning.encrypted_content"] + kwargs["include"] = ( + ["reasoning.encrypted_content"] if replay_encrypted_reasoning else [] + ) elif not is_github_responses and not is_xai_responses: kwargs["include"] = [] @@ -144,6 +184,17 @@ def build_kwargs( if request_overrides: kwargs.update(request_overrides) + # xAI Responses API rejects ``service_tier`` (HTTP 400 "Argument not + # supported: service_tier") — hit when ``/fast`` priority-processing + # mode lingers from a prior model in the same session, or when a + # user explicitly sets ``agent.service_tier`` in config.yaml. The + # main-loop guard (``resolve_fast_mode_overrides`` only returns + # ``service_tier`` for OpenAI fast-eligible models) doesn't cover + # those leak paths, so strip defensively when targeting xAI. See + # #28490 for the original report. + if is_xai_responses: + kwargs.pop("service_tier", None) + # Forward per-request timeout to the SDK so OpenAI/Anthropic clients # honor it. Without this, ``providers..request_timeout_seconds`` # is silently dropped on the main agent Codex path while the @@ -213,8 +264,13 @@ def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse: _normalize_codex_response, ) + # Issuer for this response = explicit kwarg if the caller knows it, + # otherwise the stash from the matching build_kwargs/convert_messages + # call. Either way it gets stamped onto reasoning items so future + # turns can detect a model swap and drop foreign-issuer blobs. + issuer_kind = kwargs.get("issuer_kind") or self._last_issuer_kind # _normalize_codex_response returns (SimpleNamespace, finish_reason_str) - msg, finish_reason = _normalize_codex_response(response) + msg, finish_reason = _normalize_codex_response(response, issuer_kind=issuer_kind) tool_calls = None if msg and msg.tool_calls: diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index fcf4f6228346a..93ced2e7d431d 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -711,8 +711,8 @@ def normalize_usage( output_tokens = _to_int(getattr(response_usage, "completion_tokens", 0)) details = getattr(response_usage, "prompt_tokens_details", None) # Primary: OpenAI-style prompt_tokens_details. Fallback: Anthropic-style - # top-level fields that some OpenAI-compatible proxies (OpenRouter, Vercel - # AI Gateway, Cline) expose when routing Claude models — without this + # top-level fields that some OpenAI-compatible proxies (OpenRouter, Cline) + # expose when routing Claude models — without this # fallback, cache writes are undercounted as 0 and cache reads can be # missed when the proxy only surfaces them at the top level. # Port of cline/cline#10266. diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 939f602cdfb4c..355b6bb756947 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -29,7 +29,6 @@ model: # "arcee" - Arcee AI Trinity models (requires: ARCEEAI_API_KEY) # "ollama-cloud" - Ollama Cloud (requires: OLLAMA_API_KEY — https://ollama.com/settings) # "kilocode" - KiloCode gateway (requires: KILOCODE_API_KEY) - # "ai-gateway" - Vercel AI Gateway (requires: AI_GATEWAY_API_KEY) # "azure-foundry" - Microsoft Foundry / Azure OpenAI (API key or Entra ID) # "lmstudio" - LM Studio local server (optional: LM_API_KEY, defaults to http://127.0.0.1:1234/v1) # @@ -917,6 +916,15 @@ display: # Toggle at runtime with /verbose in the CLI tool_progress: all + # Per-platform defaults can be quieter than the global setting. Telegram + # tunes for mobile: tool_progress and busy_ack_detail default off (no + # per-tool breadcrumb stream, no "iteration 21/60" debug detail in busy + # acks or heartbeats), but interim_assistant_messages and + # long_running_notifications STAY ON so the user has real signal between + # turn start and final answer (mid-turn assistant commentary + a single + # edit-in-place "⏳ Working — N min" heartbeat). Override under + # display.platforms.telegram. + # Auto-cleanup of temporary progress bubbles after the final response lands. # On platforms that support message deletion (currently Telegram), this # removes the tool-progress bubble, "⏳ Still working..." notices, and @@ -940,6 +948,22 @@ display: # false: Only send the final response interim_assistant_messages: true + # Gateway-only long-running status heartbeats. + # When false, the platform does not receive periodic "⏳ Working — N min" + # notifications even if agent.gateway_notify_interval is non-zero. The + # heartbeat edits a single message in place (where the adapter supports + # editing) instead of posting a new bubble each interval. + # Default: true everywhere, including Telegram (silent agents are worse + # than a single edit-in-place heartbeat). + long_running_notifications: true + + # Include detailed iteration/tool/status context in busy acknowledgments + # and long-running heartbeats. When true, busy acks show "iteration 21/60, + # terminal, 10 min" and the heartbeat shows "⏳ Working — 12 min, + # iteration 21/60, terminal". When false (Telegram default), both stay + # terse: "Interrupting current task" and "⏳ Working — 12 min, terminal". + busy_ack_detail: true + # What Enter does when Hermes is already busy (CLI and gateway platforms). # interrupt: Interrupt the current run and redirect Hermes (default) # queue: Queue your message for the next turn @@ -1098,3 +1122,46 @@ 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 +# dashboard.public_url <- HERMES_DASHBOARD_PUBLIC_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 +# +# # Force the absolute base URL the OAuth callback (and any other public +# # URL the dashboard hands to external systems) is built from. Set this +# # for deploys behind reverse proxies that don't reliably forward +# # X-Forwarded-Host / X-Forwarded-Proto / X-Forwarded-Prefix (manual +# # nginx setups, on-prem ingresses, custom-domain Fly deploys without +# # full proxy header chains). +# # +# # When set, the value is the complete authority: scheme + host + +# # optional path prefix (e.g. "https://example.com/hermes"). The OAuth +# # callback URL becomes "/auth/callback" — X-Forwarded-Prefix +# # is IGNORED on this code path because the operator has explicitly +# # declared the public URL and we no longer need to guess. +# # +# # Leave empty to use the existing proxy-header reconstruction (the +# # default — works on Fly.io out of the box). +# # +# # public_url: "https://example.com/hermes" diff --git a/cli.py b/cli.py index c05c361a7c05a..6c77afc07a429 100644 --- a/cli.py +++ b/cli.py @@ -562,13 +562,12 @@ def load_cli_config() -> Dict[str, Any]: "singularity_image": "TERMINAL_SINGULARITY_IMAGE", "modal_image": "TERMINAL_MODAL_IMAGE", "daytona_image": "TERMINAL_DAYTONA_IMAGE", - "vercel_runtime": "TERMINAL_VERCEL_RUNTIME", # SSH config "ssh_host": "TERMINAL_SSH_HOST", "ssh_user": "TERMINAL_SSH_USER", "ssh_port": "TERMINAL_SSH_PORT", "ssh_key": "TERMINAL_SSH_KEY", - # Container resource config (docker, singularity, modal, daytona, vercel_sandbox -- ignored for local/ssh) + # Container resource config (docker, singularity, modal, daytona -- ignored for local/ssh) "container_cpu": "TERMINAL_CONTAINER_CPU", "container_memory": "TERMINAL_CONTAINER_MEMORY", "container_disk": "TERMINAL_CONTAINER_DISK", @@ -7155,11 +7154,13 @@ def _prompt_text_input_modal( * ``sys.platform == "win32"`` — native Windows console (ConPTY / win32_input) does not support the modal reliably. - * Called from a non-main thread — the prompt_toolkit event loop only - runs on the main thread; key bindings can't fire from a daemon - thread (same rationale as the ``_prompt_text_input`` thread guard - in PR #23454). * ``self._app`` is not set — unit tests / non-interactive contexts. + + On non-Windows platforms the modal itself is still safe from the + ``process_loop`` daemon thread as long as the main-thread event loop + owns the prompt_toolkit buffer mutations. When we are off the main + thread, schedule the modal snapshot / restore work on ``self._app.loop`` + via ``call_soon_threadsafe`` and keep the queue-based response path. """ import threading import time as _time @@ -7180,33 +7181,62 @@ def _prompt_text_input_modal( if sys.platform == "win32": return self._prompt_text_input("Choice [1/2/3]: ") - # Mirror the thread-aware guard from _prompt_text_input (PR #23454): - # run_in_terminal and the modal queue both depend on the main-thread - # event loop. From a daemon thread the modal key bindings never fire. - if threading.current_thread() is not threading.main_thread(): + try: + app_loop = self._app.loop + except Exception: + app_loop = None + + in_main_thread = threading.current_thread() is threading.main_thread() + if not in_main_thread and app_loop is None: return self._prompt_text_input("Choice [1/2/3]: ") response_queue = queue.Queue() - self._capture_modal_input_snapshot() - self._slash_confirm_state = { - "title": title, - "detail": detail, - "choices": choices, - "selected": 0, - "response_queue": response_queue, - } - self._slash_confirm_deadline = _time.monotonic() + timeout - self._invalidate() + + def _setup_modal() -> None: + self._capture_modal_input_snapshot() + self._slash_confirm_state = { + "title": title, + "detail": detail, + "choices": choices, + "selected": 0, + "response_queue": response_queue, + } + self._slash_confirm_deadline = _time.monotonic() + timeout + self._invalidate() + + def _teardown_modal() -> None: + self._slash_confirm_state = None + self._slash_confirm_deadline = 0 + self._restore_modal_input_snapshot() + self._invalidate() + + def _run_on_app_loop(fn) -> bool: + if in_main_thread or app_loop is None: + fn() + return True + ready = threading.Event() + + def _wrapped() -> None: + try: + fn() + finally: + ready.set() + + try: + app_loop.call_soon_threadsafe(_wrapped) + except Exception: + return False + return ready.wait(timeout=5) + + if not _run_on_app_loop(_setup_modal): + return self._prompt_text_input("Choice [1/2/3]: ") _last_countdown_refresh = _time.monotonic() try: while True: try: result = response_queue.get(timeout=1) - self._slash_confirm_state = None - self._slash_confirm_deadline = 0 - self._restore_modal_input_snapshot() - self._invalidate() + _run_on_app_loop(_teardown_modal) return result except queue.Empty: remaining = self._slash_confirm_deadline - _time.monotonic() @@ -7218,10 +7248,7 @@ def _prompt_text_input_modal( self._invalidate() finally: if self._slash_confirm_state is not None: - self._slash_confirm_state = None - self._slash_confirm_deadline = 0 - self._restore_modal_input_snapshot() - self._invalidate() + _run_on_app_loop(_teardown_modal) return None def _submit_slash_confirm_response(self, value: str | None) -> None: diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml new file mode 100644 index 0000000000000..31362ddd973b0 --- /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"] diff --git a/docker/main-wrapper.sh b/docker/main-wrapper.sh index 0e25e5adf91ab..a164b77eaa2f1 100755 --- a/docker/main-wrapper.sh +++ b/docker/main-wrapper.sh @@ -1,9 +1,16 @@ -#!/bin/sh +#!/command/with-contenv sh +# shellcheck shell=sh # /opt/hermes/docker/main-wrapper.sh — wraps the container's CMD with # the same argument-routing logic the pre-s6 entrypoint.sh used. Runs # as /init's "main program" (Docker CMD) so it inherits stdin/stdout/ # stderr from the container. # +# Shebang note: /init scrubs env before invoking CMD, so a plain +# `#!/bin/sh` wrapper sees an empty environ and `ENV HERMES_HOME=/opt/data` +# from the Dockerfile never reaches `hermes`. with-contenv repopulates +# the env from /run/s6/container_environment before exec'ing, which is +# what s6-supervised services use too (see main-hermes/run). +# # Routing: # no args → exec `hermes` (the default) # first arg is an executable → exec it directly (sleep, bash, sh, …) @@ -13,6 +20,12 @@ # workload runs unprivileged (UID 10000 by default). set -e +# HOME comes through with-contenv as /root (the /init context). Override +# to the hermes user's home before dropping privileges so libraries that +# resolve paths via $HOME (e.g. discord lockfile under XDG_STATE_HOME) +# don't try to write to /root. +export HOME=/opt/data + cd /opt/data # shellcheck disable=SC1091 . /opt/hermes/.venv/bin/activate diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index 64b1745d5ad34..1e8af197de9d5 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -20,6 +20,18 @@ set -eu HERMES_HOME="${HERMES_HOME:-/opt/data}" INSTALL_DIR="/opt/hermes" +# --- Bootstrap HERMES_HOME as root --- +# Create the directory (and any missing parents) while we still have root +# privileges so the chown checks below see real metadata and the later +# `s6-setuidgid hermes mkdir -p` block doesn't EACCES on root-owned +# ancestors. Without this, custom HERMES_HOME paths whose parents only +# root can create (e.g. `HERMES_HOME=/home/hermes/.hermes` in a Compose +# file, or any path under a fresh / not pre-populated by the image) +# fail on first boot with `mkdir: cannot create directory '/...': Permission +# denied` and the cont-init hook exits non-zero. Idempotent — `mkdir -p` +# is a no-op if the dir already exists. (#18482, salvages #18488) +mkdir -p "$HERMES_HOME" + # --- UID/GID remap --- if [ -n "${HERMES_UID:-}" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then echo "[stage2] Changing hermes UID to $HERMES_UID" @@ -33,6 +45,14 @@ if [ -n "${HERMES_GID:-}" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then fi # --- Fix ownership of data volume --- +# When HERMES_UID is remapped or the top-level $HERMES_HOME isn't owned by +# the runtime hermes UID, restore ownership to hermes — but ONLY for the +# directories hermes actually writes to. The full $HERMES_HOME may be a +# host-mounted bind containing unrelated user files; `chown -R` would +# silently destroy host ownership of those (see issue #19788). +# +# The canonical list of hermes-owned subdirs is the same one the s6-setuidgid +# mkdir -p block below seeds. Keep them in sync if the seed list changes. actual_hermes_uid=$(id -u hermes) needs_chown=false if [ -n "${HERMES_UID:-}" ] && [ "$HERMES_UID" != "10000" ]; then @@ -41,16 +61,45 @@ elif [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; the needs_chown=true fi if [ "$needs_chown" = true ]; then - echo "[stage2] Fixing ownership of $HERMES_HOME to hermes ($actual_hermes_uid)" + echo "[stage2] Fixing ownership of $HERMES_HOME (targeted) to hermes ($actual_hermes_uid)" # In rootless Podman the container's "root" is mapped to an # unprivileged host UID — chown will fail. That's fine: the volume # is already owned by the mapped user on the host side. - chown -R hermes:hermes "$HERMES_HOME" 2>/dev/null || \ - echo "[stage2] Warning: chown failed (rootless container?) — continuing" - # The .venv must also be re-chowned when UID is remapped, otherwise - # lazy_deps.py cannot install platform packages (discord.py, etc.). - chown -R hermes:hermes "$INSTALL_DIR/.venv" 2>/dev/null || \ - echo "[stage2] Warning: chown .venv failed (rootless container?) — continuing" + # + # Top-level $HERMES_HOME: chown the directory itself (not its contents) + # so hermes can mkdir new subdirs but bind-mounted host files keep + # their existing ownership. + chown hermes:hermes "$HERMES_HOME" 2>/dev/null || \ + echo "[stage2] Warning: chown $HERMES_HOME failed (rootless container?) — continuing" + # Hermes-owned subdirs: recursive chown is safe here because these are + # created and managed exclusively by hermes (see the s6-setuidgid mkdir + # -p block below for the canonical list). + for sub in cron sessions logs hooks memories skills skins plans workspace home profiles; do + if [ -e "$HERMES_HOME/$sub" ]; then + chown -R hermes:hermes "$HERMES_HOME/$sub" 2>/dev/null || \ + echo "[stage2] Warning: chown $HERMES_HOME/$sub failed (rootless container?) — continuing" + fi + done + # Hermes-owned trees under $INSTALL_DIR must be re-chowned when the UID + # is remapped — otherwise: + # - .venv: lazy_deps.py cannot install platform packages (discord.py, + # telegram, slack, etc.) with EACCES (#15012, #21100) + # - ui-tui: esbuild rebuilds dist/entry.js on every TUI launch (when + # the source mtime is newer than dist/ or when HERMES_TUI_FORCE_BUILD + # is set) and writes to ui-tui/dist/. Without this chown the new + # hermes UID can't write the build output (#28851). + # - node_modules: root-level dependencies (puppeteer, web tooling) + # that runtime code may walk/update. + # The set mirrors the build-time `chown -R hermes:hermes` line in the + # Dockerfile — keep them in sync if the Dockerfile chown set changes. + # These are under $INSTALL_DIR (not $HERMES_HOME), so the bind-mount + # concern doesn't apply — recursive is fine. + chown -R hermes:hermes \ + "$INSTALL_DIR/.venv" \ + "$INSTALL_DIR/ui-tui" \ + "$INSTALL_DIR/node_modules" \ + 2>/dev/null || \ + echo "[stage2] Warning: chown of build trees failed (rootless container?) — continuing" fi # Always reset ownership of $HERMES_HOME/profiles to hermes on every @@ -139,4 +188,47 @@ if [ -d "$INSTALL_DIR/skills" ]; then || echo "[stage2] Warning: skills_sync.py failed; continuing" fi +# --- Discover agent-browser's Chromium binary --- +# The image's Dockerfile runs `npx playwright install chromium`, which +# populates ``$PLAYWRIGHT_BROWSERS_PATH`` (=/opt/hermes/.playwright) with +# a ``chromium_headless_shell-/chrome-headless-shell-linux64/`` +# directory. agent-browser (the runtime CLI Hermes spawns for the +# browser tool) doesn't recognise this layout in its own cache scan and +# fails with "Auto-launch failed: Chrome not found" — even though the +# binary is right there (#15697). +# +# Fix: locate the binary at boot and export ``AGENT_BROWSER_EXECUTABLE_PATH`` +# via /run/s6/container_environment so the `with-contenv` shebang on +# main-wrapper.sh propagates it into the supervised ``hermes`` process +# and thence to agent-browser subprocesses. +# +# - Skipped when the user has already set ``AGENT_BROWSER_EXECUTABLE_PATH`` +# (lets users override with a system Chrome install). +# - Filename-matched (not path-matched): the chromium dir contains many +# shared libraries (libGLESv2.so, libEGL.so, ...) which inherit the +# executable bit from Playwright's tarball but are NOT browser binaries. +# We only accept files whose basename is chrome / chromium / +# chrome-headless-shell / chromium-browser. Compare PR #18635's earlier +# ``find | grep -Ei 'chrome|chromium'`` which would match the path +# ``.../chrome-headless-shell-linux64/libGLESv2.so`` and pick a .so. +# - Quietly skipped when $PLAYWRIGHT_BROWSERS_PATH doesn't exist (e.g. +# custom builds that strip Playwright). +if [ -z "${AGENT_BROWSER_EXECUTABLE_PATH:-}" ] && \ + [ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ] && \ + [ -d "$PLAYWRIGHT_BROWSERS_PATH" ]; then + browser_bin=$(find "$PLAYWRIGHT_BROWSERS_PATH" -type f -executable \ + \( -name 'chrome' -o -name 'chromium' \ + -o -name 'chrome-headless-shell' -o -name 'chromium-browser' \) \ + 2>/dev/null | head -n 1) + if [ -n "$browser_bin" ]; then + echo "[stage2] Found agent-browser Chromium binary: $browser_bin" + # Write to s6's container_environment so with-contenv picks it + # up for all supervised services (main-hermes, dashboard, etc.). + # Idempotent: each boot overwrites with the current path. + printf '%s' "$browser_bin" > /run/s6/container_environment/AGENT_BROWSER_EXECUTABLE_PATH + else + echo "[stage2] Warning: no Chromium binary under $PLAYWRIGHT_BROWSERS_PATH; browser tool may fail" + fi +fi + echo "[stage2] Setup complete; starting user services" diff --git a/gateway/display_config.py b/gateway/display_config.py index eab6bebc7830b..6286ade2be706 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -35,7 +35,12 @@ "show_reasoning": False, "tool_preview_length": 0, "streaming": None, # None = follow top-level streaming config - # When true, delete tool-progress / "Still working..." / status bubbles + # Gateway-only assistant/status chatter controls. These default on for + # back-compat, but mobile platforms can opt down to final-answer-first. + "interim_assistant_messages": True, + "long_running_notifications": True, + "busy_ack_detail": True, + # When true, delete tool-progress / "⏳ Working — N min" / status bubbles # after the final response lands on platforms that support message # deletion (e.g. Telegram). Off by default — progress is still shown # live, just cleaned up after success so the chat doesn't fill up with @@ -56,6 +61,9 @@ "show_reasoning": False, "tool_preview_length": 40, "streaming": None, # follow global + "interim_assistant_messages": True, + "long_running_notifications": True, + "busy_ack_detail": True, } _TIER_MEDIUM = { @@ -63,6 +71,9 @@ "show_reasoning": False, "tool_preview_length": 40, "streaming": None, + "interim_assistant_messages": True, + "long_running_notifications": True, + "busy_ack_detail": True, } _TIER_LOW = { @@ -70,6 +81,9 @@ "show_reasoning": False, "tool_preview_length": 40, "streaming": False, + "interim_assistant_messages": False, + "long_running_notifications": False, + "busy_ack_detail": False, } _TIER_MINIMAL = { @@ -77,11 +91,25 @@ "show_reasoning": False, "tool_preview_length": 0, "streaming": False, + "interim_assistant_messages": False, + "long_running_notifications": False, + "busy_ack_detail": False, } _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = { # Tier 1 — full edit support, personal/team use - "telegram": {**_TIER_HIGH, "tool_progress": "new"}, + # Telegram is usually a mobile inbox: keep tool_progress quiet and skip + # the verbose busy-ack iteration counter, but DO surface real mid-turn + # assistant commentary (interim_assistant_messages) and DO send periodic + # heartbeats (long_running_notifications) so the user has signal between + # turn start and final answer. Otherwise it looks like "typing..." for + # 30 minutes with nothing happening. Opt in to verbose iteration detail + # via display.platforms.telegram.busy_ack_detail / tool_progress. + "telegram": { + **_TIER_HIGH, + "tool_progress": "off", + "busy_ack_detail": False, + }, "discord": _TIER_HIGH, # Tier 2 — edit support, often customer/workspace channels @@ -190,7 +218,13 @@ def _normalise(setting: str, value: Any) -> Any: if value is True: return "all" return str(value).lower() - if setting in {"show_reasoning", "streaming"}: + if setting in { + "show_reasoning", + "streaming", + "interim_assistant_messages", + "long_running_notifications", + "busy_ack_detail", + }: if isinstance(value, str): return value.lower() in {"true", "1", "yes", "on"} return bool(value) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index a18630f85cec5..7d8afa64625b5 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -8,6 +8,12 @@ - DELETE /v1/responses/{response_id} — Delete a stored response - GET /v1/models — lists hermes-agent as an available model - GET /v1/capabilities — machine-readable API capabilities for external UIs +- GET /api/sessions — list client-visible Hermes sessions +- POST /api/sessions — create an empty Hermes session +- GET/PATCH/DELETE /api/sessions/{session_id} — read/update/delete a session +- GET /api/sessions/{session_id}/messages — read session message history +- POST /api/sessions/{session_id}/fork — branch a session using SessionDB lineage +- POST /api/sessions/{session_id}/chat[/stream] — chat with a persisted session - POST /v1/runs — start a run, returns run_id immediately (202) - GET /v1/runs/{run_id} — retrieve current run status - GET /v1/runs/{run_id}/events — SSE stream of structured lifecycle events @@ -313,6 +319,20 @@ def _multimodal_validation_error(exc: ValueError, *, param: str) -> "web.Respons ) +def _session_chat_user_message(body: Dict[str, Any], *, param: str = "message") -> tuple[Any, Optional["web.Response"]]: + """Parse and normalize session chat ``message`` / ``input`` like chat completions.""" + user_message = body.get("message") or body.get("input") + if not _content_has_visible_payload(user_message): + return None, web.json_response( + _openai_error("Missing 'message' field", code="missing_message"), + status=400, + ) + try: + return _normalize_multimodal_content(user_message), None + except ValueError as exc: + return None, _multimodal_validation_error(exc, param=param) + + def check_api_server_requirements() -> bool: """Check if API server dependencies are available.""" return AIOHTTP_AVAILABLE @@ -1086,6 +1106,16 @@ async def _handle_capabilities(self, request: "web.Request") -> "web.Response": "run_approval_response": True, "tool_progress_events": True, "approval_events": True, + "session_resources": True, + "session_chat": True, + "session_chat_streaming": True, + "session_fork": True, + "admin_config_rw": False, + "jobs_admin": False, + "memory_write_api": False, + "skills_api": True, + "audio_api": False, + "realtime_voice": False, "session_continuity_header": "X-Hermes-Session-Id", "session_key_header": "X-Hermes-Session-Key", "cors": bool(self._cors_origins), @@ -1101,9 +1131,540 @@ async def _handle_capabilities(self, request: "web.Request") -> "web.Response": "run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"}, "run_approval": {"method": "POST", "path": "/v1/runs/{run_id}/approval"}, "run_stop": {"method": "POST", "path": "/v1/runs/{run_id}/stop"}, + "skills": {"method": "GET", "path": "/v1/skills"}, + "toolsets": {"method": "GET", "path": "/v1/toolsets"}, + "sessions": {"method": "GET", "path": "/api/sessions"}, + "session_create": {"method": "POST", "path": "/api/sessions"}, + "session": {"method": "GET", "path": "/api/sessions/{session_id}"}, + "session_update": {"method": "PATCH", "path": "/api/sessions/{session_id}"}, + "session_delete": {"method": "DELETE", "path": "/api/sessions/{session_id}"}, + "session_messages": {"method": "GET", "path": "/api/sessions/{session_id}/messages"}, + "session_fork": {"method": "POST", "path": "/api/sessions/{session_id}/fork"}, + "session_chat": {"method": "POST", "path": "/api/sessions/{session_id}/chat"}, + "session_chat_stream": {"method": "POST", "path": "/api/sessions/{session_id}/chat/stream"}, }, }) + async def _handle_skills(self, request: "web.Request") -> "web.Response": + """GET /v1/skills — list installed skills visible to the API-server agent. + + Read-only listing intended for external clients that need to know + which skills are available without sending a chat message and asking + the model. Mirrors what the gateway/CLI surfaces through + ``/skills list``, but as a deterministic JSON payload. + + Returns the same skill metadata (name, description, category) the + skills hub uses internally. Disabled skills are excluded so the + listing matches what the agent actually loads. + """ + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + try: + from tools.skills_tool import _find_all_skills, _sort_skills + skills = _sort_skills(_find_all_skills(skip_disabled=False)) + except Exception: + logger.exception("GET /v1/skills failed") + return web.json_response( + _openai_error("Failed to enumerate skills", err_type="server_error"), + status=500, + ) + + return web.json_response({ + "object": "list", + "data": skills, + }) + + async def _handle_toolsets(self, request: "web.Request") -> "web.Response": + """GET /v1/toolsets — list toolsets and their resolved tools. + + Returns the toolset surface the api_server platform actually exposes + to its agent: each toolset's enabled/configured state plus the + concrete tool names it expands to. This is the deterministic + equivalent of what a client would otherwise have to recover by + asking the model what tools it can call. + """ + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + try: + from hermes_cli.config import load_config + from hermes_cli.tools_config import ( + _get_effective_configurable_toolsets, + _get_platform_tools, + _toolset_has_keys, + ) + from toolsets import resolve_toolset + + config = load_config() + enabled_toolsets = _get_platform_tools( + config, + "api_server", + include_default_mcp_servers=False, + ) + data: List[Dict[str, Any]] = [] + for name, label, desc in _get_effective_configurable_toolsets(): + try: + tools = sorted(set(resolve_toolset(name))) + except Exception: + tools = [] + is_enabled = name in enabled_toolsets + data.append({ + "name": name, + "label": label, + "description": desc, + "enabled": is_enabled, + "configured": _toolset_has_keys(name, config), + "tools": tools, + }) + except Exception: + logger.exception("GET /v1/toolsets failed") + return web.json_response( + _openai_error("Failed to enumerate toolsets", err_type="server_error"), + status=500, + ) + + return web.json_response({ + "object": "list", + "platform": "api_server", + "data": data, + }) + + # ------------------------------------------------------------------ + # /api/sessions — thin client/session resource API + # ------------------------------------------------------------------ + + @staticmethod + def _parse_nonnegative_int(value: Any, default: int, maximum: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + if parsed < 0: + return default + return min(parsed, maximum) + + @staticmethod + def _session_response(session: Dict[str, Any]) -> Dict[str, Any]: + """Return a stable, client-safe session representation.""" + safe_keys = ( + "id", "source", "user_id", "model", "title", "started_at", "ended_at", + "end_reason", "message_count", "tool_call_count", "input_tokens", + "output_tokens", "cache_read_tokens", "cache_write_tokens", + "reasoning_tokens", "estimated_cost_usd", "actual_cost_usd", + "api_call_count", "parent_session_id", "last_active", "preview", + "_lineage_root_id", + ) + payload = {key: session.get(key) for key in safe_keys if key in session} + # Avoid exposing full system prompts/model_config through the client API; + # callers only need to know whether those snapshots exist. + payload["has_system_prompt"] = bool(session.get("system_prompt")) + payload["has_model_config"] = bool(session.get("model_config")) + return payload + + @staticmethod + def _message_response(message: Dict[str, Any]) -> Dict[str, Any]: + safe_keys = ( + "id", "session_id", "role", "content", "tool_call_id", "tool_calls", + "tool_name", "timestamp", "token_count", "finish_reason", "reasoning", + "reasoning_content", + ) + return {key: message.get(key) for key in safe_keys if key in message} + + async def _read_json_body(self, request: "web.Request") -> tuple[Dict[str, Any], Optional["web.Response"]]: + try: + body = await request.json() + except Exception: + return {}, web.json_response(_openai_error("Invalid JSON in request body"), status=400) + if not isinstance(body, dict): + return {}, web.json_response(_openai_error("Request body must be a JSON object"), status=400) + return body, None + + def _get_existing_session_or_404(self, session_id: str) -> tuple[Optional[Dict[str, Any]], Optional["web.Response"]]: + db = self._ensure_session_db() + if db is None: + return None, web.json_response(_openai_error("Session database unavailable", code="session_db_unavailable"), status=503) + session = db.get_session(session_id) + if not session: + return None, web.json_response(_openai_error(f"Session not found: {session_id}", code="session_not_found"), status=404) + return session, None + + def _conversation_history_for_session(self, session_id: str) -> List[Dict[str, Any]]: + db = self._ensure_session_db() + if db is None: + return [] + try: + return db.get_messages_as_conversation(session_id) + except Exception as exc: + logger.warning("Failed to load session history for %s: %s", session_id, exc) + return [] + + async def _handle_list_sessions(self, request: "web.Request") -> "web.Response": + """GET /api/sessions — list persisted Hermes sessions.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + + db = self._ensure_session_db() + if db is None: + return web.json_response(_openai_error("Session database unavailable", code="session_db_unavailable"), status=503) + + limit = self._parse_nonnegative_int(request.query.get("limit"), default=50, maximum=200) + offset = self._parse_nonnegative_int(request.query.get("offset"), default=0, maximum=1_000_000) + source = request.query.get("source") or None + include_children = _coerce_request_bool(request.query.get("include_children"), default=False) + sessions = db.list_sessions_rich( + source=source, + limit=limit, + offset=offset, + include_children=include_children, + order_by_last_active=True, + ) + return web.json_response({ + "object": "list", + "data": [self._session_response(s) for s in sessions], + "limit": limit, + "offset": offset, + "has_more": len(sessions) == limit, + }) + + async def _handle_create_session(self, request: "web.Request") -> "web.Response": + """POST /api/sessions — create an empty Hermes session row.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + body, err = await self._read_json_body(request) + if err: + return err + + db = self._ensure_session_db() + if db is None: + return web.json_response(_openai_error("Session database unavailable", code="session_db_unavailable"), status=503) + + raw_id = body.get("id") or body.get("session_id") + session_id = str(raw_id).strip() if raw_id else f"api_{int(time.time())}_{uuid.uuid4().hex[:8]}" + if not session_id or re.search(r'[\r\n\x00]', session_id): + return web.json_response(_openai_error("Invalid session ID", code="invalid_session_id"), status=400) + if len(session_id) > self._MAX_SESSION_HEADER_LEN: + return web.json_response(_openai_error("Session ID too long", code="invalid_session_id"), status=400) + if db.get_session(session_id): + return web.json_response(_openai_error(f"Session already exists: {session_id}", code="session_exists"), status=409) + + model = body.get("model") or self._model_name + system_prompt = body.get("system_prompt") + if system_prompt is not None and not isinstance(system_prompt, str): + return web.json_response(_openai_error("system_prompt must be a string", code="invalid_system_prompt"), status=400) + db.create_session(session_id, "api_server", model=str(model) if model else None, system_prompt=system_prompt) + title = body.get("title") + if title is not None: + try: + db.set_session_title(session_id, str(title)) + except ValueError as exc: + db.delete_session(session_id) + return web.json_response(_openai_error(str(exc), code="invalid_title"), status=400) + session = db.get_session(session_id) or {"id": session_id, "source": "api_server", "model": model, "title": title} + return web.json_response({"object": "hermes.session", "session": self._session_response(session)}, status=201) + + async def _handle_get_session(self, request: "web.Request") -> "web.Response": + """GET /api/sessions/{session_id}.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + session, err = self._get_existing_session_or_404(request.match_info["session_id"]) + if err: + return err + return web.json_response({"object": "hermes.session", "session": self._session_response(session)}) + + async def _handle_patch_session(self, request: "web.Request") -> "web.Response": + """PATCH /api/sessions/{session_id} — update client-safe session metadata.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + session_id = request.match_info["session_id"] + session, err = self._get_existing_session_or_404(session_id) + if err: + return err + body, err = await self._read_json_body(request) + if err: + return err + allowed = {"title", "end_reason"} + unknown = sorted(set(body) - allowed) + if unknown: + return web.json_response(_openai_error(f"Unsupported session fields: {', '.join(unknown)}", code="unsupported_session_field"), status=400) + + db = self._ensure_session_db() + if "title" in body: + try: + db.set_session_title(session_id, "" if body["title"] is None else str(body["title"])) + except ValueError as exc: + return web.json_response(_openai_error(str(exc), code="invalid_title"), status=400) + if body.get("end_reason"): + db.end_session(session_id, str(body["end_reason"])) + session = db.get_session(session_id) or session + return web.json_response({"object": "hermes.session", "session": self._session_response(session)}) + + async def _handle_delete_session(self, request: "web.Request") -> "web.Response": + """DELETE /api/sessions/{session_id}.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + session_id = request.match_info["session_id"] + session, err = self._get_existing_session_or_404(session_id) + if err: + return err + db = self._ensure_session_db() + deleted = db.delete_session(session_id) + return web.json_response({"object": "hermes.session.deleted", "id": session_id, "deleted": bool(deleted)}) + + async def _handle_session_messages(self, request: "web.Request") -> "web.Response": + """GET /api/sessions/{session_id}/messages.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + session_id = request.match_info["session_id"] + _, err = self._get_existing_session_or_404(session_id) + if err: + return err + db = self._ensure_session_db() + messages = db.get_messages(session_id) + return web.json_response({ + "object": "list", + "session_id": session_id, + "data": [self._message_response(m) for m in messages], + }) + + async def _handle_fork_session(self, request: "web.Request") -> "web.Response": + """POST /api/sessions/{session_id}/fork — branch via current SessionDB primitives.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + source_id = request.match_info["session_id"] + source, err = self._get_existing_session_or_404(source_id) + if err: + return err + body, err = await self._read_json_body(request) + if err: + return err + db = self._ensure_session_db() + fork_id = str(body.get("id") or body.get("session_id") or f"api_{int(time.time())}_{uuid.uuid4().hex[:8]}").strip() + if not fork_id or re.search(r'[\r\n\x00]', fork_id): + return web.json_response(_openai_error("Invalid session ID", code="invalid_session_id"), status=400) + if db.get_session(fork_id): + return web.json_response(_openai_error(f"Session already exists: {fork_id}", code="session_exists"), status=409) + + # Match the CLI /branch semantics: mark the original as branched, then + # create a child session that carries the transcript forward. This uses + # SessionDB's native parent_session_id/end_reason visibility model rather + # than inventing a parallel fork store. + db.end_session(source_id, "branched") + db.create_session( + fork_id, + "api_server", + model=source.get("model"), + system_prompt=source.get("system_prompt"), + parent_session_id=source_id, + ) + messages = db.get_messages(source_id) + db.replace_messages(fork_id, messages) + title = body.get("title") + if title is None: + base = source.get("title") or "fork" + try: + title = db.get_next_title_in_lineage(base) + except Exception: + title = f"{base} fork" + try: + db.set_session_title(fork_id, str(title)) + except ValueError as exc: + return web.json_response(_openai_error(str(exc), code="invalid_title"), status=400) + fork = db.get_session(fork_id) or {"id": fork_id, "parent_session_id": source_id} + return web.json_response({"object": "hermes.session", "session": self._session_response(fork)}, status=201) + + async def _handle_session_chat(self, request: "web.Request") -> "web.Response": + """POST /api/sessions/{session_id}/chat — one synchronous agent turn.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + gateway_session_key, key_err = self._parse_session_key_header(request) + if key_err is not None: + return key_err + session_id = request.match_info["session_id"] + _, err = self._get_existing_session_or_404(session_id) + if err: + return err + body, err = await self._read_json_body(request) + if err: + return err + user_message, err = _session_chat_user_message(body) + if err is not None: + return err + system_prompt = body.get("system_message") or body.get("instructions") + if system_prompt is not None and not isinstance(system_prompt, str): + return web.json_response(_openai_error("system_message must be a string", code="invalid_system_message"), status=400) + history = self._conversation_history_for_session(session_id) + result, usage = await self._run_agent( + user_message=user_message, + conversation_history=history, + ephemeral_system_prompt=system_prompt, + session_id=session_id, + gateway_session_key=gateway_session_key, + ) + effective_session_id = result.get("session_id") if isinstance(result, dict) else session_id + final_response = result.get("final_response", "") if isinstance(result, dict) else "" + headers = {"X-Hermes-Session-Id": effective_session_id or session_id} + if gateway_session_key: + headers["X-Hermes-Session-Key"] = gateway_session_key + return web.json_response( + { + "object": "hermes.session.chat.completion", + "session_id": effective_session_id or session_id, + "message": {"role": "assistant", "content": final_response}, + "usage": usage, + }, + headers=headers, + ) + + async def _handle_session_chat_stream(self, request: "web.Request") -> "web.StreamResponse": + """POST /api/sessions/{session_id}/chat/stream — SSE wrapper over _run_agent.""" + auth_err = self._check_auth(request) + if auth_err: + return auth_err + gateway_session_key, key_err = self._parse_session_key_header(request) + if key_err is not None: + return key_err + session_id = request.match_info["session_id"] + _, err = self._get_existing_session_or_404(session_id) + if err: + return err + body, err = await self._read_json_body(request) + if err: + return err + user_message, err = _session_chat_user_message(body) + if err is not None: + return err + system_prompt = body.get("system_message") or body.get("instructions") + if system_prompt is not None and not isinstance(system_prompt, str): + return web.json_response(_openai_error("system_message must be a string", code="invalid_system_message"), status=400) + + loop = asyncio.get_running_loop() + queue: "asyncio.Queue[Optional[tuple[str, Dict[str, Any]]]]" = asyncio.Queue() + message_id = f"msg_{uuid.uuid4().hex}" + run_id = f"run_{uuid.uuid4().hex}" + seq = 0 + + def _event_payload(name: str, payload: Dict[str, Any]) -> tuple[str, Dict[str, Any]]: + nonlocal seq + seq += 1 + payload.setdefault("session_id", session_id) + payload.setdefault("run_id", run_id) + payload.setdefault("seq", seq) + payload.setdefault("ts", time.time()) + return name, payload + + def _enqueue(name: str, payload: Dict[str, Any]) -> None: + event = _event_payload(name, payload) + try: + running_loop = asyncio.get_running_loop() + except RuntimeError: + running_loop = None + try: + if running_loop is loop: + queue.put_nowait(event) + else: + loop.call_soon_threadsafe(queue.put_nowait, event) + except RuntimeError: + pass + + def _delta(delta: str) -> None: + if delta: + _enqueue("assistant.delta", {"message_id": message_id, "delta": delta}) + + def _tool_progress(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs) -> None: + if event_type == "reasoning.available": + _enqueue("tool.progress", {"message_id": message_id, "tool_name": tool_name or "_thinking", "delta": preview or ""}) + elif event_type in {"tool.started", "tool.completed", "tool.failed"}: + event_name = event_type.replace("tool.", "tool.") + _enqueue(event_name, {"message_id": message_id, "tool_name": tool_name, "preview": preview, "args": args}) + + async def _run_and_signal() -> None: + try: + await queue.put(_event_payload("run.started", {"user_message": {"role": "user", "content": user_message}})) + await queue.put(_event_payload("message.started", {"message": {"id": message_id, "role": "assistant"}})) + history = self._conversation_history_for_session(session_id) + result, usage = await self._run_agent( + user_message=user_message, + conversation_history=history, + ephemeral_system_prompt=system_prompt, + session_id=session_id, + stream_delta_callback=_delta, + tool_progress_callback=_tool_progress, + gateway_session_key=gateway_session_key, + ) + final_response = result.get("final_response", "") if isinstance(result, dict) else "" + effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id + await queue.put(_event_payload("assistant.completed", { + "session_id": effective_session_id, + "message_id": message_id, + "content": final_response, + "completed": True, + "partial": False, + "interrupted": False, + })) + await queue.put(_event_payload("run.completed", { + "session_id": effective_session_id, + "message_id": message_id, + "completed": True, + "usage": usage, + })) + except Exception as exc: + logger.exception("[api_server] session chat stream failed") + await queue.put(_event_payload("error", {"message": str(exc)})) + finally: + await queue.put(_event_payload("done", {})) + await queue.put(None) + + task = asyncio.create_task(_run_and_signal()) + try: + self._background_tasks.add(task) + except TypeError: + pass + if hasattr(task, "add_done_callback"): + task.add_done_callback(self._background_tasks.discard) + + headers = { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "X-Hermes-Session-Id": session_id, + } + if gateway_session_key: + headers["X-Hermes-Session-Key"] = gateway_session_key + response = web.StreamResponse(status=200, headers=headers) + await response.prepare(request) + last_write = time.monotonic() + try: + while True: + try: + item = await asyncio.wait_for(queue.get(), timeout=CHAT_COMPLETIONS_SSE_KEEPALIVE_SECONDS) + except asyncio.TimeoutError: + await response.write(b": keepalive\n\n") + last_write = time.monotonic() + continue + if item is None: + break + name, payload = item + data = json.dumps(payload, ensure_ascii=False) + await response.write(f"event: {name}\ndata: {data}\n\n".encode("utf-8")) + last_write = time.monotonic() + except (asyncio.CancelledError, ConnectionResetError): + task.cancel() + raise + except Exception as exc: + logger.debug("[api_server] session SSE stream error: %s", exc) + return response + async def _handle_chat_completions(self, request: "web.Request") -> "web.Response": """POST /v1/chat/completions — OpenAI Chat Completions format.""" auth_err = self._check_auth(request) @@ -3486,12 +4047,24 @@ async def connect(self) -> bool: try: mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None] self._app = web.Application(middlewares=mws, client_max_size=MAX_REQUEST_BYTES) - self._app["api_server_adapter"] = self + assert self._app is not None self._app.router.add_get("/health", self._handle_health) self._app.router.add_get("/health/detailed", self._handle_health_detailed) self._app.router.add_get("/v1/health", self._handle_health) self._app.router.add_get("/v1/models", self._handle_models) self._app.router.add_get("/v1/capabilities", self._handle_capabilities) + self._app.router.add_get("/v1/skills", self._handle_skills) + self._app.router.add_get("/v1/toolsets", self._handle_toolsets) + # Session/client control surface (thin wrappers over SessionDB + _run_agent) + self._app.router.add_get("/api/sessions", self._handle_list_sessions) + self._app.router.add_post("/api/sessions", self._handle_create_session) + self._app.router.add_get("/api/sessions/{session_id}", self._handle_get_session) + self._app.router.add_patch("/api/sessions/{session_id}", self._handle_patch_session) + self._app.router.add_delete("/api/sessions/{session_id}", self._handle_delete_session) + self._app.router.add_get("/api/sessions/{session_id}/messages", self._handle_session_messages) + self._app.router.add_post("/api/sessions/{session_id}/fork", self._handle_fork_session) + self._app.router.add_post("/api/sessions/{session_id}/chat", self._handle_session_chat) + self._app.router.add_post("/api/sessions/{session_id}/chat/stream", self._handle_session_chat_stream) self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions) self._app.router.add_post("/v1/responses", self._handle_responses) self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response) @@ -3511,6 +4084,12 @@ async def connect(self) -> bool: self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events) self._app.router.add_post("/v1/runs/{run_id}/approval", self._handle_run_approval) self._app.router.add_post("/v1/runs/{run_id}/stop", self._handle_stop_run) + # Store the adapter after native routes are registered. Local Hermes-Relay + # bootstrap shims use this key as a feature-detection hook; registering + # native routes first lets those shims no-op instead of shadowing the + # upstream session-control handlers. + self._app["api_server_adapter"] = self + # Start background sweep to clean up orphaned (unconsumed) run streams sweep_task = asyncio.create_task(self._sweep_orphaned_runs()) try: diff --git a/gateway/run.py b/gateway/run.py index c595e51b86d0d..cd6015d32519a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -250,6 +250,7 @@ def _prepare_gateway_status_message(platform: Any, event_type: str, message: str r"|configured\s+compression\s+model\s+.+\s+failed" r"|no\s+auxiliary\s+llm\s+provider\s+configured" r"|auto-lowered\s+compression\s+threshold" + r"|compacting\s+context\s+[—-]\s+summarizing\s+earlier\s+conversation" r"|preflight\s+compression" r"|rate\s+limited\.\s+waiting\s+\d" r"|retrying\s+in\s+\d" @@ -993,7 +994,6 @@ def _reload_runtime_env_preserving_config_authority() -> None: "singularity_image": "TERMINAL_SINGULARITY_IMAGE", "modal_image": "TERMINAL_MODAL_IMAGE", "daytona_image": "TERMINAL_DAYTONA_IMAGE", - "vercel_runtime": "TERMINAL_VERCEL_RUNTIME", "ssh_host": "TERMINAL_SSH_HOST", "ssh_user": "TERMINAL_SSH_USER", "ssh_port": "TERMINAL_SSH_PORT", @@ -1253,14 +1253,19 @@ def _resolve_runtime_agent_kwargs() -> dict: resolve_runtime_provider, format_runtime_provider_error, ) - from hermes_cli.auth import AuthError + from hermes_cli.auth import AuthError, is_rate_limited_auth_error try: runtime = resolve_runtime_provider() except AuthError as auth_exc: - # Primary provider auth failed (expired token, revoked key, etc.). - # Try the fallback provider chain before raising. - logger.warning("Primary provider auth failed: %s — trying fallback", auth_exc) + # Distinguish a transient rate-limit/quota cap (credentials are fine, + # re-auth cannot help) from a genuine auth failure (expired/revoked + # token). Both fall through to the fallback chain, but the log message + # must not mislabel a quota exhaustion as an auth failure (#32790). + if is_rate_limited_auth_error(auth_exc): + logger.warning("Primary provider rate-limited (429): %s — trying fallback", auth_exc) + else: + logger.warning("Primary provider auth failed: %s — trying fallback", auth_exc) fb_config = _try_resolve_fallback_provider() if fb_config is not None: return fb_config @@ -1306,9 +1311,13 @@ def _try_resolve_fallback_provider() -> dict | None: explicit_base_url=entry.get("base_url"), explicit_api_key=explicit_api_key, ) + # Log the literal `provider` key from config, not the resolved + # runtime category — an Ollama fallback resolves through the + # OpenAI-compatible path and would otherwise be logged as + # "openrouter", contradicting the operator's config (#32790). logger.info( "Fallback provider resolved: %s model=%s", - runtime.get("provider"), + entry.get("provider") or runtime.get("provider"), entry.get("model"), ) return { @@ -3442,9 +3451,21 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session self._busy_ack_ts[session_key] = now - # Build a status-rich acknowledgment + # Build a status-rich acknowledgment. Mobile chat defaults keep this + # terse; detailed iteration/tool state is still available in logs and + # can be opted in per platform via display.platforms..busy_ack_detail. + from gateway.display_config import resolve_display_setting status_parts = [] - if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: + busy_ack_detail_enabled = bool( + resolve_display_setting( + _load_gateway_config(), + _platform_config_key(event.source.platform), + "busy_ack_detail", + True, + ) + ) + + if busy_ack_detail_enabled and running_agent and running_agent is not _AGENT_PENDING_SENTINEL: try: summary = running_agent.get_activity_summary() iteration = summary.get("api_call_count", 0) @@ -7275,6 +7296,13 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if _denied is not None: return _denied + # Telegram sends /start for bot launches/deep-links. Treat it as a + # platform ping, not a user command: no help dump, no agent + # interrupt, no queued text. + if _cmd_def_inner and _cmd_def_inner.name == "start": + logger.info("Ignoring /start platform ping for active session %s", _quick_key) + return "" + if _cmd_def_inner and _cmd_def_inner.name == "restart": return await self._handle_restart_command(event) @@ -7731,6 +7759,10 @@ async def _do_reset(): if canonical == "help": return await self._handle_help_command(event) + if canonical == "start": + logger.info("Ignoring /start platform ping for session %s", _quick_key) + return "" + if canonical == "commands": return await self._handle_commands_command(event) @@ -13714,6 +13746,40 @@ async def _execute_mcp_reload(self, event: MessageEvent) -> str: else: lines.append(t("gateway.reload_mcp.tools_available", tools=len(new_tools), servers=len(connected_servers))) + # Refresh cached agents so existing sessions see new MCP tools on + # their next turn — without this, the user has to `/new` (which + # discards conversation history) to pick up tools from a server + # that was just added or reconnected. The user has already + # consented to the prompt-cache invalidation via the slash-confirm + # gate in _handle_reload_mcp_command before we reach this point. + try: + from model_tools import get_tool_definitions + _cache = getattr(self, "_agent_cache", None) + _cache_lock = getattr(self, "_agent_cache_lock", None) + if _cache_lock is not None and _cache: + with _cache_lock: + for _sess_key, _entry in list(_cache.items()): + try: + _agent = _entry[0] if isinstance(_entry, tuple) else _entry + except Exception: + continue + if _agent is None: + continue + new_defs = get_tool_definitions( + enabled_toolsets=getattr(_agent, "enabled_toolsets", None), + disabled_toolsets=getattr(_agent, "disabled_toolsets", None), + quiet_mode=True, + ) + _agent.tools = new_defs + _agent.valid_tool_names = { + t["function"]["name"] for t in new_defs + } if new_defs else set() + except Exception as _exc: + logger.debug( + "Failed to update cached agent tools after MCP reload: %s", + _exc, + ) + # Inject a message at the END of the session history so the # model knows tools changed on its next turn. Appended after # all existing messages to preserve prompt-cache for the prefix. @@ -16204,9 +16270,13 @@ def _run_still_current() -> bool: # in chat platforms while opting into concise mid-turn updates. interim_assistant_messages_enabled = ( source.platform != Platform.WEBHOOK - and is_truthy_value( - display_config.get("interim_assistant_messages"), - default=True, + and bool( + resolve_display_setting( + user_config, + platform_key, + "interim_assistant_messages", + True, + ) ) ) # Per-chat opt-out for interim messages — same shape as the @@ -16240,7 +16310,7 @@ def _run_still_current() -> bool: # Auto-cleanup of temporary progress bubbles (Telegram + any adapter # that implements ``delete_message``). When enabled via # ``display.platforms..cleanup_progress: true``, message IDs - # from the tool-progress / "Still working..." / status-callback bubbles + # from the tool-progress / "⏳ Working — N min" / status-callback bubbles # are collected here and deleted after the final response lands. # Failed runs skip cleanup so the bubbles remain as breadcrumbs. _cleanup_progress = bool( @@ -17813,6 +17883,15 @@ async def monitor_for_interrupt(): # 0 = disable notifications. _NOTIFY_INTERVAL_RAW = _float_env("HERMES_AGENT_NOTIFY_INTERVAL", 180) _NOTIFY_INTERVAL = _NOTIFY_INTERVAL_RAW if _NOTIFY_INTERVAL_RAW > 0 else None + if not bool( + resolve_display_setting( + user_config, + platform_key, + "long_running_notifications", + True, + ) + ): + _NOTIFY_INTERVAL = None _notify_start = time.time() async def _notify_long_running(): @@ -17821,41 +17900,74 @@ async def _notify_long_running(): _notify_adapter = self.adapters.get(source.platform) if not _notify_adapter: return + # Track the heartbeat message id so we can edit-in-place on + # platforms that support it (Telegram, Discord, Slack, etc.) + # instead of spamming a new "Still working" bubble every + # interval. Falls back to send-new when edit fails or isn't + # supported by the adapter. + _heartbeat_msg_id: Optional[str] = None while True: await asyncio.sleep(_NOTIFY_INTERVAL) _elapsed_mins = int((time.time() - _notify_start) // 60) - # Include agent activity context if available. + # Include agent activity context if available. Default + # heartbeat is terse: elapsed + current tool. Verbose + # iteration counter is gated on busy_ack_detail so users + # who want it can opt in per platform. _agent_ref = agent_holder[0] _status_detail = "" + _want_iteration_detail = bool( + resolve_display_setting( + user_config, + platform_key, + "busy_ack_detail", + True, + ) + ) if _agent_ref and hasattr(_agent_ref, "get_activity_summary"): try: _a = _agent_ref.get_activity_summary() - _parts = [f"iteration {_a['api_call_count']}/{_a['max_iterations']}"] - if _a.get("current_tool"): - _parts.append(f"running: {_a['current_tool']}") - else: - _parts.append(_a.get("last_activity_desc", "")) - _status_detail = " — " + ", ".join(_parts) + _parts = [] + if _want_iteration_detail: + _parts.append( + f"iteration {_a['api_call_count']}/{_a['max_iterations']}" + ) + _action = _a.get("current_tool") or _a.get("last_activity_desc") + if _action: + _parts.append(str(_action)) + if _parts: + _status_detail = " — " + ", ".join(_parts) except Exception: pass - # Tag the send so end-user-channel adapters can drop the - # ping without re-parsing the body. The ⏳ prefix already - # trips body-based glyph filters, but the explicit tag is - # both cheaper and survives prefix stripping upstream. + # Tag the send so end-user-channel adapters (Inkbox email/SMS) + # can drop the ping without re-parsing the body. Preserved + # across the upstream switch to editable heartbeats. _notify_meta = dict(_status_thread_metadata or {}) _notify_meta["notice_type"] = "notify_interval" + _heartbeat_text = f"⏳ Working — {_elapsed_mins} min{_status_detail}" try: - _notify_res = await _notify_adapter.send( - source.chat_id, - f"⏳ Still working... ({_elapsed_mins} min elapsed{_status_detail})", - metadata=_notify_meta, - ) - if ( - _cleanup_progress - and getattr(_notify_res, "success", False) - and getattr(_notify_res, "message_id", None) - ): - _cleanup_msg_ids.append(str(_notify_res.message_id)) + _notify_res = None + if _heartbeat_msg_id: + try: + _notify_res = await _notify_adapter.edit_message( + source.chat_id, + _heartbeat_msg_id, + _heartbeat_text, + ) + except Exception as _ee: + logger.debug("Heartbeat edit failed: %s", _ee) + _notify_res = None + if not (_notify_res and getattr(_notify_res, "success", False)): + _notify_res = await _notify_adapter.send( + source.chat_id, + _heartbeat_text, + metadata=_notify_meta, + ) + if getattr(_notify_res, "success", False) and getattr( + _notify_res, "message_id", None + ): + _heartbeat_msg_id = str(_notify_res.message_id) + if _cleanup_progress: + _cleanup_msg_ids.append(_heartbeat_msg_id) except Exception as _ne: logger.debug("Long-running notification error: %s", _ne) diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6f4fc3446361b..87069b3de8d2f 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -379,14 +379,6 @@ class ProviderConfig: api_key_env_vars=("NVIDIA_API_KEY",), base_url_env_var="NVIDIA_BASE_URL", ), - "ai-gateway": ProviderConfig( - id="ai-gateway", - name="Vercel AI Gateway", - auth_type="api_key", - inference_base_url="https://ai-gateway.vercel.sh/v1", - api_key_env_vars=("AI_GATEWAY_API_KEY",), - base_url_env_var="AI_GATEWAY_BASE_URL", - ), "opencode-zen": ProviderConfig( id="opencode-zen", name="OpenCode Zen", @@ -402,6 +394,7 @@ class ProviderConfig: # OpenCode Go mixes API surfaces by model: # - GLM / Kimi use OpenAI-compatible chat completions under /v1 # - MiniMax models use Anthropic Messages under /v1/messages + # - Qwen 3.7 uses Anthropic Messages under /v1/messages # Keep the provider base at /v1 and select api_mode per-model. inference_base_url="https://opencode.ai/zen/go/v1", api_key_env_vars=("OPENCODE_GO_API_KEY",), @@ -736,6 +729,12 @@ def _resolve_zai_base_url(api_key: str, default_url: str, env_override: str) -> # Error Types # ============================================================================= +# Error code marking upstream rate-limit / usage-quota exhaustion (HTTP 429). +# Such failures are transient and re-authenticating cannot resolve them, so +# they must be kept distinct from missing/expired-credential errors. +CODEX_RATE_LIMITED_CODE = "codex_rate_limited" + + class AuthError(RuntimeError): """Structured auth error with UX mapping hints.""" @@ -753,11 +752,52 @@ def __init__( self.relogin_required = relogin_required +def is_rate_limited_auth_error(error: Exception) -> bool: + """True when an :class:`AuthError` represents upstream rate-limiting / quota + exhaustion rather than missing or invalid credentials. + + These failures are transient — re-authenticating cannot resolve them — so + callers should surface a "retry later" notice and prefer a fallback chain + instead of prompting the operator to run ``hermes auth``. + """ + return ( + isinstance(error, AuthError) + and not error.relogin_required + and error.code == CODEX_RATE_LIMITED_CODE + ) + + +def _parse_retry_after_seconds(headers: Any) -> Optional[int]: + """Best-effort parse of a ``Retry-After`` header into whole seconds. + + Supports the delta-seconds form (e.g. ``"120"``). HTTP-date forms and + missing/unparseable values return ``None`` rather than guessing. + """ + if headers is None: + return None + try: + raw = headers.get("retry-after") + except Exception: + return None + if raw is None: + return None + try: + seconds = int(str(raw).strip()) + except (TypeError, ValueError): + return None + return seconds if seconds >= 0 else None + + def format_auth_error(error: Exception) -> str: """Map auth failures to concise user-facing guidance.""" if not isinstance(error, AuthError): return str(error) + # Rate-limit / quota errors are not credential problems — never append the + # "re-authenticate" remediation, which would mislead the operator. + if is_rate_limited_auth_error(error): + return str(error) + if error.relogin_required: return f"{error} Run `hermes model` to re-authenticate." @@ -1439,7 +1479,6 @@ def resolve_provider( "github": "copilot", "github-copilot": "copilot", "github-models": "copilot", "github-model": "copilot", "github-copilot-acp": "copilot-acp", "copilot-acp-agent": "copilot-acp", - "aigateway": "ai-gateway", "vercel": "ai-gateway", "vercel-ai-gateway": "ai-gateway", "opencode": "opencode-zen", "zen": "opencode-zen", "qwen-portal": "qwen-oauth", "qwen-cli": "qwen-oauth", "qwen-oauth": "qwen-oauth", "google-gemini-cli": "google-gemini-cli", "gemini-cli": "google-gemini-cli", "gemini-oauth": "google-gemini-cli", "hf": "huggingface", "hugging-face": "huggingface", "huggingface-hub": "huggingface", @@ -3231,6 +3270,48 @@ def _read_codex_tokens(*, _lock: bool = True) -> Dict[str, Any]: } +def _sync_codex_pool_entries( + auth_store: Dict[str, Any], + tokens: Dict[str, str], + last_refresh: Optional[str], +) -> None: + """Mirror a fresh Codex re-auth into the credential_pool singleton entries. + + The runtime selects credentials from ``credential_pool.openai-codex``, not + from ``providers.openai-codex.tokens``. A re-auth invalidates the prior + OAuth pair server-side, but the pool's ``device_code`` entry keeps holding + the now-consumed refresh token plus any stale error markers — so the next + request spends a dead token and gets a 401 ``token_invalidated``. Update + the singleton-seeded entries in lockstep with the provider tokens and clear + the error state so the fresh credentials take effect immediately. Manual + (``manual:*``) entries are independent credentials and are left untouched. + """ + access_token = tokens.get("access_token") + if not access_token: + return + refresh_token = tokens.get("refresh_token") + pool = auth_store.get("credential_pool") + if not isinstance(pool, dict): + return + entries = pool.get("openai-codex") + if not isinstance(entries, list): + return + for entry in entries: + if not isinstance(entry, dict) or entry.get("source") != "device_code": + continue + entry["access_token"] = access_token + if refresh_token: + entry["refresh_token"] = refresh_token + if last_refresh: + entry["last_refresh"] = last_refresh + entry["last_status"] = None + entry["last_status_at"] = None + entry["last_error_code"] = None + entry["last_error_reason"] = None + entry["last_error_message"] = None + entry["last_error_reset_at"] = None + + def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None: """Save Codex OAuth tokens to Hermes auth store (~/.hermes/auth.json).""" if last_refresh is None: @@ -3242,6 +3323,7 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None) -> None state["last_refresh"] = last_refresh state["auth_mode"] = "chatgpt" _save_provider_state(auth_store, "openai-codex", state) + _sync_codex_pool_entries(auth_store, tokens, last_refresh) _save_auth_store(auth_store) @@ -3273,6 +3355,30 @@ def refresh_codex_oauth_pure( }, ) + if response.status_code == 429: + # Upstream rate-limit / usage-quota exhaustion on the token endpoint. + # The stored refresh token is still valid here — re-authenticating + # cannot lift a quota cap. Classify distinctly from auth failures so + # callers surface a "retry later" notice instead of a misleading + # "run hermes auth" prompt (see issue #32790). + retry_after = _parse_retry_after_seconds(getattr(response, "headers", None)) + if retry_after is not None: + message = ( + f"Codex provider quota exhausted (429); retry after {retry_after}s. " + "Credentials are still valid." + ) + else: + message = ( + "Codex provider quota exhausted (429). Credentials are still valid; " + "retry after the usage limit resets." + ) + raise AuthError( + message, + provider="openai-codex", + code=CODEX_RATE_LIMITED_CODE, + relogin_required=False, + ) + if response.status_code != 200: code = "codex_refresh_failed" message = f"Codex token refresh failed with status {response.status_code}." @@ -3410,8 +3516,36 @@ def resolve_codex_runtime_credentials( refresh_if_expiring: bool = True, refresh_skew_seconds: int = CODEX_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, ) -> Dict[str, Any]: - """Resolve runtime credentials from Hermes's own Codex token store.""" - data = _read_codex_tokens() + """Resolve runtime credentials from Hermes's own Codex token store. + + Falls back to the credential pool when the singleton (``providers.openai-codex.tokens``) + has no usable access_token but the pool (``credential_pool.openai-codex``) does. This + closes the divergence between the chat path (singleton-only via this function) and + the auxiliary path (pool-first via ``_read_codex_access_token``). Without this + fallback, a user whose tokens live only in the pool — for example after a manual + pool seed, a partial re-auth, or pool-only restoration from a backup — gets a bare + HTTP 401 ``Missing Authentication header`` from the wire instead of a usable + credential. See issue #32992. + """ + try: + data = _read_codex_tokens() + except AuthError: + pool_token = _pool_codex_access_token() + if pool_token: + base_url = ( + os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") + or DEFAULT_CODEX_BASE_URL + ) + return { + "provider": "openai-codex", + "base_url": base_url, + "api_key": pool_token, + "source": "credential_pool", + "last_refresh": None, + "auth_mode": "chatgpt", + } + raise + tokens = dict(data["tokens"]) access_token = str(tokens.get("access_token", "") or "").strip() refresh_timeout_seconds = float(os.getenv("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", "20")) @@ -3449,6 +3583,46 @@ def resolve_codex_runtime_credentials( } +def _pool_codex_access_token() -> str: + """Return the most-recent usable access_token from the openai-codex pool. + + Used as a fallback by ``resolve_codex_runtime_credentials`` when the + singleton has no creds. Reads ``credential_pool.openai-codex`` entries + directly from auth.json and picks the first non-empty access_token, + preferring entries that are not currently in an exhaustion cooldown. + Returns ``""`` when no usable entry is found (caller handles by raising + the original AuthError). + """ + try: + with _auth_store_lock(): + auth_store = _load_auth_store() + pool = auth_store.get("credential_pool") + if not isinstance(pool, dict): + return "" + entries = pool.get("openai-codex") + if not isinstance(entries, list): + return "" + + def _entry_usable(entry: Dict[str, Any]) -> bool: + if not isinstance(entry, dict): + return False + token = entry.get("access_token") + if not isinstance(token, str) or not token.strip(): + return False + # Skip entries currently in an exhaustion cooldown window. + reset_at = entry.get("last_error_reset_at") + if isinstance(reset_at, (int, float)) and reset_at > time.time(): + return False + return True + + for entry in entries: + if _entry_usable(entry): + return str(entry.get("access_token", "")).strip() + except Exception: + logger.debug("Codex pool fallback lookup failed", exc_info=True) + return "" + + # ============================================================================= # xAI Grok OAuth — tokens stored in ~/.hermes/auth.json # ============================================================================= diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index f589248621c56..47cc173396703 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -63,6 +63,8 @@ class CommandDef: COMMAND_REGISTRY: list[CommandDef] = [ # Session + CommandDef("start", "Acknowledge platform start pings without a reply", "Session", + gateway_only=True), CommandDef("new", "Start a new session (fresh session ID + history)", "Session", aliases=("reset",), args_hint="[name]"), CommandDef("topic", "Enable or inspect Telegram DM topic sessions", "Session", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 92f3faa799373..e0dc2d9ebc862 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -718,8 +718,7 @@ def _ensure_hermes_home_managed(home: Path): "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20", "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20", "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", - "vercel_runtime": "node24", - # Container resource limits (docker, singularity, modal, daytona, vercel_sandbox — ignored for local/ssh) + # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh) "container_cpu": 1, "container_memory": 5120, # MB (default 5GB) "container_disk": 51200, # MB (default 50GB) @@ -1187,6 +1186,44 @@ def _ensure_hermes_home_managed(home: Path): # 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) + }, + # Public URL override (env: ``HERMES_DASHBOARD_PUBLIC_URL``). + # When set, this is the complete authority — scheme + host + + # optional path prefix (e.g. ``https://example.com/hermes``) — + # the OAuth ``redirect_uri`` is built from. Set this for deploys + # behind reverse proxies that don't reliably forward + # ``X-Forwarded-Host`` / ``X-Forwarded-Proto`` / ``X-Forwarded-Prefix`` + # (manual nginx setups, on-prem ingresses, custom-domain Fly + # deploys without proper proxy headers). When set, + # ``X-Forwarded-Prefix`` is IGNORED on the OAuth path because + # the operator has declared the public URL — we no longer need + # to guess from proxy headers, and stacking the prefix on top + # would double-prefix the common case where the prefix is + # already baked into ``public_url``. Leave empty to use the + # existing proxy-header reconstruction (the default). + # + # Validation: rejects values without ``http(s)://`` scheme or + # without a host, and any string containing quote / angle / + # whitespace / control characters. A malformed value silently + # falls through to request reconstruction rather than breaking + # the login flow. + "public_url": "", }, # Privacy settings @@ -5245,9 +5282,6 @@ def show_config(): print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}") daytona_key = get_env_value('DAYTONA_API_KEY') print(f" API key: {'configured' if daytona_key else '(not set)'}") - elif terminal.get('backend') == 'vercel_sandbox': - print(f" Vercel runtime: {terminal.get('vercel_runtime', 'node24')}") - print(f" Vercel auth: {'configured' if get_env_value('VERCEL_OIDC_TOKEN') or (get_env_value('VERCEL_TOKEN') and get_env_value('VERCEL_PROJECT_ID') and get_env_value('VERCEL_TEAM_ID')) else '(not set)'}") elif terminal.get('backend') == 'ssh': ssh_host = get_env_value('TERMINAL_SSH_HOST') ssh_user = get_env_value('TERMINAL_SSH_USER') @@ -5444,7 +5478,6 @@ def set_config_value(key: str, value: str): "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE", "terminal.modal_image": "TERMINAL_MODAL_IMAGE", "terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE", - "terminal.vercel_runtime": "TERMINAL_VERCEL_RUNTIME", "terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER", "terminal.docker_env": "TERMINAL_DOCKER_ENV", diff --git a/hermes_cli/dashboard_auth/__init__.py b/hermes_cli/dashboard_auth/__init__.py new file mode 100644 index 0000000000000..4a5c68b6e4e26 --- /dev/null +++ b/hermes_cli/dashboard_auth/__init__.py @@ -0,0 +1,40 @@ +"""Dashboard authentication provider framework. + +The dashboard auth gate engages only when the dashboard binds to a +non-loopback host without ``--insecure``. In that mode, every request must +carry a verified session from one of the registered ``DashboardAuthProvider`` +plugins. + +The Nous provider lives in ``plugins/dashboard-auth-nous/`` and is the +default. Third parties register their own providers via the plugin hook +``ctx.register_dashboard_auth_provider``. +""" +from hermes_cli.dashboard_auth.base import ( + DashboardAuthProvider, + Session, + LoginStart, + InvalidCodeError, + ProviderError, + RefreshExpiredError, + assert_protocol_compliance, +) +from hermes_cli.dashboard_auth.registry import ( + register_provider, + get_provider, + list_providers, + clear_providers, +) + +__all__ = [ + "DashboardAuthProvider", + "Session", + "LoginStart", + "InvalidCodeError", + "ProviderError", + "RefreshExpiredError", + "assert_protocol_compliance", + "register_provider", + "get_provider", + "list_providers", + "clear_providers", +] diff --git a/hermes_cli/dashboard_auth/audit.py b/hermes_cli/dashboard_auth/audit.py new file mode 100644 index 0000000000000..9e52ca75ebe64 --- /dev/null +++ b/hermes_cli/dashboard_auth/audit.py @@ -0,0 +1,87 @@ +"""Audit log for dashboard-auth events. + +Profile-aware location: ``$HERMES_HOME/logs/dashboard-auth.log``. +Format: one JSON object per line. Token-like fields are stripped before +serialisation to avoid leaking refresh tokens or JWTs to disk. + +This module deliberately keeps a minimal dependency surface — no imports +from ``hermes_constants`` or other hermes_cli modules — so it can be +imported safely from middleware code that loads early in the startup +sequence. +""" +from __future__ import annotations + +import datetime as _dt +import enum +import json +import logging +import os +import threading +from pathlib import Path +from typing import Any + +_log = logging.getLogger(__name__) +_write_lock = threading.Lock() + +# Field names that must never appear in the log raw. Any kwarg matching +# these is silently dropped. +_REDACTED_FIELDS: frozenset = frozenset({ + "access_token", "refresh_token", "code", "code_verifier", + "state", "ticket", "cookie", "Authorization", "authorization", +}) + + +class AuditEvent(enum.Enum): + """Event types written to dashboard-auth.log. + + Values are the literal ``event`` field on the JSON line. + """ + + LOGIN_START = "login_start" + LOGIN_SUCCESS = "login_success" + LOGIN_FAILURE = "login_failure" + LOGOUT = "logout" + REFRESH_SUCCESS = "refresh_success" + REFRESH_FAILURE = "refresh_failure" + REVOKE = "revoke" + SESSION_VERIFY_FAILURE = "session_verify_failure" + WS_TICKET_MINTED = "ws_ticket_minted" + WS_TICKET_REJECTED = "ws_ticket_rejected" + + +def _resolve_log_path() -> Path: + """``$HERMES_HOME/logs/dashboard-auth.log`` with the standard fallback. + + Mirrors ``hermes_constants.get_hermes_home`` semantics: env var wins, + else ``~/.hermes``. A local copy avoids an import cycle with the + middleware which lives below ``hermes_cli``. + """ + home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes") + return Path(home) / "logs" / "dashboard-auth.log" + + +def audit_log(event: AuditEvent, **fields: Any) -> None: + """Append one event to the audit log. + + Token-like fields are dropped. Missing log directory is created. + Write failures are logged at WARNING but never raise — auth must not + fail because the audit logger broke. + """ + safe_fields = { + k: v for k, v in fields.items() + if k not in _REDACTED_FIELDS + } + entry = { + "ts": _dt.datetime.now(_dt.timezone.utc).isoformat(), + "event": event.value, + **safe_fields, + } + line = json.dumps(entry, separators=(",", ":")) + "\n" + path = _resolve_log_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + with _write_lock: + with open(path, "a", encoding="utf-8") as f: + f.write(line) + except Exception as e: + _log.warning("dashboard-auth audit log write failed: %s", e) diff --git a/hermes_cli/dashboard_auth/base.py b/hermes_cli/dashboard_auth/base.py new file mode 100644 index 0000000000000..207c7c602d4ad --- /dev/null +++ b/hermes_cli/dashboard_auth/base.py @@ -0,0 +1,158 @@ +"""Abstract base + dataclasses + exceptions for dashboard auth providers.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class Session: + """A verified identity. Returned by ``complete_login`` and ``verify_session``. + + All fields are mandatory. Providers that don't have a concept of orgs + should set ``org_id`` to an empty string. ``access_token`` and + ``refresh_token`` are opaque to Hermes — provider-specific. + """ + + user_id: str + email: str + display_name: str + org_id: str + provider: str + expires_at: int # unix seconds; the access_token's exp claim + access_token: str + refresh_token: str + + +@dataclass(frozen=True) +class LoginStart: + """First leg of the OAuth round trip. + + ``redirect_url`` is the URL the browser must navigate to (e.g. the + Portal's ``/oauth/authorize``). ``cookie_payload`` is a dict of cookie + name → serialised value that the auth route will ``Set-Cookie`` on the + response. Used for PKCE state, CSRF nonces, etc. Cookies set here MUST + be HttpOnly + Secure (when over HTTPS) + SameSite=Lax with a TTL ≤ 10 + minutes (the login lifetime). + """ + + redirect_url: str + cookie_payload: dict[str, str] + + +class ProviderError(Exception): + """IDP unreachable, network error, or other transient failure. + + Middleware translates this to HTTP 503. + """ + + +class InvalidCodeError(Exception): + """The OAuth callback ``code`` / ``state`` failed validation. + + Middleware translates this to HTTP 400. + """ + + +class RefreshExpiredError(Exception): + """The refresh token is dead. + + Middleware clears cookies and forces re-login (302 → ``/login``). + """ + + +class DashboardAuthProvider(ABC): + """Protocol every dashboard-auth provider plugin implements. + + Lifecycle: + 1. ``start_login`` — user clicks "Log in with X" on the login page. + Provider returns a redirect URL and any PKCE/CSRF state to stash + in short-lived cookies. + 2. Browser bounces through the OAuth IDP and lands at /auth/callback. + 3. ``complete_login`` — exchange the code + verifier for a Session. + 4. ``verify_session`` — called on every request to validate the + access token in the cookie. Returns ``None`` if the token is + expired or invalid (middleware then triggers refresh or logout). + 5. ``refresh_session`` — called when the access token is near expiry. + Returns a new Session with rotated tokens. + 6. ``revoke_session`` — called on /auth/logout. Best-effort. + + Failure semantics: + * ``start_login`` may raise ``ProviderError`` if the IDP is + unreachable. + * ``complete_login`` raises ``InvalidCodeError`` on bad code/state; + ``ProviderError`` if the IDP is unreachable. + * ``verify_session`` returns ``None`` on expiry / unknown token; + raises ``ProviderError`` if the IDP is unreachable. Middleware + treats expiry and unreachable differently (expiry → refresh; + unreachable → 503). + * ``refresh_session`` raises ``RefreshExpiredError`` when the + refresh token is also invalid; middleware then forces re-login. + Raises ``ProviderError`` on network failure. + * ``revoke_session`` is best-effort and must not raise. + + Subclasses MUST set ``name`` (lowercase identifier, stable forever) + and ``display_name`` (user-facing label on the login page). + """ + + name: str = "" + display_name: str = "" + + @abstractmethod + def start_login(self, *, redirect_uri: str) -> LoginStart: ... + + @abstractmethod + def complete_login( + self, + *, + code: str, + state: str, + code_verifier: str, + redirect_uri: str, + ) -> Session: ... + + @abstractmethod + def verify_session(self, *, access_token: str) -> Optional[Session]: ... + + @abstractmethod + def refresh_session(self, *, refresh_token: str) -> Session: ... + + @abstractmethod + def revoke_session(self, *, refresh_token: str) -> None: ... + + +def assert_protocol_compliance(cls: type) -> None: + """Raise ``TypeError`` if ``cls`` doesn't fully implement the provider protocol. + + Call this in every provider plugin's unit tests:: + + def test_protocol_compliance(): + assert_protocol_compliance(MyProvider) + + Returns ``None`` on success so callers can assert it explicitly. + """ + required_methods = ( + "start_login", + "complete_login", + "verify_session", + "refresh_session", + "revoke_session", + ) + required_attrs = ("name", "display_name") + + for attr in required_attrs: + val = getattr(cls, attr, "") + if not val: + raise TypeError( + f"{cls.__name__} missing or empty attribute: {attr!r}" + ) + for method in required_methods: + if not callable(getattr(cls, method, None)): + raise TypeError(f"{cls.__name__} missing method: {method}") + # Also catch the ABC-not-overridden case. + if getattr(cls, "__abstractmethods__", None): + raise TypeError( + f"{cls.__name__} has unimplemented abstract methods: " + f"{sorted(cls.__abstractmethods__)}" + ) diff --git a/hermes_cli/dashboard_auth/cookies.py b/hermes_cli/dashboard_auth/cookies.py new file mode 100644 index 0000000000000..f8fc77f242698 --- /dev/null +++ b/hermes_cli/dashboard_auth/cookies.py @@ -0,0 +1,234 @@ +"""Cookie helpers for dashboard auth. + +Three cookies in play: + - hermes_session_at: the OAuth access token + (HttpOnly, lifetime = token TTL) + - hermes_session_rt: the OAuth refresh token + (HttpOnly, lifetime = 30 days) + **DEPRECATED in OAuth contract v1** — Nous Portal + does not issue refresh tokens; we keep the cookie + name and clear semantics for forward compatibility + and to flush stale cookies from old browsers. + - hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider + hint (HttpOnly, lifetime = 10 minutes) + +All three are ``SameSite=Lax`` (browser will send on cross-site GET +top-level navigation, which we need for the IDP redirect back to +``/auth/callback``) and live under the prefix's Path. ``Secure`` is set +ONLY when the dashboard was reached over HTTPS — detected via the +request URL scheme, which honours ``X-Forwarded-Proto`` upstream of +Fly's TLS terminator when uvicorn is configured with +``proxy_headers=True``. Loopback dev traffic is always HTTP so +``Secure`` would lock the cookies out of the browser. + +Cookie prefix selection (browser hardening per +https://datatracker.ietf.org/doc/html/draft-west-cookie-prefixes): + + * Loopback HTTP — bare name. ``__Host-`` / ``__Secure-`` require + ``Secure``, which is incompatible with HTTP. + * Gated HTTPS, direct deploy (Path=/) — ``__Host-`` prefix. Binds the + cookie to the exact origin (no Domain attribute) — strongest spec + guarantee. + * Gated HTTPS, behind a reverse-proxy prefix (Path=/hermes) — + ``__Secure-`` prefix. ``__Host-`` is disallowed when Path != "/"; + ``__Secure-`` keeps the Secure-required hardening without the + Path constraint, and the explicit ``Path=/hermes`` covers + same-origin app isolation. + +The setters and readers BOTH consult the active prefix because the +cookie *name* changes — a reader that looked up the bare name when the +setter wrote ``__Secure-hermes_session_at`` would never find the value. + +.. deprecated:: contract v1 + ``set_session_cookies`` accepts ``refresh_token=""`` (the contract-v1 + default) and silently skips writing the RT cookie in that case. + ``clear_session_cookies`` still emits a Max-Age=0 deletion for the RT + cookie so users carrying a stale cookie from an earlier deployment get + it cleared on logout / session expiry. The full refresh-flow machinery + was rewritten as "401 → redirect to /login" in Phase 6. +""" +from __future__ import annotations + +from typing import Optional, Tuple + +from fastapi import Request +from fastapi.responses import Response + +# Bare cookie names — the request-scoped ``_resolved_name`` helper +# decides whether to prepend ``__Host-`` / ``__Secure-`` based on the +# request's HTTPS + prefix combination. +SESSION_AT_COOKIE = "hermes_session_at" +SESSION_RT_COOKIE = "hermes_session_rt" +PKCE_COOKIE = "hermes_session_pkce" + +# Possible name variants we may have to read back. Sorted so most-strict +# wins on iteration when both happen to be present (shouldn't happen in +# practice — a single request emits exactly one variant). +_NAME_VARIANTS = ("__Host-", "__Secure-", "") + +# 30 days — matches Portal's REFRESH_TOKEN_TTL_SECONDS +_RT_MAX_AGE = 30 * 24 * 60 * 60 +_PKCE_MAX_AGE = 10 * 60 + + +def _resolved_name(bare: str, *, use_https: bool, prefix: str) -> str: + """Pick the cookie-prefix variant for the active request shape. + + See module docstring for the prefix selection rules. Mismatch + between setter and reader would silently break sessions, so this + function is the single source of truth for naming. + """ + if not use_https: + return bare + if prefix: + # Path != "/" forbids __Host-; fall back to __Secure-. + return f"__Secure-{bare}" + return f"__Host-{bare}" + + +def _cookie_path(prefix: str) -> str: + """Cookie ``Path`` attribute for the active deploy shape. + + Under ``X-Forwarded-Prefix: /hermes`` we want ``Path=/hermes`` so: + a) the browser sends the cookie back on requests under the prefix + (browsers omit the cookie if request path doesn't start with + Path); + b) the cookie doesn't leak to other apps on the same origin + (``mission-control.tilos.com/billing/...``). + + Direct-deploy (no proxy prefix) gets ``Path=/``. + """ + return prefix if prefix else "/" + + +def _common_attrs(*, use_https: bool, prefix: str) -> dict: + attrs: dict = { + "httponly": True, + "samesite": "lax", + "path": _cookie_path(prefix), + } + if use_https: + attrs["secure"] = True + return attrs + + +def set_session_cookies( + response: Response, + *, + access_token: str, + refresh_token: str, + access_token_expires_in: int, + use_https: bool, + prefix: str = "", +) -> None: + """Set the session cookies on the response. + + ``access_token_expires_in`` is in seconds. Use the provider's reported + TTL for the access token. + + ``refresh_token`` is accepted for backward / forward compatibility but + SKIPPED when empty — Nous Portal contract v1 issues no refresh tokens + so a ``Session.refresh_token == ""`` from the provider means we don't + persist anything. If a future contract revision starts emitting refresh + tokens, this helper will write the RT cookie again with no other change. + + ``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``) + or ``""`` for a direct deploy. It influences both the cookie name + (``__Host-`` vs ``__Secure-`` vs bare) and the ``Path`` attribute. + """ + response.set_cookie( + _resolved_name(SESSION_AT_COOKIE, use_https=use_https, prefix=prefix), + access_token, + max_age=access_token_expires_in, + **_common_attrs(use_https=use_https, prefix=prefix), + ) + # Contract v1: empty refresh token means "don't persist RT cookie". + # Keeping a literal empty-value cookie around would be dead state at + # best, attack surface at worst. + if refresh_token: + response.set_cookie( + _resolved_name(SESSION_RT_COOKIE, use_https=use_https, prefix=prefix), + refresh_token, + max_age=_RT_MAX_AGE, + **_common_attrs(use_https=use_https, prefix=prefix), + ) + + +def clear_session_cookies(response: Response, *, prefix: str = "") -> None: + """Emit Max-Age=0 deletions for both session cookies. + + To delete a cookie reliably the deletion's ``Path`` must match the + set path AND the cookie name must match the variant the setter used. + We don't know which variant was originally set (cookie prefix + depends on the request that set it), so we emit deletions for every + plausible variant under the active path. + """ + path = _cookie_path(prefix) + for variant in _NAME_VARIANTS: + response.set_cookie( + f"{variant}{SESSION_AT_COOKIE}", "", max_age=0, + path=path, httponly=True, samesite="lax", + ) + response.set_cookie( + f"{variant}{SESSION_RT_COOKIE}", "", max_age=0, + path=path, httponly=True, samesite="lax", + ) + + +def set_pkce_cookie( + response: Response, *, payload: str, use_https: bool, prefix: str = "", +) -> None: + response.set_cookie( + _resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix), + payload, + max_age=_PKCE_MAX_AGE, + **_common_attrs(use_https=use_https, prefix=prefix), + ) + + +def clear_pkce_cookie(response: Response, *, prefix: str = "") -> None: + path = _cookie_path(prefix) + for variant in _NAME_VARIANTS: + response.set_cookie( + f"{variant}{PKCE_COOKIE}", "", max_age=0, + path=path, httponly=True, samesite="lax", + ) + + +def _read_with_fallback( + request: Request, bare_name: str, +) -> Optional[str]: + """Read a cookie by checking every prefix variant in order. + + The setter chooses one variant based on the active request shape; + the reader doesn't know which one fired (the request that READS + the cookie may not be the same shape as the request that SET it + in pathological cases). Trying all three guarantees we find it. + """ + for variant in _NAME_VARIANTS: + value = request.cookies.get(f"{variant}{bare_name}") + if value is not None: + return value + return None + + +def read_session_cookies(request: Request) -> Tuple[Optional[str], Optional[str]]: + """Returns (access_token, refresh_token), either may be None.""" + at = _read_with_fallback(request, SESSION_AT_COOKIE) + rt = _read_with_fallback(request, SESSION_RT_COOKIE) + return at, rt + + +def read_pkce_cookie(request: Request) -> Optional[str]: + return _read_with_fallback(request, PKCE_COOKIE) + + +def detect_https(request: Request) -> bool: + """Decide whether to set the ``Secure`` cookie flag. + + Reads ``request.url.scheme`` — under uvicorn's ``proxy_headers=True`` + (which start_server enables when the gate is active), this honours + ``X-Forwarded-Proto`` from Fly's TLS terminator. Loopback traffic is + always HTTP so this returns False there. + """ + return request.url.scheme == "https" diff --git a/hermes_cli/dashboard_auth/login_page.py b/hermes_cli/dashboard_auth/login_page.py new file mode 100644 index 0000000000000..74da4dbe2f029 --- /dev/null +++ b/hermes_cli/dashboard_auth/login_page.py @@ -0,0 +1,384 @@ +"""Server-rendered /login page. + +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 + +import html + +from hermes_cli.dashboard_auth import list_providers + +# Inline minimal CSS. The dashboard's full skin lives in the React +# 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 = """\ + + + + + +Sign in — Hermes Agent + + + +
+
NousResearch
+
+

Sign in

+

Choose a sign-in method to continue to the Hermes Agent dashboard.

+
+{provider_buttons} +
+
+
+ Public bind · Auth required +
+
+ + +""" + +_EMPTY_HTML = """\ + + + + + +Sign-in unavailable — Hermes Agent + + + +
+

Sign-in unavailable

+

This dashboard is bound to a non-loopback host but no authentication +providers are installed.

+

Install plugins/dashboard-auth-nous (default) or another +auth provider, or restart with --insecure to bypass the +auth gate (not recommended on untrusted networks).

+
+ + +""" + + +def render_login_html(*, next_path: str = "") -> str: + """Return the full HTML for ``GET /login``. + + ``next_path`` — when set, the post-login landing path the user + originally requested. Threaded into each provider button's ``href`` + as a ``next=`` query parameter so the OAuth round trip carries it + end-to-end. The caller (``routes.login_page``) is responsible for + validating ``next_path`` against the same-origin rules before we + emit it; we still HTML-escape it as defence in depth. + """ + providers = list_providers() + if not providers: + return _EMPTY_HTML + + if next_path: + # URL-encode then HTML-escape. The URL-encode step matches the + # gate's ``_safe_next_target`` output shape (also URL-encoded), + # so a value that round-tripped from /login?next=... back into + # the button href is byte-identical. + from urllib.parse import quote + next_qs = f"&next={html.escape(quote(next_path, safe=''), quote=True)}" + else: + next_qs = "" + + 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)) diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py new file mode 100644 index 0000000000000..5b42c90ebf7f5 --- /dev/null +++ b/hermes_cli/dashboard_auth/middleware.py @@ -0,0 +1,207 @@ +"""Auth-gate middleware for the dashboard. + +Engaged when ``app.state.auth_required is True``. The gate's job: + + 1. Allow a small set of routes through unauthenticated (login page, + ``/auth/*`` OAuth round trip, ``/api/auth/providers``, static + assets). + 2. For everything else, demand a valid session cookie and attach the + verified :class:`Session` to ``request.state.session``. + 3. On HTML routes, redirect missing/invalid cookies to ``/login``. + On ``/api/*`` routes, return 401 JSON. + +The middleware is a no-op when ``auth_required`` is False (loopback +mode); the legacy ``_SESSION_TOKEN`` ``auth_middleware`` handles those +binds. +""" +from __future__ import annotations + +import logging +from typing import Awaitable, Callable + +from fastapi import Request +from fastapi.responses import JSONResponse, RedirectResponse, Response + +from hermes_cli.dashboard_auth import list_providers +from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log +from hermes_cli.dashboard_auth.base import ProviderError +from hermes_cli.dashboard_auth.cookies import read_session_cookies + +_log = logging.getLogger(__name__) + +# Paths that bypass the auth gate. Order matters: prefix match. +_GATE_PUBLIC_PREFIXES: tuple[str, ...] = ( + "/auth/login", + "/auth/callback", + "/auth/logout", + "/login", + "/api/auth/providers", + "/assets/", + "/favicon.ico", + "/ds-assets/", + "/fonts/", + "/fonts-terminal/", +) + + +def _path_is_public(path: str) -> bool: + return any( + path == prefix or path.startswith(prefix) + for prefix in _GATE_PUBLIC_PREFIXES + ) + + +def _client_ip(request: Request) -> str: + fwd = request.headers.get("x-forwarded-for", "") + if fwd: + return fwd.split(",")[0].strip() + return request.client.host if request.client else "" + + +def _unauth_response(request: Request, *, reason: str) -> Response: + """API routes → 401 JSON with ``login_url``; HTML routes → 302 → /login. + + The JSON envelope carries a ``login_url`` field with a ``next=`` query + string so the SPA's global 401 handler can drop the user back where + they were after re-auth. The contract is intentionally simple so any + fetch-wrapper can implement the redirect without parsing details: + + if response.status === 401 && body.error in ("unauthenticated", + "session_expired"): + window.location.assign(body.login_url); + + HTML redirects also carry the ``next=`` query string so direct + navigation to ``/sessions`` (etc.) without a cookie comes back to + ``/sessions`` after login. + + Under a reverse proxy with ``X-Forwarded-Prefix: /hermes``, the + ``login_url`` is prefixed (``/hermes/login?next=...``) so the + browser's window.location.assign / Location: follow lands on the + proxied login page rather than the bare ``/login`` (which the + proxy doesn't route to the dashboard). + """ + from hermes_cli.dashboard_auth.prefix import prefix_from_request + + path = request.url.path + next_param = _safe_next_target(request) + prefix = prefix_from_request(request) + login_url = ( + f"{prefix}/login?next={next_param}" if next_param + else f"{prefix}/login" + ) + + if path.startswith("/api/"): + # API routes never get redirects: the browser fetch() API would + # follow a 302 into the cross-origin OAuth dance opaquely. Return + # 401 with a structured envelope so the SPA can full-page-navigate + # to login_url. + error_code = ( + "session_expired" + if reason == "invalid_or_expired_session" + else "unauthenticated" + ) + return JSONResponse( + { + "error": error_code, + "detail": "Unauthorized", + "reason": reason, + "login_url": login_url, + }, + status_code=401, + ) + return RedirectResponse(url=login_url, status_code=302) + + +def _safe_next_target(request: Request) -> str: + """Build the URL-encoded ``next`` query value, or empty string. + + Only same-origin relative paths are accepted; absolute URLs or + ``//evil.com`` open-redirect attempts are silently dropped. The empty + string return means the caller produces a bare ``/login`` URL — fine, + user lands at the dashboard root after re-auth. + """ + path = request.url.path + # Reject anything that doesn't start with "/" or starts with "//" + # (protocol-relative URL — would open-redirect to an attacker host). + if not path or not path.startswith("/") or path.startswith("//"): + return "" + # Don't redirect back to the auth routes themselves — that loops. + if any( + path == p or path.startswith(p) + for p in ("/login", "/auth/", "/api/auth/") + ): + return "" + # Preserve query string if present (e.g. /sessions?page=2). + query = request.url.query + target = f"{path}?{query}" if query else path + # urlencode the whole thing as a single value. + from urllib.parse import quote + return quote(target, safe="") + + +async def gated_auth_middleware( + request: Request, + call_next: Callable[[Request], Awaitable[Response]], +) -> Response: + """Engaged only when ``app.state.auth_required is True``. + + No-op pass-through in loopback mode so the legacy auth_middleware can + handle those binds via ``_SESSION_TOKEN``. + """ + if not getattr(request.app.state, "auth_required", False): + return await call_next(request) + + path = request.url.path + if _path_is_public(path): + return await call_next(request) + + at, _rt = read_session_cookies(request) + if not at: + return _unauth_response(request, reason="no_cookie") + + # Try every registered provider's verify_session in turn. Providers + # MUST return None for tokens they don't recognise (not raise). This + # lets multiple providers stack — the first one that recognises a + # token wins. + session = None + for provider in list_providers(): + try: + session = provider.verify_session(access_token=at) + except ProviderError as e: + _log.warning( + "dashboard-auth: provider %r unreachable during verify: %s", + provider.name, e, + ) + audit_log( + AuditEvent.SESSION_VERIFY_FAILURE, + provider=provider.name, + reason="provider_unreachable", + ip=_client_ip(request), + ) + return JSONResponse( + {"detail": f"Auth provider {provider.name!r} unreachable"}, + status_code=503, + ) + if session is not None: + break + + if session is None: + audit_log( + AuditEvent.SESSION_VERIFY_FAILURE, + reason="no_provider_recognises", + ip=_client_ip(request), + ) + response = _unauth_response(request, reason="invalid_or_expired_session") + # Clear the dead cookie so the browser doesn't keep sending it. + # Contract v1: no refresh token to retry with, so the only correct + # next step is full re-auth via /login. Importing locally avoids a + # cycle with cookies → middleware at module load. Pass the active + # prefix so the deletion's Path matches the set-Path (otherwise + # the browser ignores it). + from hermes_cli.dashboard_auth.cookies import clear_session_cookies + from hermes_cli.dashboard_auth.prefix import prefix_from_request + clear_session_cookies(response, prefix=prefix_from_request(request)) + return response + + request.state.session = session + return await call_next(request) diff --git a/hermes_cli/dashboard_auth/prefix.py b/hermes_cli/dashboard_auth/prefix.py new file mode 100644 index 0000000000000..0c009502390c6 --- /dev/null +++ b/hermes_cli/dashboard_auth/prefix.py @@ -0,0 +1,157 @@ +"""Helpers for X-Forwarded-Prefix support. + +Mission-control style deploys reverse-proxy the dashboard at a path +prefix (e.g. ``mission-control.tilos.com/hermes/*`` -> dashboard on +:9119), injecting ``X-Forwarded-Prefix: /hermes`` so the backend can +reconstruct prefixed URLs (Location: headers, OAuth redirect_uri, +cookie Path attributes, SPA asset URLs). + +This module is also the home of the ``HERMES_DASHBOARD_PUBLIC_URL`` / +``dashboard.public_url`` resolution — when the operator declares a +complete public URL (scheme + host + optional path prefix), we use +that directly for the OAuth ``redirect_uri`` and skip the +X-Forwarded-Prefix reconstruction. Relief valve for deploys where the +proxy header chain isn't reliable. + +The single source of truth for both helpers lives here so the gate +middleware, the OAuth routes, the cookie helpers, and the SPA mount +all agree on validation rules. +""" +from __future__ import annotations + +import logging +import os +import urllib.parse +from typing import Optional + +_log = logging.getLogger(__name__) + +# Characters that, if present in a public_url or prefix value, indicate +# either a typo or a header-injection attempt. Reject the whole value +# rather than try to sanitise — the operator can fix their config. +_REJECT_CHARS = frozenset(('"', "'", "<", ">", " ", "\n", "\r", "\t")) + + +def normalise_prefix(raw: Optional[str]) -> str: + """Normalise an X-Forwarded-Prefix header value. + + Returns a string like ``"/hermes"`` (no trailing slash) or ``""`` + when no prefix is set / the header is malformed. We deliberately + reject anything containing ``..`` or non-printable bytes so a + hostile proxy can't inject HTML or path-traversal sequences via the + prefix. + """ + if not raw: + return "" + p = raw.strip() + if not p: + return "" + if not p.startswith("/"): + p = "/" + p + p = p.rstrip("/") + if ( + "//" in p + or ".." in p + or any(c in p for c in _REJECT_CHARS) + ): + return "" + if len(p) > 64: + return "" + return p + + +def prefix_from_request(request) -> str: + """Convenience wrapper that reads the header off a Starlette/FastAPI + Request and normalises it. Returns ``""`` when no prefix. + """ + return normalise_prefix(request.headers.get("x-forwarded-prefix")) + + +# --------------------------------------------------------------------------- +# HERMES_DASHBOARD_PUBLIC_URL / dashboard.public_url +# --------------------------------------------------------------------------- + + +def _normalise_public_url(raw: Optional[str]) -> str: + """Normalise a ``dashboard.public_url`` value. + + Returns the cleaned URL (scheme://netloc[/path], trailing slash + removed) on success, or ``""`` when the value is empty, malformed, + or contains characters that suggest header injection. The caller + must treat ``""`` as "fall back to request reconstruction" — never + as "the user explicitly chose no public URL", because the two are + indistinguishable from an empty env var. + """ + if not raw: + return "" + url = raw.strip() + if not url: + return "" + # Reject control / quote / whitespace characters before trying to + # parse — urlparse is permissive enough to accept some hostile + # values (e.g. embedded newlines) and we want a hard "no" rather + # than a soft "maybe". + if any(c in url for c in _REJECT_CHARS): + return "" + try: + parsed = urllib.parse.urlparse(url) + except ValueError: + return "" + if parsed.scheme not in {"http", "https"}: + return "" + if not parsed.netloc: + return "" + # Strip a single trailing slash so callers can append paths without + # producing ``//`` double-slashes. + return url.rstrip("/") + + +def _load_dashboard_section() -> dict: + """Return the ``dashboard`` 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), and (b) ``dashboard`` being absent or non-dict. + Both shapes fall through to ``{}`` so the caller can rely on + ``.get(...)`` access. + """ + try: + from hermes_cli.config import load_config + except Exception: + return {} + try: + cfg = load_config() + except Exception as exc: # noqa: BLE001 — broad catch is intentional + _log.debug( + "dashboard-auth.prefix: load_config() raised %s; " + "falling back to env-only configuration", + exc, + ) + return {} + section = cfg.get("dashboard") if isinstance(cfg, dict) else None + return section if isinstance(section, dict) else {} + + +def resolve_public_url() -> str: + """Resolve the operator-declared dashboard public URL. + + Precedence (mirrors ``dashboard.oauth.client_id``): + + 1. ``HERMES_DASHBOARD_PUBLIC_URL`` 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.public_url`` in ``config.yaml``. + 3. Empty string — signals "no override, reconstruct from request" + to the caller. + + Each candidate value is run through :func:`_normalise_public_url`. + A malformed env var falls through to the config.yaml entry; a + malformed config entry falls through to ``""``. This means a typo + in one surface doesn't prevent the other from working. + """ + env_raw = os.environ.get("HERMES_DASHBOARD_PUBLIC_URL", "") + env_clean = _normalise_public_url(env_raw) + if env_clean: + return env_clean + cfg_raw = _load_dashboard_section().get("public_url", "") + return _normalise_public_url(str(cfg_raw)) diff --git a/hermes_cli/dashboard_auth/registry.py b/hermes_cli/dashboard_auth/registry.py new file mode 100644 index 0000000000000..fde1420e2044c --- /dev/null +++ b/hermes_cli/dashboard_auth/registry.py @@ -0,0 +1,58 @@ +"""Module-level registry for DashboardAuthProvider instances. + +Plugins call ``register_provider`` via the plugin context hook at startup. +The auth gate middleware iterates ``list_providers()`` and uses +``get_provider`` to dispatch on the session's ``provider`` field. +""" +from __future__ import annotations + +import logging +import threading +from typing import List, Optional + +from hermes_cli.dashboard_auth.base import ( + DashboardAuthProvider, + assert_protocol_compliance, +) + +_log = logging.getLogger(__name__) +_lock = threading.Lock() +_providers: dict[str, DashboardAuthProvider] = {} + + +def register_provider(provider: DashboardAuthProvider) -> None: + """Register a provider. + + Raises: + TypeError: on protocol violation. + ValueError: if a provider with the same name is already registered. + """ + assert_protocol_compliance(type(provider)) + with _lock: + if provider.name in _providers: + raise ValueError( + f"dashboard-auth provider already registered: {provider.name!r}" + ) + _providers[provider.name] = provider + _log.info( + "dashboard-auth: registered provider %r (%s)", + provider.name, provider.display_name, + ) + + +def get_provider(name: str) -> Optional[DashboardAuthProvider]: + """Return the registered provider for ``name``, or None if unknown.""" + with _lock: + return _providers.get(name) + + +def list_providers() -> List[DashboardAuthProvider]: + """All registered providers, in registration order.""" + with _lock: + return list(_providers.values()) + + +def clear_providers() -> None: + """Test-only: drop all registrations.""" + with _lock: + _providers.clear() diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py new file mode 100644 index 0000000000000..50d4645991b58 --- /dev/null +++ b/hermes_cli/dashboard_auth/routes.py @@ -0,0 +1,456 @@ +"""HTTP routes for the dashboard-auth OAuth round trip. + +Mounted at root (no prefix) by ``web_server.py``. The router does not +auto-gate; gating is performed by ``gated_auth_middleware``, which +allowlists everything under ``/auth/*`` and ``/api/auth/providers``. + +The routes: + + GET /login → server-rendered login page + GET /auth/login?provider=N → 302 to IDP, sets PKCE cookie + GET /auth/callback?code,state → completes login, sets session cookies + POST /auth/logout → clears cookies, best-effort revoke + GET /api/auth/providers → list registered providers (login bootstrap) + GET /api/auth/me → current Session as JSON (auth-required) +""" +from __future__ import annotations + +import logging +import time +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse + +from hermes_cli.dashboard_auth import ( + get_provider, + list_providers, +) +from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log +from hermes_cli.dashboard_auth.base import ( + InvalidCodeError, + ProviderError, +) +from hermes_cli.dashboard_auth.cookies import ( + clear_pkce_cookie, + clear_session_cookies, + detect_https, + read_pkce_cookie, + read_session_cookies, + set_pkce_cookie, + set_session_cookies, +) +from hermes_cli.dashboard_auth.login_page import render_login_html + +_log = logging.getLogger(__name__) + +router = APIRouter() + + +def _redirect_uri(request: Request) -> str: + """Reconstruct the absolute callback URL the IDP redirects back to. + + Three resolution tiers: + + 1. ``HERMES_DASHBOARD_PUBLIC_URL`` env var or + ``dashboard.public_url`` in config.yaml — when set, this is + the complete authority (scheme + host + optional path prefix) + and we append ``/auth/callback`` verbatim. ``X-Forwarded-Prefix`` + is IGNORED on this code path because the operator has declared + the public URL — we no longer need to guess from proxy headers, + and stacking the prefix on top would double-prefix the common + case where the prefix is already baked into ``public_url``. + Relief valve for deploys behind reverse proxies whose forwarded + headers aren't reliable. + + 2. ``X-Forwarded-Prefix: /hermes`` (Mission Control deploys) — we + prepend the prefix to the path FastAPI's ``url_for`` produces + (it doesn't natively honour this header — it isn't part of the + Starlette/uvicorn proxy_headers set). + + 3. Bare ``request.url_for("auth_callback")`` — under uvicorn's + ``proxy_headers=True`` this picks up the public https URL from + ``X-Forwarded-Host`` plus ``X-Forwarded-Proto``. Fly.io's + default path. + """ + from urllib.parse import urlparse, urlunparse + + from hermes_cli.dashboard_auth.prefix import ( + prefix_from_request, + resolve_public_url, + ) + + # Tier 1: operator-declared public URL. + public_url = resolve_public_url() + if public_url: + # ``public_url`` is the complete authority (possibly with a + # path prefix already baked in). Append the auth callback path + # verbatim. ``resolve_public_url`` already stripped any trailing + # slash so we don't produce ``//auth/callback`` double-slashes. + return f"{public_url}/auth/callback" + + # Tier 2 + 3: reconstruct from the request URL, optionally with + # X-Forwarded-Prefix layered on top of the path. + base = str(request.url_for("auth_callback")) + prefix = prefix_from_request(request) + if not prefix: + return base + parsed = urlparse(base) + return urlunparse(parsed._replace(path=f"{prefix}{parsed.path}")) + + +def _client_ip(request: Request) -> str: + fwd = request.headers.get("x-forwarded-for", "") + if fwd: + return fwd.split(",")[0].strip() + return request.client.host if request.client else "" + + +def _prefix(request: Request) -> str: + """Resolve the X-Forwarded-Prefix header for the active request. + + Local indirection so the routes pass a consistent value to the + cookie helpers (cookie name + Path attribute) and the gate's + redirect builders (login_url construction). See + ``hermes_cli.dashboard_auth.prefix`` for the normalisation rules. + """ + from hermes_cli.dashboard_auth.prefix import prefix_from_request + return prefix_from_request(request) + + +# --------------------------------------------------------------------------- +# Public: login page (server-rendered HTML, no SPA bundle) +# --------------------------------------------------------------------------- + + +@router.get("/login", name="login_page") +async def login_page(request: Request) -> HTMLResponse: + # Read the ``next=`` query the gate's ``_unauth_response`` set on + # the redirect URL. Validate against the same same-origin rules the + # callback applies (defence in depth — the gate already filters, + # but /login is reachable directly too). + next_path = _validate_post_login_target( + request.query_params.get("next", "") + ) + return HTMLResponse( + render_login_html(next_path=next_path), + headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, + ) + + +# --------------------------------------------------------------------------- +# Public: provider list for the login-page bootstrap +# --------------------------------------------------------------------------- + + +@router.get("/api/auth/providers", name="auth_providers") +async def api_auth_providers() -> Any: + providers = list_providers() + if not providers: + # Q13: fail-closed when zero providers are registered. + return JSONResponse( + {"detail": "no auth providers registered"}, + status_code=503, + ) + return { + "providers": [ + {"name": p.name, "display_name": p.display_name} + for p in providers + ], + } + + +# --------------------------------------------------------------------------- +# Public: OAuth round trip +# --------------------------------------------------------------------------- + + +@router.get("/auth/login", name="auth_login") +async def auth_login(request: Request, provider: str, next: str = ""): + p = get_provider(provider) + if p is None: + raise HTTPException( + status_code=404, + detail=f"Unknown provider: {provider!r}", + ) + + try: + ls = p.start_login(redirect_uri=_redirect_uri(request)) + except ProviderError as e: + audit_log( + AuditEvent.LOGIN_FAILURE, + provider=provider, + reason="provider_unreachable", + ip=_client_ip(request), + ) + raise HTTPException( + status_code=503, + detail=f"Provider unreachable: {e}", + ) + + audit_log( + AuditEvent.LOGIN_START, + provider=provider, + ip=_client_ip(request), + ) + + resp = RedirectResponse(url=ls.redirect_url, status_code=302) + # Pack the provider name into the PKCE cookie so the callback can + # find it without a separate cookie. Provider may or may not have + # already included a ``provider=`` segment. + pkce = ls.cookie_payload.get("hermes_session_pkce", "") + if "provider=" not in pkce: + pkce = f"provider={provider};{pkce}" if pkce else f"provider={provider}" + # Carry ``next=`` through the round trip in the PKCE cookie. Real + # IDPs only echo back ``code`` + ``state`` on the callback URL, so + # query-string transport would lose the value — the cookie is the + # only server-controlled channel that survives. Validate before we + # store it so an attacker who reaches /auth/login directly with + # ``next=//evil.example`` can't poison the cookie. + safe_next = _validate_post_login_target(next) + if safe_next: + from urllib.parse import quote + pkce = f"{pkce};next={quote(safe_next, safe='')}" + set_pkce_cookie( + resp, payload=pkce, use_https=detect_https(request), + prefix=_prefix(request), + ) + return resp + + +@router.get("/auth/callback", name="auth_callback") +async def auth_callback( + request: Request, + code: str = "", + state: str = "", + error: str = "", + error_description: str = "", +): + pkce_raw = read_pkce_cookie(request) + if not pkce_raw: + audit_log( + AuditEvent.LOGIN_FAILURE, + reason="missing_pkce_cookie", + ip=_client_ip(request), + ) + raise HTTPException( + status_code=400, + detail="Missing PKCE state cookie", + ) + + # Parse ``provider=...;state=...;verifier=...;next=...`` — the + # ``next`` segment is optional (only present when /auth/login was + # given a next= query). All keys live in the same flat namespace; + # ``next`` carries a URL-encoded path so it never contains ``;``. + parts = dict( + seg.split("=", 1) for seg in pkce_raw.split(";") if "=" in seg + ) + provider_name = parts.get("provider", "") + expected_state = parts.get("state", "") + verifier = parts.get("verifier", "") + # Read next= from the cookie ONLY. The IDP doesn't echo next= back + # on the callback URL (it only carries ``code`` + ``state``), so any + # next= query parameter on the callback URL is attacker-controlled + # and MUST be ignored. + next_from_cookie = parts.get("next", "") + + p = get_provider(provider_name) + if p is None: + raise HTTPException( + status_code=400, + detail=f"Unknown provider in cookie: {provider_name!r}", + ) + + if error: + audit_log( + AuditEvent.LOGIN_FAILURE, + provider=provider_name, + reason="idp_error", + error=error, + ip=_client_ip(request), + ) + raise HTTPException( + status_code=400, + detail=f"OAuth error from provider: {error} ({error_description})", + ) + + if not state or state != expected_state: + audit_log( + AuditEvent.LOGIN_FAILURE, + provider=provider_name, + reason="state_mismatch", + ip=_client_ip(request), + ) + raise HTTPException( + status_code=400, + detail="OAuth state mismatch (CSRF check failed)", + ) + + try: + session = p.complete_login( + code=code, + state=state, + code_verifier=verifier, + redirect_uri=_redirect_uri(request), + ) + except InvalidCodeError as e: + audit_log( + AuditEvent.LOGIN_FAILURE, + provider=provider_name, + reason="invalid_code", + ip=_client_ip(request), + ) + raise HTTPException(status_code=400, detail=f"Invalid code: {e}") + except ProviderError as e: + audit_log( + AuditEvent.LOGIN_FAILURE, + provider=provider_name, + reason="provider_unreachable", + ip=_client_ip(request), + ) + raise HTTPException( + status_code=503, + detail=f"Provider unreachable: {e}", + ) + + audit_log( + AuditEvent.LOGIN_SUCCESS, + provider=provider_name, + user_id=session.user_id, + email=session.email, + org_id=session.org_id, + ip=_client_ip(request), + ) + + expires_in = max(60, session.expires_at - int(time.time())) + # Honour the ``next=`` value the gate's _unauth_response set in the + # /login redirect URL and that /auth/login persisted into the PKCE + # cookie. We re-validate against the same-origin rules here — the + # cookie is server-set so this is defence in depth, but a regression + # that lets attacker-controlled bytes into the cookie would otherwise + # produce an open redirect. + landing = _validate_post_login_target(next_from_cookie) or "/" + resp = RedirectResponse(url=landing, status_code=302) + set_session_cookies( + resp, + access_token=session.access_token, + refresh_token=session.refresh_token, + access_token_expires_in=expires_in, + use_https=detect_https(request), + prefix=_prefix(request), + ) + clear_pkce_cookie(resp, prefix=_prefix(request)) + return resp + + +def _validate_post_login_target(raw: str) -> str: + """Return ``raw`` if it's a safe same-origin path, else empty string. + + The ``next`` query param survives a full OAuth round trip — the gate + encodes it into the /login redirect, the login page emits it back into + /auth/login, and the IDP preserves it across /authorize/callback. We + have to re-validate here because the value came back in via the + URL (an attacker could craft a /auth/callback URL with their own + ``next=https://evil.example``). + """ + if not raw: + return "" + from urllib.parse import unquote + decoded = unquote(raw) + if not decoded.startswith("/") or decoded.startswith("//"): + return "" + # Don't loop back to login pages or auth flow. + if any( + decoded == p or decoded.startswith(p) + for p in ("/login", "/auth/", "/api/auth/") + ): + return "" + return decoded + + +@router.post("/auth/logout", name="auth_logout") +async def auth_logout(request: Request): + _at, rt = read_session_cookies(request) + if rt: + # Best-effort revoke. Try every provider so a session minted by + # any registered provider is revoked correctly. Failures are + # logged but never raised. + for provider in list_providers(): + try: + provider.revoke_session(refresh_token=rt) + except Exception as e: # noqa: BLE001 — best-effort + _log.warning( + "dashboard-auth: revoke on %r failed: %s", + provider.name, e, + ) + + sess = getattr(request.state, "session", None) + audit_log( + AuditEvent.LOGOUT, + provider=(sess.provider if sess else "unknown"), + user_id=(sess.user_id if sess else ""), + ip=_client_ip(request), + ) + + prefix = _prefix(request) + resp = RedirectResponse(url=f"{prefix}/login", status_code=302) + clear_session_cookies(resp, prefix=prefix) + clear_pkce_cookie(resp, prefix=prefix) + return resp + + +# --------------------------------------------------------------------------- +# Auth-required: identity probe for the SPA +# --------------------------------------------------------------------------- + + +@router.get("/api/auth/me", name="auth_me") +async def api_auth_me(request: Request): + """Return the verified session as JSON. Auth-required (gate enforces).""" + sess = getattr(request.state, "session", None) + if sess is None: + raise HTTPException(status_code=401, detail="Unauthorized") + return { + "user_id": sess.user_id, + "email": sess.email, + "display_name": sess.display_name, + "org_id": sess.org_id, + "provider": sess.provider, + "expires_at": sess.expires_at, + } + + +# --------------------------------------------------------------------------- +# Auth-required: WS upgrade ticket (Phase 5) +# --------------------------------------------------------------------------- + + +@router.post("/api/auth/ws-ticket", name="auth_ws_ticket") +async def api_auth_ws_ticket(request: Request): + """Mint a short-lived single-use ticket for the authenticated session. + + Browsers cannot set ``Authorization`` on a WebSocket upgrade, so in + gated mode the SPA POSTs this endpoint to get a ``?ticket=`` value to + append to ``/api/pty``, ``/api/ws``, ``/api/pub``, or ``/api/events``. + + The ticket has a 30-second TTL and is single-use. Calling this endpoint + multiple times in quick succession (e.g. one ticket per WS) is the + expected pattern. + """ + 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") + + # Import here so the routes module stays usable in test contexts that + # don't load the ticket store. + from hermes_cli.dashboard_auth.ws_tickets import TTL_SECONDS, mint_ticket + + 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": TTL_SECONDS} diff --git a/hermes_cli/dashboard_auth/ws_tickets.py b/hermes_cli/dashboard_auth/ws_tickets.py new file mode 100644 index 0000000000000..6ebad217e46d8 --- /dev/null +++ b/hermes_cli/dashboard_auth/ws_tickets.py @@ -0,0 +1,87 @@ +"""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 is injected into the SPA bundle. In gated mode there is no injected +token — the SPA gets a fresh ticket via the authenticated REST endpoint +``POST /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. The module +exposes a small functional API rather than a class so tests can patch +``time.time`` cleanly. +""" + +from __future__ import annotations + +import secrets +import threading +import time +from typing import Any, Dict, Tuple + +#: Time-to-live for newly-minted tickets in seconds. 30 s is long enough +#: that the SPA can call ``getWsTicket()`` and immediately open the WS, +#: short enough that a leaked ticket is uninteresting. +TTL_SECONDS = 30 + +_lock = threading.Lock() +_tickets: Dict[str, Tuple[int, Dict[str, Any]]] = {} # 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. + + The returned token is base64url, 43 bytes of entropy (32-byte random + seed). Stash returns the ``info`` dict to the caller on consume so the + WS handler can carry the identity forward into its session log. + """ + 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[str, Any]: + """Validate and consume. Raises :class:`TicketInvalid` on missing/expired/used. + + Single-use semantics: a successful consume immediately removes the + ticket from the store, so a second call with the same value raises + ``TicketInvalid("unknown ticket: …")``. + """ + now = int(time.time()) + with _lock: + entry = _tickets.pop(ticket, None) + if entry is None: + # Truncate ticket value in the error so misuse never logs the + # secret in full. + truncated = (ticket[:8] + "…") if ticket else "" + raise TicketInvalid(f"unknown ticket: {truncated}") + expires_at, info = entry + if expires_at < now: + raise TicketInvalid("expired") + return info + + +def _gc_expired_locked() -> None: + """Drop expired tickets. Caller must hold ``_lock``.""" + now = int(time.time()) + expired = [t for t, (exp, _) in _tickets.items() if exp < now] + for t in expired: + _tickets.pop(t, None) + + +def _reset_for_tests() -> None: + """Test-only: drop all tickets.""" + with _lock: + _tickets.clear() diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index dbc486e87b104..b99eea4d5671f 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -25,7 +25,6 @@ from hermes_cli.colors import Colors, color from hermes_cli.models import _HERMES_USER_AGENT -from hermes_cli.vercel_auth import describe_vercel_auth from hermes_constants import OPENROUTER_MODELS_URL from utils import base_url_host_matches @@ -49,7 +48,6 @@ "DEEPSEEK_API_KEY", "DASHSCOPE_API_KEY", "HF_TOKEN", - "AI_GATEWAY_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY", "XIAOMI_API_KEY", @@ -324,7 +322,6 @@ def _build_apikey_providers_list() -> list: ("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True), # MiniMax CN: /v1 endpoint does NOT support /models (returns 404). ("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False), - ("Vercel AI Gateway", ("AI_GATEWAY_API_KEY",), "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True), ("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True), ("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True), # OpenCode Go has no shared /models endpoint; skip the health check. @@ -340,7 +337,7 @@ def _build_apikey_providers_list() -> list: "Arcee AI": "arcee", "GMI Cloud": "gmi", "DeepSeek": "deepseek", "Hugging Face": "huggingface", "NVIDIA NIM": "nvidia", "Alibaba/DashScope": "alibaba", "MiniMax": "minimax", - "MiniMax (China)": "minimax-cn", "Vercel AI Gateway": "ai-gateway", + "MiniMax (China)": "minimax-cn", "Kilo Code": "kilocode", "OpenCode Zen": "opencode-zen", "OpenCode Go": "opencode-go", } @@ -690,7 +687,6 @@ def run_doctor(args): "openrouter", "custom", "auto", - "ai-gateway", "kilocode", "opencode-zen", "huggingface", @@ -1262,68 +1258,6 @@ def run_doctor(args): issues, ) - # Vercel Sandbox (if using vercel_sandbox backend) - if terminal_env == "vercel_sandbox": - runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24" - from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES - if runtime in _SUPPORTED_VERCEL_RUNTIMES: - check_ok("Vercel runtime", f"({runtime})") - else: - supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES) - _fail_and_issue( - "Vercel runtime unsupported", - f"({runtime}; use {supported})", - f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}", - issues, - ) - - disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() - if disk in {"", "0", "51200"}: - check_ok("Vercel disk setting", "(uses platform default)") - else: - _fail_and_issue( - "Vercel custom disk unsupported", - "(reset terminal.container_disk to 51200)", - "Vercel Sandbox does not support custom container_disk; use the shared default 51200", - issues, - ) - - if importlib.util.find_spec("vercel") is not None: - check_ok("vercel SDK", "(installed)") - else: - _fail_and_issue( - "vercel SDK not installed", - "(pip install 'hermes-agent[vercel]')", - "Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'", - issues, - ) - - auth_status = describe_vercel_auth() - if auth_status.ok: - check_ok("Vercel auth", f"({auth_status.label})") - elif auth_status.label.startswith("partial"): - _fail_and_issue( - "Vercel auth incomplete", - f"({auth_status.label})", - "Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together", - issues, - ) - else: - _fail_and_issue( - "Vercel auth not configured", - f"({auth_status.label})", - "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", - issues, - ) - for line in auth_status.detail_lines: - check_info(f"Vercel auth {line}") - - persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"} - if persistent: - check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation") - else: - check_info("Vercel persistence: ephemeral filesystem") - # Node.js + agent-browser (for browser automation tools) if _safe_which("node"): check_ok("Node.js") diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index b025ab2788330..618cf8bc5de28 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -280,7 +280,6 @@ def run_dump(args): ("DASHSCOPE_API_KEY", "dashscope"), ("HF_TOKEN", "huggingface"), ("NVIDIA_API_KEY", "nvidia"), - ("AI_GATEWAY_API_KEY", "ai_gateway"), ("OPENCODE_ZEN_API_KEY", "opencode_zen"), ("OPENCODE_GO_API_KEY", "opencode_go"), ("KILOCODE_API_KEY", "kilocode"), diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 478f82c38eb0e..c89e697c98d29 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -867,8 +867,6 @@ class Event: session_id TEXT ); -CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id); - CREATE TABLE IF NOT EXISTS task_links ( parent_id TEXT NOT NULL, child_id TEXT NOT NULL, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a4578b16d1ad9..8bda836623d5a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2374,8 +2374,6 @@ def _active_custom_key_from_base_url() -> str: # Step 2: Provider-specific setup + model selection if selected_provider == "openrouter": _model_flow_openrouter(config, current_model) - elif selected_provider == "ai-gateway": - _model_flow_ai_gateway(config, current_model) elif selected_provider == "nous": _model_flow_nous(config, current_model, args=args) elif selected_provider == "openai-codex": @@ -2962,59 +2960,6 @@ def _model_flow_openrouter(config, current_model=""): print("No change.") -def _model_flow_ai_gateway(config, current_model=""): - """Vercel AI Gateway provider: ensure API key, then pick model with pricing.""" - from hermes_constants import AI_GATEWAY_BASE_URL - from hermes_cli.auth import ( - PROVIDER_REGISTRY, - _prompt_model_selection, - _save_model_choice, - deactivate_provider, - ) - from hermes_cli.config import get_env_value - - # Route through _prompt_api_key so users can replace a stale/broken key - # in-flow (K/R/C) instead of having to edit ~/.hermes/.env by hand. - pconfig = PROVIDER_REGISTRY["ai-gateway"] - existing_key = get_env_value("AI_GATEWAY_API_KEY") or "" - if not existing_key: - print( - "Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway" - ) - print("Add a payment method to get $5 in free credits.") - print() - _resolved, abort = _prompt_api_key(pconfig, existing_key, provider_id="ai-gateway") - if abort: - return - - from hermes_cli.models import ai_gateway_model_ids, get_pricing_for_provider - - models_list = ai_gateway_model_ids(force_refresh=True) - pricing = get_pricing_for_provider("ai-gateway", force_refresh=True) - - selected = _prompt_model_selection( - models_list, current_model=current_model, pricing=pricing - ) - if selected: - _save_model_choice(selected) - - from hermes_cli.config import load_config, save_config - - cfg = load_config() - model = cfg.get("model") - if not isinstance(model, dict): - model = {"default": model} if model else {} - cfg["model"] = model - model["provider"] = "ai-gateway" - model["base_url"] = AI_GATEWAY_BASE_URL - model["api_mode"] = "chat_completions" - save_config(cfg) - deactivate_provider() - print(f"Default model set to: {selected} (via Vercel AI Gateway)") - else: - print("No change.") - - def _model_flow_nous(config, current_model="", args=None): """Nous Portal provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( @@ -6988,7 +6933,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" ) @@ -8395,13 +8358,36 @@ def _finalize_update_output(state): pass -def _cmd_update_check(): - """Implement ``hermes update --check``: fetch and report without installing.""" +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.") @@ -8422,16 +8408,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"], @@ -8440,10 +8444,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() @@ -8457,6 +8458,20 @@ def _cmd_update_check(): 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, @@ -8675,7 +8690,13 @@ 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 = _resolve_update_branch(args) + _cmd_update_check( + branch=branch, + branch_explicit=bool(getattr(args, "branch", None)), + ) return gateway_mode = getattr(args, "gateway", False) @@ -8835,26 +8856,57 @@ def _cmd_update_impl(args, gateway_mode: bool): ) current_branch = result.stdout.strip() - # Always update against main - branch = "main" - - # If user is on a non-main branch or detached HEAD, switch to main - if current_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 = _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 + # "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) @@ -8885,7 +8937,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, @@ -8947,7 +8999,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) @@ -10683,6 +10735,22 @@ def cmd_dashboard(args): sys.exit(1) print(f"→ Skipping web UI build (--skip-build); using dist at {_dist_root}") + # Discover and load plugins so any DashboardAuthProvider plugin + # (e.g. plugins/dashboard_auth/nous) registers BEFORE start_server's + # fail-closed gate check runs. The top-level argparse setup skips + # plugin discovery for built-in subcommands like ``dashboard`` to + # save ~500ms startup; we have to trigger it explicitly here because + # the dashboard's server-side runtime depends on plugin-registered + # providers (image_gen, web, dashboard_auth, …). + try: + from hermes_cli.plugins import discover_plugins + discover_plugins() + except Exception as exc: + # Discovery failures must not block dashboard startup outright — + # log and proceed; the gate's fail-closed branch will surface + # the missing-provider state if it matters. + print(f"⚠ Plugin discovery failed: {exc}", file=sys.stderr) + from hermes_cli.web_server import start_server embedded_chat = args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1" @@ -13018,6 +13086,24 @@ def cmd_computer_use(args): ) mcp_login_p.add_argument("name", help="Server name to re-authenticate") + # ── Catalog (Nous-approved MCPs shipped with the repo) ───────────────── + mcp_sub.add_parser( + "picker", + help="Interactive catalog picker (also the default for `hermes mcp`)", + ) + mcp_sub.add_parser( + "catalog", + help="List Nous-approved MCPs available for one-click install", + ) + mcp_install_p = mcp_sub.add_parser( + "install", + help="Install a catalog MCP by name (e.g. `hermes mcp install n8n`)", + ) + mcp_install_p.add_argument( + "identifier", + help="Catalog entry name (or `official/`)", + ) + _add_accept_hooks_flag(mcp_parser) def cmd_mcp(args): @@ -13431,6 +13517,17 @@ def cmd_claw(args): 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/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py new file mode 100644 index 0000000000000..18214767590b5 --- /dev/null +++ b/hermes_cli/mcp_catalog.py @@ -0,0 +1,776 @@ +"""MCP catalog — curated, Nous-approved MCP servers shipped with the repo. + +Mirrors the optional-skills/ pattern: each catalog entry lives under +``optional-mcps//manifest.yaml`` and ships disabled. Users discover +entries via ``hermes mcp catalog`` or the interactive ``hermes mcp picker``, +and install them with ``hermes mcp install `` (or by toggling in the +picker, which flows them through any required env/OAuth setup). + +Catalog policy: +- Entries are added only by merging a PR into hermes-agent. Presence in the + ``optional-mcps/`` directory = Nous approval. No community tier, no trust + signals beyond "it's in the catalog". +- Manifests pin transport details (commands, args, refs). MCPs are never + auto-updated; users explicitly re-run ``hermes mcp install `` to + pull a new manifest version after a repo update. +- Secrets prompted at install time go to ``~/.hermes/.env`` (the + .env-is-for-secrets rule). Non-secret env vars also go to .env to keep + one credential store. + +See website/docs/user-guide/mcp-catalog.md for user docs. +See references/mcp-catalog.md (this repo's skill) for the manifest schema. +""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +from hermes_constants import get_hermes_home, get_optional_mcps_dir +from hermes_cli.colors import Colors, color +from hermes_cli.config import ( + load_config, + save_config, + get_env_value, + save_env_value, +) +from hermes_cli.cli_output import prompt as _prompt_input, prompt_yes_no + +_MANIFEST_VERSION = 1 + +# Substituted at install time inside `transport.command` / `transport.args`. +_INSTALL_DIR_VAR = "${INSTALL_DIR}" + + +# ─── Data classes ──────────────────────────────────────────────────────────── + + +@dataclass +class EnvVarSpec: + name: str + prompt: str + required: bool = True + secret: bool = True + default: str = "" + + +@dataclass +class AuthSpec: + type: str # "api_key" | "oauth" | "none" + env: List[EnvVarSpec] = field(default_factory=list) + # OAuth-specific (case 2: third-party provider like Google) + provider: Optional[str] = None + scopes: List[str] = field(default_factory=list) + env_var: Optional[str] = None + + +@dataclass +class TransportSpec: + type: str # "stdio" | "http" + command: Optional[str] = None + args: List[str] = field(default_factory=list) + url: Optional[str] = None + version: Optional[str] = None # informational, pinned + + +@dataclass +class InstallSpec: + """Optional bootstrap step (git clone + dep install). + + Omit for one-shot launchable servers (npx, uvx). + """ + type: str # "git" + url: str + ref: str # commit/tag/branch — pinned, never floats + bootstrap: List[str] = field(default_factory=list) + + +@dataclass +class ToolsSpec: + """Manifest-side tool-selection hints. + + Drives the pre-checked state of the install-time tool checklist, and acts + as the fallback selection when probe fails. See install_entry() flow. + """ + + # If declared, these tool names are pre-checked in the checklist (or + # applied directly when probe fails). If None, all probed tools are + # pre-checked (or no filter is written when probe fails). + default_enabled: Optional[List[str]] = None + + +@dataclass +class CatalogEntry: + name: str + description: str + source: str + transport: TransportSpec + auth: AuthSpec + tools: ToolsSpec = field(default_factory=ToolsSpec) + install: Optional[InstallSpec] = None + post_install: str = "" + manifest_path: Path = field(default_factory=Path) + + +# ─── Manifest loader ───────────────────────────────────────────────────────── + + +class CatalogError(Exception): + """Manifest parse/validation failure or install error.""" + + +def _catalog_root() -> Path: + """Return the optional-mcps/ directory shipped with this Hermes install.""" + # Prefer the env-var override / packaged location; fall back to the repo's + # optional-mcps/ next to the package (source checkout). + return get_optional_mcps_dir(Path(__file__).parent.parent / "optional-mcps") + + +def _parse_env_spec(raw: Any) -> EnvVarSpec: + if not isinstance(raw, dict): + raise CatalogError(f"env entry must be a mapping, got {type(raw).__name__}") + name = raw.get("name") or "" + if not name or not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name): + raise CatalogError(f"invalid env var name: {name!r}") + return EnvVarSpec( + name=name, + prompt=raw.get("prompt") or name, + required=bool(raw.get("required", True)), + secret=bool(raw.get("secret", True)), + default=str(raw.get("default") or ""), + ) + + +def _parse_manifest(path: Path) -> CatalogEntry: + """Read and validate a manifest.yaml. Raise CatalogError on any problem.""" + try: + with open(path, "r", encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + except Exception as exc: + raise CatalogError(f"failed to read {path}: {exc}") from exc + + if not isinstance(data, dict): + raise CatalogError(f"{path}: manifest must be a mapping") + + mv = data.get("manifest_version") + if mv != _MANIFEST_VERSION: + raise CatalogError( + f"{path}: manifest_version {mv!r} unsupported " + f"(this Hermes understands version {_MANIFEST_VERSION})" + ) + + name = data.get("name") or "" + if not name or not re.match(r"^[A-Za-z0-9_-]+$", name): + raise CatalogError(f"{path}: invalid or missing 'name'") + + description = str(data.get("description") or "").strip() + if not description: + raise CatalogError(f"{path}: 'description' required") + + source = str(data.get("source") or "").strip() + + transport_raw = data.get("transport") or {} + if not isinstance(transport_raw, dict): + raise CatalogError(f"{path}: 'transport' must be a mapping") + t_type = transport_raw.get("type") + if t_type not in ("stdio", "http"): + raise CatalogError(f"{path}: transport.type must be 'stdio' or 'http'") + args = transport_raw.get("args") or [] + if not isinstance(args, list): + raise CatalogError(f"{path}: transport.args must be a list") + transport = TransportSpec( + type=t_type, + command=transport_raw.get("command"), + args=[str(a) for a in args], + url=transport_raw.get("url"), + version=transport_raw.get("version"), + ) + if t_type == "stdio" and not transport.command: + raise CatalogError(f"{path}: stdio transport requires 'command'") + if t_type == "http" and not transport.url: + raise CatalogError(f"{path}: http transport requires 'url'") + + auth_raw = data.get("auth") or {"type": "none"} + if not isinstance(auth_raw, dict): + raise CatalogError(f"{path}: 'auth' must be a mapping") + a_type = auth_raw.get("type") or "none" + if a_type not in ("api_key", "oauth", "none"): + raise CatalogError(f"{path}: auth.type must be 'api_key'|'oauth'|'none'") + env_list_raw = auth_raw.get("env") or [] + if not isinstance(env_list_raw, list): + raise CatalogError(f"{path}: auth.env must be a list") + env_list = [_parse_env_spec(e) for e in env_list_raw] + auth = AuthSpec( + type=a_type, + env=env_list, + provider=auth_raw.get("provider"), + scopes=list(auth_raw.get("scopes") or []), + env_var=auth_raw.get("env_var"), + ) + + tools_raw = data.get("tools") or {} + if not isinstance(tools_raw, dict): + raise CatalogError(f"{path}: 'tools' must be a mapping") + default_enabled = tools_raw.get("default_enabled") + if default_enabled is not None: + if not isinstance(default_enabled, list) or not all( + isinstance(t, str) for t in default_enabled + ): + raise CatalogError( + f"{path}: tools.default_enabled must be a list of strings" + ) + tools_spec = ToolsSpec(default_enabled=default_enabled) + + install: Optional[InstallSpec] = None + install_raw = data.get("install") + if install_raw is not None: + if not isinstance(install_raw, dict): + raise CatalogError(f"{path}: 'install' must be a mapping") + i_type = install_raw.get("type") + if i_type != "git": + raise CatalogError(f"{path}: install.type must be 'git' (got {i_type!r})") + url = install_raw.get("url") or "" + ref = install_raw.get("ref") or "" + if not url or not ref: + raise CatalogError(f"{path}: install.url and install.ref are required") + bootstrap = install_raw.get("bootstrap") or [] + if not isinstance(bootstrap, list): + raise CatalogError(f"{path}: install.bootstrap must be a list") + install = InstallSpec( + type=i_type, + url=url, + ref=ref, + bootstrap=[str(c) for c in bootstrap], + ) + + return CatalogEntry( + name=name, + description=description, + source=source, + transport=transport, + auth=auth, + tools=tools_spec, + install=install, + post_install=str(data.get("post_install") or ""), + manifest_path=path, + ) + + +def list_catalog() -> List[CatalogEntry]: + """Return all valid catalog entries, sorted by name. + + Invalid manifests are skipped silently (CI tests catch them at PR time). + Manifests with a future ``manifest_version`` are also skipped, but the + skip is surfaced via :func:`catalog_diagnostics` so the picker / catalog + UIs can tell the user their Hermes is out of date. + """ + root = _catalog_root() + if not root.exists(): + return [] + entries: List[CatalogEntry] = [] + _CATALOG_DIAGNOSTICS.clear() + for child in sorted(root.iterdir()): + manifest = child / "manifest.yaml" + if not manifest.is_file(): + continue + try: + entries.append(_parse_manifest(manifest)) + except CatalogError as exc: + msg = str(exc) + # Recognize the future-manifest error specifically so the UI can + # surface a more actionable nudge than "broken manifest". + if "manifest_version" in msg and "unsupported" in msg: + _CATALOG_DIAGNOSTICS.append((child.name, "future_manifest", msg)) + else: + _CATALOG_DIAGNOSTICS.append((child.name, "invalid", msg)) + continue + return entries + + +# Populated by list_catalog(). Inspected by the picker / catalog UIs so the +# user gets actionable feedback instead of a silently-shorter list. +_CATALOG_DIAGNOSTICS: List[tuple] = [] + + +def catalog_diagnostics() -> List[tuple]: + """Diagnostics from the most recent :func:`list_catalog` call. + + Returns a list of ``(entry_name, kind, message)`` tuples where ``kind`` + is one of: + - ``future_manifest`` — manifest_version is newer than this Hermes + understands. Update Hermes to install this entry. + - ``invalid`` — manifest is malformed in some other way (caught by + CI for shipped manifests; user-modified manifests can hit this). + """ + return list(_CATALOG_DIAGNOSTICS) + + +def get_entry(name: str) -> Optional[CatalogEntry]: + """Look up a single entry by name. ``official/`` prefix accepted.""" + if name.startswith("official/"): + name = name[len("official/"):] + for entry in list_catalog(): + if entry.name == name: + return entry + return None + + +# ─── Status helpers ────────────────────────────────────────────────────────── + + +def installed_servers() -> Dict[str, dict]: + """Return current ``mcp_servers`` block from config.yaml.""" + cfg = load_config() + servers = cfg.get("mcp_servers") or {} + return servers if isinstance(servers, dict) else {} + + +def is_installed(name: str) -> bool: + return name in installed_servers() + + +def is_enabled(name: str) -> bool: + servers = installed_servers() + cfg = servers.get(name) + if not cfg: + return False + enabled = cfg.get("enabled", True) + if isinstance(enabled, str): + return enabled.lower() in {"true", "1", "yes"} + return bool(enabled) + + +# ─── Install ───────────────────────────────────────────────────────────────── + + +def _install_root() -> Path: + """Where git-bootstrapped MCPs are cloned. Per-user, profile-aware.""" + root = get_hermes_home() / "mcp-installs" + root.mkdir(parents=True, exist_ok=True) + return root + + +def _run_bootstrap(cwd: Path, commands: List[str]) -> None: + """Execute bootstrap commands in *cwd*. Raise CatalogError on first failure. + + Each command runs through the shell (so `&&` etc. work). The output is + streamed to the user's terminal for visibility. + """ + for cmd in commands: + print(color(f" $ {cmd}", Colors.DIM)) + proc = subprocess.run(cmd, cwd=str(cwd), shell=True) + if proc.returncode != 0: + raise CatalogError( + f"bootstrap step failed (exit {proc.returncode}): {cmd}" + ) + + +def _do_git_install(entry: CatalogEntry) -> Path: + """Clone the entry's repo into ``~/.hermes/mcp-installs/`` and run + bootstrap commands. Returns the install directory.""" + assert entry.install is not None and entry.install.type == "git" + install = entry.install + dest = _install_root() / entry.name + + git = shutil.which("git") + if not git: + raise CatalogError("git is required to install this MCP but was not found on PATH") + + if dest.exists(): + # Fresh checkout each install — manifest version is the source of truth, + # so wipe + re-clone for determinism. + print(color(f" Removing existing install at {dest}", Colors.DIM)) + shutil.rmtree(dest) + + print(color(f" Cloning {install.url} ({install.ref}) → {dest}", Colors.CYAN)) + + # `git clone --branch` only accepts branches and tags, NOT commit SHAs. + # Detecting SHA-shaped refs upfront avoids a guaranteed stderr leak on + # the fast path (the --branch attempt would always fail noisily for a + # SHA ref before we fall back to full-clone-then-checkout). + is_sha_ref = bool(re.fullmatch(r"[0-9a-f]{7,40}", install.ref)) + + if not is_sha_ref: + proc = subprocess.run( + [git, "clone", "--depth", "1", "--branch", install.ref, install.url, str(dest)], + ) + if proc.returncode == 0: + pass + else: + # Branch/tag form failed (unlikely for valid manifests; possible if + # the ref was deleted upstream). Fall through to the full-clone path. + if dest.exists(): + shutil.rmtree(dest) + is_sha_ref = True # treat the same as a SHA ref from here + + if is_sha_ref: + proc = subprocess.run([git, "clone", install.url, str(dest)]) + if proc.returncode != 0: + raise CatalogError(f"git clone failed for {install.url}") + proc = subprocess.run([git, "-C", str(dest), "checkout", install.ref]) + if proc.returncode != 0: + raise CatalogError(f"git checkout {install.ref} failed") + + if install.bootstrap: + _run_bootstrap(dest, install.bootstrap) + + return dest + + +def _expand_install_dir(value: str, install_dir: Optional[Path]) -> str: + if _INSTALL_DIR_VAR not in value: + return value + if install_dir is None: + raise CatalogError( + f"manifest references {_INSTALL_DIR_VAR} but no install block exists" + ) + return value.replace(_INSTALL_DIR_VAR, str(install_dir)) + + +def _prompt_env_vars(specs: List[EnvVarSpec]) -> Dict[str, str]: + """Walk the env spec list, prompting the user for each. Writes secrets and + non-secrets alike to ~/.hermes/.env via save_env_value().""" + collected: Dict[str, str] = {} + for spec in specs: + existing = get_env_value(spec.name) + if existing: + print(color(f" ✓ {spec.name} already set in .env", Colors.GREEN)) + collected[spec.name] = existing + continue + value = _prompt_input( + spec.prompt, + default=spec.default or None, + password=spec.secret, + ) + if not value: + if spec.required: + raise CatalogError(f"{spec.name} is required but no value was provided") + continue + save_env_value(spec.name, value) + collected[spec.name] = value + return collected + + +def _build_server_config( + entry: CatalogEntry, install_dir: Optional[Path] +) -> dict: + """Translate a manifest into the ``mcp_servers.`` block format used + by hermes_cli/mcp_config.py.""" + cfg: dict = {} + t = entry.transport + if t.type == "stdio": + cfg["command"] = _expand_install_dir(t.command or "", install_dir) + if t.args: + cfg["args"] = [_expand_install_dir(a, install_dir) for a in t.args] + elif t.type == "http": + cfg["url"] = t.url + if entry.auth.type == "oauth": + cfg["auth"] = "oauth" + return cfg + + +def _read_prior_tool_selection(name: str) -> Optional[List[str]]: + """Return the user's prior `tools.include` for *name*, if any. + + Used during reinstalls so the install-time checklist starts pre-checked + with whatever the user already had. Tools no longer on the server are + silently dropped at checklist-display time. + """ + servers = installed_servers() + cfg = servers.get(name) or {} + tools_cfg = cfg.get("tools") or {} + if not isinstance(tools_cfg, dict): + return None + include = tools_cfg.get("include") + if isinstance(include, list) and all(isinstance(t, str) for t in include): + return list(include) + return None + + +def _probe_tools(name: str) -> Optional[List[tuple]]: + """Connect to a freshly-configured MCP and list its tools. + + Returns a list of ``(tool_name, description)`` tuples on success, or + ``None`` on any failure (server unreachable, OAuth not yet completed, + backing service offline, etc.). Failures are intentionally swallowed + here — the fallback path in :func:`_apply_tool_selection` handles them. + """ + servers = installed_servers() + server_cfg = servers.get(name) + if not server_cfg: + return None + try: + # Import lazily so the catalog module stays cheap to load. + from hermes_cli.mcp_config import _probe_single_server + + tools = _probe_single_server(name, server_cfg) + return list(tools) if tools is not None else [] + except Exception as exc: + # Display the cause but never raise from the install path. + print(color(f" Probe failed: {exc}", Colors.YELLOW)) + return None + + +def _write_tools_include(name: str, include: Optional[List[str]]) -> None: + """Persist or clear ``mcp_servers..tools.include``.""" + cfg = load_config() + servers = cfg.setdefault("mcp_servers", {}) + server_entry = servers.get(name) or {} + if include is None: + # No filter — drop any existing tools block. + server_entry.pop("tools", None) + else: + tools_block = server_entry.get("tools") or {} + if not isinstance(tools_block, dict): + tools_block = {} + tools_block["include"] = list(include) + tools_block.pop("exclude", None) + server_entry["tools"] = tools_block + servers[name] = server_entry + cfg["mcp_servers"] = servers + save_config(cfg) + + +def _apply_tool_selection( + entry: CatalogEntry, *, prior_selection: Optional[List[str]] +) -> None: + """Probe the server and let the user pick which tools to enable. + + Probe-success path: + - Curses checklist of all probed tools. + - Pre-check uses (in priority order): + 1. *prior_selection* (reinstall: preserve what the user had) + 2. manifest's ``tools.default_enabled`` + 3. all tools (default) + - All-on selection clears any filter (no ``tools.include`` written). + - Sub-selection writes ``tools.include``. + + Probe-fail path: + - If manifest declares ``tools.default_enabled`` → apply directly. + - Otherwise → leave config with no filter (all on when reachable). + - Either way, point the user at ``hermes mcp configure ``. + """ + print() + print(color(f" Probing '{entry.name}' for available tools...", Colors.CYAN)) + probed = _probe_tools(entry.name) + + # Probe failure path + if probed is None: + manifest_default = entry.tools.default_enabled + if manifest_default: + _write_tools_include(entry.name, manifest_default) + print(color( + f" Couldn\'t probe server. Applied manifest default " + f"({len(manifest_default)} tools). " + f"Run `hermes mcp configure {entry.name}` after the server " + "is reachable to refine.", + Colors.YELLOW, + )) + else: + _write_tools_include(entry.name, None) + print(color( + f" Couldn\'t probe server; installed with no tool filter " + "(all tools enabled when reachable). " + f"Run `hermes mcp configure {entry.name}` after first " + "connect to prune.", + Colors.YELLOW, + )) + return + + if not probed: + # Probe succeeded but server reported zero tools. Nothing to filter. + _write_tools_include(entry.name, None) + print(color(" Server reported no tools.", Colors.YELLOW)) + return + + tool_names = [t[0] for t in probed] + + # Build the pre-checked set in priority order + if prior_selection: + pre_set = {n for n in prior_selection if n in tool_names} + elif entry.tools.default_enabled: + pre_set = {n for n in entry.tools.default_enabled if n in tool_names} + else: + pre_set = set(tool_names) + + pre_indices = {i for i, n in enumerate(tool_names) if n in pre_set} + + # Non-TTY: skip the checklist. Priority matches the interactive + # pre-check priority: prior user selection > manifest default > all-on. + import sys as _sys + if not _sys.stdin.isatty(): + if prior_selection is not None: + include = [n for n in prior_selection if n in tool_names] + _write_tools_include(entry.name, include) + elif entry.tools.default_enabled: + include = [n for n in entry.tools.default_enabled if n in tool_names] + _write_tools_include(entry.name, include) + else: + _write_tools_include(entry.name, None) + return + + print(color( + f" Found {len(probed)} tool(s). " + f"Pre-checked: {len(pre_indices)}.", + Colors.GREEN, + )) + + from hermes_cli.curses_ui import curses_checklist + + labels = [ + f"{n} — {(d[:60] + '...') if len(d) > 60 else d}" + for n, d in probed + ] + chosen_indices = curses_checklist( + f"Select tools for '{entry.name}' (SPACE toggle, ENTER confirm)", + labels, + pre_indices, + ) + + if not chosen_indices: + # User unchecked everything; treat as "no tools" — write empty include + # so the server is installed but contributes nothing until reconfigured. + _write_tools_include(entry.name, []) + print(color( + f" No tools selected. Run `hermes mcp configure {entry.name}` " + "to change.", + Colors.YELLOW, + )) + return + + if len(chosen_indices) == len(probed): + # Everything selected — clear filter for the cleanest config shape. + # NOTE: this means any tools the server adds later (e.g. a future MCP + # version) will also be auto-enabled. To pin to the current set, + # the user can re-run `hermes mcp configure ` and unselect a + # tool to switch back to include-mode. + _write_tools_include(entry.name, None) + print(color( + f" ✓ All {len(probed)} tools enabled (no filter — new tools " + "the server adds later will be auto-enabled).", + Colors.GREEN, + )) + return + + chosen_names = [tool_names[i] for i in sorted(chosen_indices)] + _write_tools_include(entry.name, chosen_names) + print(color( + f" ✓ {len(chosen_names)}/{len(probed)} tools enabled.", + Colors.GREEN, + )) + + +def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None: + """Install a catalog entry end-to-end. + + Steps: + 1. If ``install.type == git``, clone + run bootstrap commands. + 2. If ``auth.type == api_key``, prompt for env vars, save to .env. + 3. If ``auth.type == oauth`` (remote MCP / case 1), write the + ``auth: oauth`` marker (MCP client handles browser on first connect + in the non-pre-authenticated case). + 4. Translate the manifest into an ``mcp_servers.`` block and + save into config.yaml. + 5. Probe the server, present a curses checklist for tool selection, + write ``tools.include`` (or no filter, depending on choice). + If probe fails, fall back to the manifest's + ``tools.default_enabled`` or all-on. + 6. Print post_install notes. + """ + print() + print(color(f" Installing MCP '{entry.name}'", Colors.CYAN + Colors.BOLD)) + if entry.description: + print(color(f" {entry.description}", Colors.DIM)) + if entry.source: + print(color(f" Source: {entry.source}", Colors.DIM)) + print() + + install_dir: Optional[Path] = None + if entry.install is not None: + install_dir = _do_git_install(entry) + + # Auth + if entry.auth.type == "api_key": + print() + print(color(" Configure credentials:", Colors.CYAN)) + _prompt_env_vars(entry.auth.env) + elif entry.auth.type == "oauth": + if entry.auth.provider: + # Case 2: provider-mediated (Google, GitHub, etc.). We rely on + # the existing `hermes auth ` flow. Surface guidance + # here rather than auto-running it — keeps the catalog install + # decoupled from provider-auth lifecycle. + print(color( + f" This MCP uses {entry.auth.provider} OAuth. Run " + f"`hermes auth {entry.auth.provider}` if you have not " + "already authenticated.", + Colors.YELLOW, + )) + else: + print(color( + " This MCP uses native OAuth 2.1; tokens will be acquired " + "on first connection (browser flow).", + Colors.DIM, + )) + # auth.type == "none": nothing to do. + + # ── Preserve any prior user tool selection across reinstalls ──────── + # Reading BEFORE we overwrite the entry below so a reinstall pre-checks + # whatever the user picked last time. + prior_selection = _read_prior_tool_selection(entry.name) + + # Build and write the mcp_servers entry (without tools filter yet; + # _apply_tool_selection() finalizes it below). + server_cfg = _build_server_config(entry, install_dir) + server_cfg["enabled"] = enable + + cfg = load_config() + cfg.setdefault("mcp_servers", {})[entry.name] = server_cfg + save_config(cfg) + + # ── Probe + tool selection ────────────────────────────────────────── + _apply_tool_selection(entry, prior_selection=prior_selection) + + print() + print(color( + f" ✓ Installed '{entry.name}' " + f"({'enabled' if enable else 'disabled'}). " + f"Start a new Hermes session to load its tools.", + Colors.GREEN, + )) + if entry.post_install: + print() + for line in entry.post_install.strip().splitlines(): + print(color(f" {line}", Colors.DIM)) + print() + + +def uninstall_entry(name: str, *, purge_install_dir: bool = True) -> bool: + """Remove a catalog-installed MCP from config and (optionally) wipe its + clone directory. Returns True if anything was removed.""" + cfg = load_config() + servers = cfg.get("mcp_servers") or {} + removed = False + if name in servers: + del servers[name] + if not servers: + cfg.pop("mcp_servers", None) + else: + cfg["mcp_servers"] = servers + save_config(cfg) + removed = True + + if purge_install_dir: + clone = _install_root() / name + if clone.exists(): + shutil.rmtree(clone) + removed = True + + return removed diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index ed9d7b5f6dbce..0a1ca33619384 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -749,6 +749,24 @@ def mcp_command(args): run_mcp_server(verbose=getattr(args, "verbose", False)) return + # Catalog subcommands live in mcp_picker / mcp_catalog. Import lazily so + # the original `mcp_config` module stays import-cheap. + if action == "picker": + from hermes_cli.mcp_picker import run_picker + run_picker() + return + if action == "catalog": + from hermes_cli.mcp_picker import show_catalog + show_catalog() + return + if action == "install": + from hermes_cli.mcp_picker import install_by_name + import sys as _sys + rc = install_by_name(getattr(args, "identifier", "") or "") + if rc: + _sys.exit(rc) + return + handlers = { "add": cmd_mcp_add, "remove": cmd_mcp_remove, @@ -765,15 +783,20 @@ def mcp_command(args): if handler: handler(args) else: - # No subcommand — show list - cmd_mcp_list() + # No subcommand — drop the user into the catalog picker. This is the + # "try enabling and it flows you into setup" UX matching `hermes plugin`. + from hermes_cli.mcp_picker import run_picker + run_picker() print(color(" Commands:", Colors.CYAN)) + _info("hermes mcp Open the catalog picker (default)") + _info("hermes mcp catalog List Nous-approved MCPs") + _info("hermes mcp install Install a catalog MCP") _info("hermes mcp serve Run as MCP server") - _info("hermes mcp add --url Add an MCP server") + _info("hermes mcp add --url Add a custom MCP server") _info("hermes mcp add --command Add a stdio server") _info("hermes mcp add --preset Add from a known preset") _info("hermes mcp remove Remove a server") - _info("hermes mcp list List servers") + _info("hermes mcp list List configured servers") _info("hermes mcp test Test connection") _info("hermes mcp configure Toggle tools") _info("hermes mcp login Re-authenticate OAuth") diff --git a/hermes_cli/mcp_picker.py b/hermes_cli/mcp_picker.py new file mode 100644 index 0000000000000..8bf2beffaf9d7 --- /dev/null +++ b/hermes_cli/mcp_picker.py @@ -0,0 +1,322 @@ +"""MCP picker — interactive `hermes mcp picker` (also the default `hermes mcp`). + +Lists every catalog entry plus any custom MCP servers the user has added via +``hermes mcp add``, lets them pick one, and routes to install / enable / +disable / uninstall / configure-tools flows. + +Mirrors the `hermes plugin` picker UX: arrow keys to navigate, ENTER on a row +to act on it. The action depends on current status: + + not installed (catalog) → install (clone/bootstrap if needed, prompt for creds) + installed / disabled → enable + installed / enabled → submenu: configure tools / disable / uninstall / reinstall + custom (non-catalog) → submenu: configure tools / enable / disable / remove + +The picker loops until the user hits ESC/q so they can manage multiple +entries in one session. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from typing import List, Optional + +from hermes_cli.colors import Colors, color +from hermes_cli.cli_output import prompt_yes_no +from hermes_cli.curses_ui import curses_single_select +from hermes_cli.mcp_catalog import ( + CatalogEntry, + CatalogError, + catalog_diagnostics, + install_entry, + is_enabled, + is_installed, + list_catalog, + installed_servers, + uninstall_entry, +) +from hermes_cli.config import load_config, save_config + + +# ─── Status badges ──────────────────────────────────────────────────────────── + +_STATUS_NOT_INSTALLED = "available" +_STATUS_DISABLED = "installed (disabled)" +_STATUS_ENABLED = "enabled" +_STATUS_CUSTOM_ENABLED = "custom — enabled" +_STATUS_CUSTOM_DISABLED = "custom — disabled" + + +# ─── Row model — unifies catalog and custom entries ────────────────────────── + + +@dataclass +class _Row: + """A row in the picker. ``entry`` is set for catalog rows; for custom + user-added MCPs only ``name`` + ``description`` + status are populated.""" + + name: str + description: str + status: str + entry: Optional[CatalogEntry] = None # None for non-catalog (custom) rows + + @property + def is_custom(self) -> bool: + return self.entry is None + + +def _build_rows() -> List[_Row]: + """Return catalog rows + any custom (non-catalog) MCPs found in config.""" + catalog_entries = list_catalog() + catalog_names = {e.name for e in catalog_entries} + + rows: List[_Row] = [] + for entry in catalog_entries: + if not is_installed(entry.name): + status = _STATUS_NOT_INSTALLED + elif is_enabled(entry.name): + status = _STATUS_ENABLED + else: + status = _STATUS_DISABLED + rows.append( + _Row( + name=entry.name, + description=entry.description, + status=status, + entry=entry, + ) + ) + + # Custom MCPs the user added directly (not in the catalog) + for name, cfg in sorted(installed_servers().items()): + if name in catalog_names: + continue + enabled = cfg.get("enabled", True) + if isinstance(enabled, str): + enabled = enabled.lower() in {"true", "1", "yes"} + status = _STATUS_CUSTOM_ENABLED if enabled else _STATUS_CUSTOM_DISABLED + # Use the transport URL/command as the "description" for custom rows + desc = cfg.get("url") or cfg.get("command") or "(no transport)" + rows.append(_Row(name=name, description=str(desc), status=status)) + + return rows + + +def _format_row(row: _Row) -> str: + return f"{row.name:<18} {row.status:<24} {row.description}" + + +# ─── Actions ────────────────────────────────────────────────────────────────── + + +def _enable_disable(name: str, *, enable: bool) -> None: + cfg = load_config() + servers = cfg.get("mcp_servers") or {} + server = servers.get(name) + if not server: + print(color(f" '{name}' is not installed.", Colors.RED)) + return + server["enabled"] = enable + cfg["mcp_servers"] = servers + save_config(cfg) + print(color( + f" ✓ '{name}' {'enabled' if enable else 'disabled'}. " + "Start a new Hermes session for changes to take effect.", + Colors.GREEN, + )) + + +def _configure_tools(name: str) -> None: + """Open the tool selection checklist for an already-installed MCP. + + Delegates to the existing ``cmd_mcp_configure`` flow which probes the + server, displays a checklist, and writes ``tools.include``. + """ + import argparse + from hermes_cli.mcp_config import cmd_mcp_configure + + cmd_mcp_configure(argparse.Namespace(name=name)) + + +def _remove_custom(name: str) -> None: + """Remove a non-catalog MCP entry from config.yaml.""" + cfg = load_config() + servers = cfg.get("mcp_servers") or {} + if name not in servers: + print(color(f" '{name}' is not configured.", Colors.RED)) + return + if not prompt_yes_no(f"Remove '{name}' from mcp_servers?", default=False): + return + del servers[name] + if not servers: + cfg.pop("mcp_servers", None) + else: + cfg["mcp_servers"] = servers + save_config(cfg) + print(color(f" ✓ Removed '{name}'", Colors.GREEN)) + + +def _handle_row(row: _Row) -> None: + """Act on the picked row based on its current status.""" + # === Catalog row, not yet installed === + if row.entry and not is_installed(row.name): + try: + install_entry(row.entry, enable=True) + except CatalogError as exc: + print(color(f" ✗ install failed: {exc}", Colors.RED)) + return + + # === Catalog row, installed but disabled === + if row.entry and not is_enabled(row.name): + _enable_disable(row.name, enable=True) + return + + # === Catalog row, installed + enabled OR custom row === + if row.is_custom: + # Custom (non-catalog) row submenu + actions = [ + "Configure tools (probe server + re-pick)", + "Enable" if not is_enabled(row.name) else "Disable", + "Remove from config", + ] + choice = curses_single_select(f"Action for '{row.name}' (custom)", actions) + if choice is None: + return + if choice == 0: + _configure_tools(row.name) + elif choice == 1: + _enable_disable(row.name, enable=not is_enabled(row.name)) + elif choice == 2: + _remove_custom(row.name) + return + + # Catalog row, installed + enabled + print() + print(color(f" '{row.name}' is already enabled.", Colors.DIM)) + actions = [ + "Configure tools (probe server + re-pick)", + "Disable (keep config, stop loading on next session)", + "Uninstall (remove config and any cloned files)", + "Reinstall (re-clone, re-prompt for credentials)", + ] + choice = curses_single_select(f"Action for '{row.name}'", actions) + if choice is None: + return + if choice == 0: + _configure_tools(row.name) + elif choice == 1: + _enable_disable(row.name, enable=False) + elif choice == 2: + if prompt_yes_no(f"Uninstall '{row.name}'?", default=False): + if uninstall_entry(row.name): + print(color( + f" ✓ Uninstalled '{row.name}'. " + "Credentials in .env preserved — delete manually if no longer needed.", + Colors.GREEN, + )) + else: + print(color(f" '{row.name}' was not installed", Colors.DIM)) + elif choice == 3: + try: + assert row.entry is not None + install_entry(row.entry, enable=True) + except CatalogError as exc: + print(color(f" ✗ reinstall failed: {exc}", Colors.RED)) + + +# ─── Output / entry points ──────────────────────────────────────────────────── + + +def _print_rows_text(rows: List[_Row]) -> None: + """Plain-text catalog dump used as a fallback when curses can't run, and + as the default output of `hermes mcp catalog`.""" + if not rows: + print() + print(color(" No MCPs in the catalog or configured.", Colors.DIM)) + print() + return + + print() + print(color(" MCP Catalog + configured servers:", Colors.CYAN + Colors.BOLD)) + print() + print(f" {'Name':<18} {'Status':<24} Description") + print(f" {'-' * 18} {'-' * 24} {'-' * 11}") + for row in rows: + print(f" {_format_row(row)}") + print() + print(color( + " Install: hermes mcp install Picker: hermes mcp", + Colors.DIM, + )) + + # Surface manifest-version warnings so users know when their Hermes is + # too old to install everything in the catalog. + diags = catalog_diagnostics() + future = [d for d in diags if d[1] == "future_manifest"] + if future: + print() + for name, _, msg in future: + print(color( + f" ⚠ '{name}' requires a newer Hermes — run `hermes update` " + "to install this entry.", + Colors.YELLOW, + )) + print() + print() + + +def show_catalog() -> None: + """`hermes mcp catalog` — print the curated list + custom servers, no interaction.""" + _print_rows_text(_build_rows()) + + +def run_picker() -> None: + """`hermes mcp picker` (and default `hermes mcp`) — interactive selector. + + Loops until the user hits ESC/q. After each action the picker re-renders + so the user can manage several entries in one session. + """ + if not sys.stdin.isatty(): + # Non-interactive shell: degrade to the text dump rather than failing. + _print_rows_text(_build_rows()) + return + + while True: + rows = _build_rows() + if not rows: + _print_rows_text(rows) + return + + labels = [_format_row(r) for r in rows] + idx = curses_single_select( + "MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit", + labels, + ) + if idx is None: + return + _handle_row(rows[idx]) + + +def install_by_name(identifier: str) -> int: + """`hermes mcp install ` — non-interactive entry-point. + + Returns 0 on success, non-zero on failure (so the CLI can propagate + exit codes). + """ + from hermes_cli.mcp_catalog import get_entry + + entry = get_entry(identifier) + if entry is None: + print(color( + f" ✗ '{identifier}' is not in the catalog. " + "Run `hermes mcp catalog` to see available entries.", + Colors.RED, + )) + return 1 + try: + install_entry(entry, enable=True) + except CatalogError as exc: + print(color(f" ✗ install failed: {exc}", Colors.RED)) + return 1 + return 0 diff --git a/hermes_cli/model_normalize.py b/hermes_cli/model_normalize.py index 0e74db718d936..d7f8f3ea22ea7 100644 --- a/hermes_cli/model_normalize.py +++ b/hermes_cli/model_normalize.py @@ -67,7 +67,6 @@ _AGGREGATOR_PROVIDERS: frozenset[str] = frozenset({ "openrouter", "nous", - "ai-gateway", "kilocode", }) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 2617ecf33ab11..097b6a7eb9381 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -37,7 +37,7 @@ ("anthropic/claude-sonnet-4.6", ""), ("moonshotai/kimi-k2.6", "recommended"), ("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"), - ("qwen/qwen3.6-plus", ""), + ("qwen/qwen3.7-max", ""), ("anthropic/claude-haiku-4.5", ""), ("openai/gpt-5.5", ""), ("openai/gpt-5.5-pro", ""), @@ -69,29 +69,6 @@ _openrouter_catalog_cache: list[tuple[str, str]] | None = None -# Fallback Vercel AI Gateway snapshot used when the live catalog is unavailable. -# OSS / open-weight models prioritized first, then closed-source by family. -# Slugs match Vercel's actual /v1/models catalog (e.g. alibaba/ for Qwen, -# zai/ and xai/ without hyphens). -VERCEL_AI_GATEWAY_MODELS: list[tuple[str, str]] = [ - ("moonshotai/kimi-k2.6", "recommended"), - ("alibaba/qwen3.6-plus", ""), - ("zai/glm-5.1", ""), - ("minimax/minimax-m2.7", ""), - ("anthropic/claude-sonnet-4.6", ""), - ("anthropic/claude-opus-4.7", ""), - ("anthropic/claude-opus-4.6", ""), - ("anthropic/claude-haiku-4.5", ""), - ("openai/gpt-5.4", ""), - ("openai/gpt-5.4-mini", ""), - ("openai/gpt-5.3-codex", ""), - ("google/gemini-3.1-pro-preview", ""), - ("google/gemini-3-flash", ""), - ("google/gemini-3.1-flash-lite-preview", ""), - ("xai/grok-4.20-reasoning", ""), -] - -_ai_gateway_catalog_cache: list[tuple[str, str]] | None = None def _codex_curated_models() -> list[str]: @@ -166,7 +143,7 @@ def _xai_curated_models() -> list[str]: "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "moonshotai/kimi-k2.6", - "qwen/qwen3.6-plus", + "qwen/qwen3.7-max", "anthropic/claude-haiku-4.5", "openai/gpt-5.5", "openai/gpt-5.5-pro", @@ -399,6 +376,7 @@ def _xai_curated_models() -> list[str]: "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", + "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus", ], @@ -415,6 +393,7 @@ def _xai_curated_models() -> list[str]: # to https://dashscope-intl.aliyuncs.com/compatible-mode/v1 (OpenAI-compat) # or https://dashscope-intl.aliyuncs.com/apps/anthropic (Anthropic-compat). "alibaba": [ + "qwen3.7-max", "qwen3.6-plus", "kimi-k2.5", "qwen3.5-plus", @@ -428,6 +407,7 @@ def _xai_curated_models() -> list[str]: # Alibaba Coding Plan — same platform as alibaba (DashScope coding-intl), # separate provider ID with its own base_url_env_var. "alibaba-coding-plan": [ + "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus", "qwen3-coder-plus", @@ -478,12 +458,6 @@ def _xai_curated_models() -> list[str]: ], } -# Vercel AI Gateway: derive the bare-model-id catalog from the curated -# ``VERCEL_AI_GATEWAY_MODELS`` snapshot so both the picker (tuples with descriptions) -# and the static fallback catalog (bare ids) stay in sync from a single -# source of truth. -_PROVIDER_MODELS["ai-gateway"] = [mid for mid, _ in VERCEL_AI_GATEWAY_MODELS] - # --------------------------------------------------------------------------- # Nous Portal free-model helper # --------------------------------------------------------------------------- @@ -968,7 +942,6 @@ class ProviderEntry(NamedTuple): ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"), ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek — IAM or API key)"), ProviderEntry("azure-foundry", "Azure Foundry", "Azure Foundry (OpenAI-style or Anthropic-style endpoint — your Azure AI deployment)"), - ProviderEntry("ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway"), ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"), ] @@ -1032,9 +1005,6 @@ class ProviderEntry(NamedTuple): "zen": "opencode-zen", "go": "opencode-go", "opencode-go-sub": "opencode-go", - "aigateway": "ai-gateway", - "vercel": "ai-gateway", - "vercel-ai-gateway": "ai-gateway", "kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode", @@ -1219,95 +1189,6 @@ def get_curated_nous_model_ids() -> list[str]: return list(_PROVIDER_MODELS.get("nous", [])) -def _ai_gateway_model_is_free(pricing: Any) -> bool: - """Return True if an AI Gateway model has $0 input AND output pricing.""" - if not isinstance(pricing, dict): - return False - try: - return float(pricing.get("input", "0")) == 0 and float(pricing.get("output", "0")) == 0 - except (TypeError, ValueError): - return False - - -def fetch_ai_gateway_models( - timeout: float = 8.0, - *, - force_refresh: bool = False, -) -> list[tuple[str, str]]: - """Return the curated AI Gateway picker list, refreshed from the live catalog when possible.""" - global _ai_gateway_catalog_cache - - if _ai_gateway_catalog_cache is not None and not force_refresh: - return list(_ai_gateway_catalog_cache) - - from hermes_constants import AI_GATEWAY_BASE_URL - - fallback = list(VERCEL_AI_GATEWAY_MODELS) - preferred_ids = [mid for mid, _ in fallback] - - try: - req = urllib.request.Request( - f"{AI_GATEWAY_BASE_URL.rstrip('/')}/models", - headers={"Accept": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=timeout) as resp: - payload = json.loads(resp.read().decode()) - except Exception: - return list(_ai_gateway_catalog_cache or fallback) - - live_items = payload.get("data", []) - if not isinstance(live_items, list): - return list(_ai_gateway_catalog_cache or fallback) - - live_by_id: dict[str, dict[str, Any]] = {} - for item in live_items: - if not isinstance(item, dict): - continue - mid = str(item.get("id") or "").strip() - if not mid: - continue - live_by_id[mid] = item - - curated: list[tuple[str, str]] = [] - for preferred_id in preferred_ids: - live_item = live_by_id.get(preferred_id) - if live_item is None: - continue - desc = "free" if _ai_gateway_model_is_free(live_item.get("pricing")) else "" - curated.append((preferred_id, desc)) - - if not curated: - return list(_ai_gateway_catalog_cache or fallback) - - # If the live catalog offers a free Moonshot model, auto-promote it to - # position #1 as "recommended" — dynamic discovery without a PR. - free_moonshot = next( - ( - mid - for mid, item in live_by_id.items() - if mid.startswith("moonshotai/") - and _ai_gateway_model_is_free(item.get("pricing")) - ), - None, - ) - if free_moonshot: - curated = [(mid, desc) for mid, desc in curated if mid != free_moonshot] - curated.insert(0, (free_moonshot, "recommended")) - else: - first_id, _ = curated[0] - curated[0] = (first_id, "recommended") - - _ai_gateway_catalog_cache = curated - return list(curated) - - -def ai_gateway_model_ids(*, force_refresh: bool = False) -> list[str]: - """Return just the AI Gateway model-id strings.""" - return [mid for mid, _ in fetch_ai_gateway_models(force_refresh=force_refresh)] - - - - # --------------------------------------------------------------------------- # Pricing helpers — fetch live pricing from OpenRouter-compatible /v1/models # --------------------------------------------------------------------------- @@ -1453,56 +1334,6 @@ def fetch_models_with_pricing( return result -def fetch_ai_gateway_pricing( - timeout: float = 8.0, - *, - force_refresh: bool = False, -) -> dict[str, dict[str, str]]: - """Fetch Vercel AI Gateway /v1/models and return hermes-shaped pricing. - - Vercel uses ``input`` / ``output`` field names; hermes's picker expects - ``prompt`` / ``completion``. This translates. Cache read/write field names - already match. - """ - from hermes_constants import AI_GATEWAY_BASE_URL - - cache_key = AI_GATEWAY_BASE_URL.rstrip("/") - if not force_refresh and cache_key in _pricing_cache: - return _pricing_cache[cache_key] - - try: - req = urllib.request.Request( - f"{cache_key}/models", - headers={"Accept": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=timeout) as resp: - payload = json.loads(resp.read().decode()) - except Exception: - _pricing_cache[cache_key] = {} - return {} - - result: dict[str, dict[str, str]] = {} - for item in payload.get("data", []): - if not isinstance(item, dict): - continue - mid = item.get("id") - pricing = item.get("pricing") - if not (mid and isinstance(pricing, dict)): - continue - entry: dict[str, str] = { - "prompt": str(pricing.get("input", "")), - "completion": str(pricing.get("output", "")), - } - if pricing.get("input_cache_read"): - entry["input_cache_read"] = str(pricing["input_cache_read"]) - if pricing.get("input_cache_write"): - entry["input_cache_write"] = str(pricing["input_cache_write"]) - result[mid] = entry - - _pricing_cache[cache_key] = result - return result - - def _resolve_openrouter_api_key() -> str: """Best-effort OpenRouter API key for pricing fetch.""" return os.getenv("OPENROUTER_API_KEY", "").strip() @@ -1534,7 +1365,7 @@ def _resolve_nous_pricing_credentials() -> tuple[str, str]: def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> dict[str, dict[str, str]]: - """Return live pricing for providers that support it (openrouter, nous, ai-gateway, novita).""" + """Return live pricing for providers that support it (openrouter, nous, novita).""" normalized = normalize_provider(provider) if normalized == "openrouter": return fetch_models_with_pricing( @@ -1542,8 +1373,6 @@ def get_pricing_for_provider(provider: str, *, force_refresh: bool = False) -> d base_url="https://openrouter.ai/api", force_refresh=force_refresh, ) - if normalized == "ai-gateway": - return fetch_ai_gateway_pricing(force_refresh=force_refresh) if normalized == "novita": return _fetch_novita_pricing(force_refresh=force_refresh) if normalized == "nous": @@ -1573,9 +1402,8 @@ def _fetch_novita_pricing( 0.0001 USD. Convert them to the per-token strings used by the shared pricing formatter. - Results are cached in ``_pricing_cache`` keyed on the resolved base URL, - matching the pattern used by ``fetch_ai_gateway_pricing`` — without this, - every menu render or pricing lookup re-hits the network. + Results are cached in ``_pricing_cache`` keyed on the resolved base URL — + without this, every menu render or pricing lookup re-hits the network. """ api_key = os.getenv("NOVITA_API_KEY", "").strip() if not api_key: @@ -1762,7 +1590,7 @@ def _model_in_provider_catalog(name_lower: str, providers: set[str]) -> bool: _AGGREGATOR_PROVIDERS = frozenset( - {"nous", "openrouter", "ai-gateway", "copilot", "kilocode"} + {"nous", "openrouter", "copilot", "kilocode"} ) @@ -2109,7 +1937,7 @@ def _resolve_copilot_catalog_api_key() -> str: # - "nous": curated list and Portal /models endpoint are the source of # truth for the subscription tier. # Also excluded: providers that already have dedicated live-endpoint -# branches below (copilot, anthropic, ai-gateway, ollama-cloud, custom, +# branches below (copilot, anthropic, ollama-cloud, custom, # stepfun, openai-codex) — those paths handle freshness themselves. _MODELS_DEV_PREFERRED: frozenset[str] = frozenset({ "opencode-go", @@ -2234,10 +2062,6 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) live = _fetch_anthropic_models() if live: return live - if normalized == "ai-gateway": - live = _fetch_ai_gateway_models() - if live: - return live if normalized == "ollama-cloud": live = fetch_ollama_cloud_models(force_refresh=force_refresh) if live: @@ -3015,6 +2839,8 @@ def opencode_model_api_mode(provider_id: Optional[str], model_id: Optional[str]) if provider == "opencode-go": if normalized.startswith("minimax-"): return "anthropic_messages" + if normalized.startswith("qwen3.7-max"): + return "anthropic_messages" return "chat_completions" if provider == "opencode-zen": @@ -3149,36 +2975,6 @@ def probe_api_models( } -def _fetch_ai_gateway_models(timeout: float = 5.0) -> Optional[list[str]]: - """Fetch available language models with tool-use from AI Gateway.""" - api_key = os.getenv("AI_GATEWAY_API_KEY", "").strip() - if not api_key: - return None - base_url = os.getenv("AI_GATEWAY_BASE_URL", "").strip() - if not base_url: - from hermes_constants import AI_GATEWAY_BASE_URL - base_url = AI_GATEWAY_BASE_URL - - url = base_url.rstrip("/") + "/models" - headers: dict[str, str] = { - "Authorization": f"Bearer {api_key}", - "User-Agent": _HERMES_USER_AGENT, - } - req = urllib.request.Request(url, headers=headers) - try: - with urllib.request.urlopen(req, timeout=timeout) as resp: - data = json.loads(resp.read().decode()) - return [ - m["id"] - for m in data.get("data", []) - if m.get("id") - and m.get("type") == "language" - and "tool-use" in (m.get("tags") or []) - ] - except Exception: - return None - - def fetch_api_models( api_key: Optional[str], base_url: Optional[str], diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index bd6367a44c844..854f3d9f3096d 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -553,6 +553,46 @@ def register_image_gen_provider(self, provider) -> None: self.manifest.name, provider.name, ) + # -- dashboard auth provider registration -------------------------------- + + def register_dashboard_auth_provider(self, provider) -> None: + """Register a dashboard authentication provider. + + ``provider`` must be an instance of + :class:`hermes_cli.dashboard_auth.DashboardAuthProvider`. Used by + the dashboard OAuth auth gate, which engages when the dashboard + binds to a non-loopback host without ``--insecure``. + + Misbehaving providers (wrong type, duplicate name) are logged at + WARNING and silently ignored — never raised — so a broken plugin + cannot crash the host. Same convention as + ``register_image_gen_provider``. + """ + from hermes_cli.dashboard_auth import ( + DashboardAuthProvider, register_provider, + ) + + if not isinstance(provider, DashboardAuthProvider): + logger.warning( + "Plugin '%s' tried to register a dashboard-auth provider " + "that does not inherit from DashboardAuthProvider. Ignoring.", + self.manifest.name, + ) + return + try: + register_provider(provider) + except (TypeError, ValueError) as e: + logger.warning( + "Plugin '%s' failed to register dashboard-auth provider " + "%r: %s", + self.manifest.name, getattr(provider, "name", "?"), e, + ) + return + logger.info( + "Plugin '%s' registered dashboard-auth provider: %s (%s)", + self.manifest.name, provider.name, provider.display_name, + ) + # -- video gen provider registration ------------------------------------- def register_video_gen_provider(self, provider) -> None: diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 2490bad802a97..a19a4584f98cc 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -143,10 +143,6 @@ class HermesOverlay: transport="openai_chat", base_url_env_var="ALIBABA_CODING_PLAN_BASE_URL", ), - "vercel": HermesOverlay( - transport="openai_chat", - is_aggregator=True, - ), "opencode": HermesOverlay( transport="openai_chat", is_aggregator=True, @@ -290,11 +286,6 @@ class ProviderDef: "github": "github-copilot", "github-copilot-acp": "copilot-acp", - # vercel (models.dev ID for AI Gateway) - "ai-gateway": "vercel", - "aigateway": "vercel", - "vercel-ai-gateway": "vercel", - # opencode (models.dev ID for OpenCode Zen) "opencode-zen": "opencode", "zen": "opencode", diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index e85d2100404c6..57c0a8824cf54 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -104,7 +104,7 @@ def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: state = self._read_state() if state is None: raise RuntimeError( - "Not logged into Nous Portal. Run `hermes login nous` first." + "Not logged into Nous Portal. Run `hermes auth add nous` first." ) try: @@ -135,7 +135,7 @@ def _get_credential(self, *, inference_auth_mode: str) -> UpstreamCredential: if not agent_key: raise RuntimeError( "Nous Portal refresh did not return a usable agent_key. " - "Try `hermes login nous` to re-authenticate." + "Try `hermes auth add nous` to re-authenticate." ) base_url = ( diff --git a/hermes_cli/proxy/cli.py b/hermes_cli/proxy/cli.py index 6accd9497058b..7c7b86caf0852 100644 --- a/hermes_cli/proxy/cli.py +++ b/hermes_cli/proxy/cli.py @@ -44,7 +44,7 @@ def cmd_proxy_start(args: Any) -> int: return 2 if not adapter.is_authenticated(): - auth_hint = getattr(adapter, "auth_hint", f"hermes login {adapter.name}") + auth_hint = getattr(adapter, "auth_hint", f"hermes auth add {adapter.name}") print( f"Not logged into {adapter.display_name}. " f"Run `{auth_hint}` first.", diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 1d6e1324e4e0a..2cb7af202f867 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -101,10 +101,9 @@ def _supports_same_provider_pool_setup(provider: str) -> bool: "arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"], "minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"], "minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"], - "ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"], "kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"], "opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"], - "opencode-go": ["kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.6-plus", "qwen3.5-plus"], + "opencode-go": ["kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"], "huggingface": [ "Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507", "Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528", @@ -684,102 +683,6 @@ def _prompt_container_resources(config: dict): pass -def _prompt_vercel_sandbox_settings(config: dict): - """Prompt for Vercel Sandbox settings without exposing unsupported disk sizing.""" - terminal = config.setdefault("terminal", {}) - - print() - print_info("Vercel Sandbox settings:") - print_info(" Filesystem persistence uses Vercel snapshots.") - print_info(" Snapshots restore files only; live processes do not continue after sandbox recreation.") - - from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES - - current_runtime = terminal.get("vercel_runtime") or "node24" - supported_label = ", ".join(_SUPPORTED_VERCEL_RUNTIMES) - runtime = prompt(f" Runtime ({supported_label})", current_runtime).strip() or current_runtime - if runtime not in _SUPPORTED_VERCEL_RUNTIMES: - print_warning(f"Unsupported Vercel runtime '{runtime}', keeping {current_runtime}.") - runtime = current_runtime if current_runtime in _SUPPORTED_VERCEL_RUNTIMES else "node24" - terminal["vercel_runtime"] = runtime - save_env_value("TERMINAL_VERCEL_RUNTIME", runtime) - - current_persist = terminal.get("container_persistent", True) - persist_label = "yes" if current_persist else "no" - terminal["container_persistent"] = prompt( - " Persist filesystem with snapshots? (yes/no)", persist_label - ).lower() in {"yes", "true", "y", "1"} - - current_cpu = terminal.get("container_cpu", 1) - cpu_str = prompt(" CPU cores", str(current_cpu)) - try: - terminal["container_cpu"] = float(cpu_str) - except ValueError: - pass - - current_mem = terminal.get("container_memory", 5120) - mem_str = prompt(" Memory in MB (5120 = 5GB)", str(current_mem)) - try: - terminal["container_memory"] = int(mem_str) - except ValueError: - pass - - if terminal.get("container_disk", 51200) not in {0, 51200}: - print_warning("Vercel Sandbox does not support custom disk sizing; resetting container_disk to 51200.") - terminal["container_disk"] = 51200 - - print() - print_info("Vercel authentication:") - print_info(" Use a long-lived Vercel access token plus project/team IDs.") - linked_project = _read_nearest_vercel_project() - if linked_project: - print_info(" Found defaults in nearest .vercel/project.json.") - - remove_env_value("VERCEL_OIDC_TOKEN") - token = prompt(" Vercel access token", get_env_value("VERCEL_TOKEN") or "", password=True) - project = prompt( - " Vercel project ID", - get_env_value("VERCEL_PROJECT_ID") or linked_project.get("projectId", ""), - ) - team = prompt( - " Vercel team ID", - get_env_value("VERCEL_TEAM_ID") or linked_project.get("orgId", ""), - ) - if token: - save_env_value("VERCEL_TOKEN", token) - if project: - save_env_value("VERCEL_PROJECT_ID", project) - if team: - save_env_value("VERCEL_TEAM_ID", team) - - -def _read_nearest_vercel_project(start: Path | None = None) -> dict[str, str]: - """Read project/team defaults from the nearest Vercel link file.""" - current = (start or Path.cwd()).resolve() - if current.is_file(): - current = current.parent - - for directory in (current, *current.parents): - project_file = directory / ".vercel" / "project.json" - if not project_file.exists(): - continue - try: - data = json.loads(project_file.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {} - if not isinstance(data, dict): - return {} - return { - key: value - for key, value in { - "projectId": data.get("projectId"), - "orgId": data.get("orgId"), - }.items() - if isinstance(value, str) and value.strip() - } - return {} - - # Tool categories and provider config are now in tools_config.py (shared # between `hermes tools` and `hermes setup tools`). @@ -941,7 +844,6 @@ def setup_model_provider(config: dict, *, quick: bool = False): "minimax": "MiniMax", "minimax-cn": "MiniMax CN", "anthropic": "Anthropic", - "ai-gateway": "Vercel AI Gateway", "custom": "your custom endpoint", } _prov_display = _prov_names.get(selected_provider, selected_provider or "your provider") @@ -1412,12 +1314,11 @@ def setup_terminal_backend(config: dict): "Modal - serverless cloud sandbox", "SSH - run on a remote machine", "Daytona - persistent cloud development environment", - "Vercel Sandbox - cloud microVM with snapshot filesystem persistence", ] - idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "vercel_sandbox"} - backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "vercel_sandbox": 5} + idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"} + backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4} - next_idx = 6 + next_idx = 5 if is_linux: terminal_choices.append("Singularity/Apptainer - HPC-friendly container") idx_to_backend[next_idx] = "singularity" @@ -1663,39 +1564,6 @@ def setup_terminal_backend(config: dict): _prompt_container_resources(config) - elif selected_backend == "vercel_sandbox": - print_success("Terminal backend: Vercel Sandbox") - print_info("Cloud microVM sandboxes with snapshot-backed filesystem persistence.") - print_info("Requires the optional SDK: pip install 'hermes-agent[vercel]'") - - try: - __import__("vercel") - except ImportError: - print_info("Installing vercel SDK...") - import subprocess - - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [uv_bin, "pip", "install", "--python", sys.executable, "vercel"], - capture_output=True, - text=True, - ) - else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "vercel"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - print_success("vercel SDK installed") - else: - print_warning("Install failed — run manually: pip install 'hermes-agent[vercel]'") - if result.stderr: - print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") - - _prompt_vercel_sandbox_settings(config) - elif selected_backend == "ssh": print_success("Terminal backend: SSH") print_info("Run commands on a remote machine via SSH.") @@ -1749,8 +1617,6 @@ def setup_terminal_backend(config: dict): save_env_value("TERMINAL_ENV", selected_backend) if selected_backend == "modal": save_env_value("TERMINAL_MODAL_MODE", config["terminal"].get("modal_mode", "auto")) - if selected_backend == "vercel_sandbox": - save_env_value("TERMINAL_VERCEL_RUNTIME", config["terminal"].get("vercel_runtime", "node24")) save_config(config) print() print_success(f"Terminal backend set to: {selected_backend}") diff --git a/hermes_cli/status.py b/hermes_cli/status.py index ab4d40bbc474d..df72d084e4997 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -18,7 +18,6 @@ from hermes_cli.models import provider_label from hermes_cli.nous_subscription import get_nous_subscription_features from hermes_cli.runtime_provider import resolve_requested_provider -from hermes_cli.vercel_auth import describe_vercel_auth from hermes_constants import OPENROUTER_MODELS_URL from tools.tool_backend_helpers import managed_nous_tools_enabled @@ -380,23 +379,6 @@ def _resolve_env(env_ref) -> str: elif terminal_env == "daytona": daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20") print(f" Daytona Image: {daytona_image}") - elif terminal_env == "vercel_sandbox": - runtime = os.getenv("TERMINAL_VERCEL_RUNTIME") or terminal_cfg.get("vercel_runtime") or "node24" - persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT") - if persist is None: - persist_enabled = bool(terminal_cfg.get("container_persistent", True)) - else: - persist_enabled = persist.lower() in {"1", "true", "yes", "on"} - auth_status = describe_vercel_auth() - sdk_ok = importlib.util.find_spec("vercel") is not None - sdk_label = "installed" if sdk_ok else "missing (install: pip install 'hermes-agent[vercel]')" - print(f" Runtime: {runtime}") - print(f" SDK: {check_mark(sdk_ok)} {sdk_label}") - print(f" Auth: {check_mark(auth_status.ok)} {auth_status.label}") - for line in auth_status.detail_lines: - print(f" Auth detail: {line}") - print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}") - print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation") sudo_password = os.getenv("SUDO_PASSWORD", "") print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}") diff --git a/hermes_cli/tips.py b/hermes_cli/tips.py index 2871cc4af8f34..feebe4310a092 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -227,6 +227,9 @@ "browser_vision with annotate=true overlays numbered labels on interactive elements.", # --- MCP --- + "hermes mcp opens an interactive picker of Nous-approved MCPs you can install in one keystroke.", + "hermes mcp catalog lists Nous-approved MCP servers shipped with the repo.", + "hermes mcp install installs a catalog entry, prompts for credentials, and lets you pick which of its tools to enable.", "MCP servers are configured in config.yaml — both stdio and HTTP transports supported.", "Per-server tool filtering: tools.include whitelists and tools.exclude blacklists specific tools.", "MCP servers auto-generate toolsets at runtime — hermes tools can toggle them per platform.", @@ -260,7 +263,7 @@ "Custom providers: save named endpoints in config.yaml under custom_providers.", "HERMES_EPHEMERAL_SYSTEM_PROMPT injects a system prompt that's never persisted to history.", "credential_pool_strategies supports fill_first, round_robin, least_used, and random rotation.", - "hermes login supports OAuth-based auth for Nous and OpenAI Codex providers.", + "hermes auth add nous or hermes auth add openai-codex sets up OAuth-based providers.", "The API server supports both Chat Completions and Responses API with server-side state.", "tool_preview_length: 0 in config shows full file paths in the spinner's activity feed.", "hermes status --deep runs deeper diagnostic checks across all components.", diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index cbe8a449d2731..1306dcfca56b4 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -3190,21 +3190,26 @@ def _configure_mcp_tools_interactive(config: dict): _print_info(f" {server_name}: no changes") continue - # Compute new exclude list based on unchecked tools - new_exclude = [tool_names[i] for i in range(len(tool_names)) if i not in chosen] + # Compute new include list (the chosen tools). We standardize on + # tools.include across the codebase (catalog installs, hermes mcp + # configure, and this UI) so a server\'s on-disk config shape doesn\'t + # depend on which UI the user touched last. + chosen_names = [tool_names[i] for i in sorted(chosen)] # Update config srv_cfg = mcp_servers.setdefault(server_name, {}) tools_cfg = srv_cfg.setdefault("tools", {}) - if new_exclude: - tools_cfg["exclude"] = new_exclude - # Remove include if present — we're switching to exclude mode + if len(chosen) == len(tools): + # All tools enabled — clear filters (cleanest config shape; the + # server\'s native tool set is the active set, and any tools the + # server adds later are auto-enabled). + tools_cfg.pop("exclude", None) tools_cfg.pop("include", None) else: - # All tools enabled — clear filters + tools_cfg["include"] = chosen_names + # Drop any legacy exclude block — we\'re include-mode now. tools_cfg.pop("exclude", None) - tools_cfg.pop("include", None) enabled_count = len(chosen) disabled_count = len(tools) - enabled_count diff --git a/hermes_cli/vercel_auth.py b/hermes_cli/vercel_auth.py deleted file mode 100644 index 4666d516e1eb1..0000000000000 --- a/hermes_cli/vercel_auth.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Helpers for reporting Vercel Sandbox authentication state.""" - -from __future__ import annotations - -import os -from dataclasses import dataclass - - -_TOKEN_TUPLE_VARS = ("VERCEL_TOKEN", "VERCEL_PROJECT_ID", "VERCEL_TEAM_ID") - - -@dataclass(frozen=True) -class VercelAuthStatus: - ok: bool - label: str - detail_lines: tuple[str, ...] - - -def _present(name: str) -> bool: - return bool(os.getenv(name)) - - -def describe_vercel_auth() -> VercelAuthStatus: - """Return Vercel auth status without exposing secret values.""" - - has_oidc = _present("VERCEL_OIDC_TOKEN") - token_states = {name: _present(name) for name in _TOKEN_TUPLE_VARS} - present_token_vars = tuple(name for name, present in token_states.items() if present) - missing_token_vars = tuple(name for name, present in token_states.items() if not present) - - if has_oidc: - details = [ - "mode: OIDC", - "active env: VERCEL_OIDC_TOKEN", - "note: OIDC tokens are development-only; use access-token auth for deployments and long-running processes", - ] - if present_token_vars: - details.append(f"also present: {', '.join(present_token_vars)}") - return VercelAuthStatus(True, "OIDC token via VERCEL_OIDC_TOKEN", tuple(details)) - - if not missing_token_vars: - return VercelAuthStatus( - True, - "access token + project/team via VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID", - ( - "mode: access token", - "active env: VERCEL_TOKEN, VERCEL_PROJECT_ID, VERCEL_TEAM_ID", - ), - ) - - if present_token_vars: - return VercelAuthStatus( - False, - f"partial access-token auth (missing {', '.join(missing_token_vars)})", - ( - "mode: incomplete access token", - f"present env: {', '.join(present_token_vars)}", - f"missing env: {', '.join(missing_token_vars)}", - "recommended: set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together", - ), - ) - - return VercelAuthStatus( - False, - "not configured", - ( - "recommended: set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", - "development-only alternative: set VERCEL_OIDC_TOKEN", - ), - ) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d8d7996b868e0..872546196c5cc 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -160,6 +160,22 @@ def _require_token(request: Request) -> None: }) +def should_require_auth(host: str, allow_public: bool) -> bool: + """Return True iff the dashboard OAuth auth gate must be active. + + Truth table: + host == loopback → False (no auth) + host != loopback AND allow_public (--insecure)→ False (legacy escape hatch) + host != loopback AND NOT allow_public → True (gate engages) + + "Loopback" matches the same set used by ``--insecure`` enforcement in + ``start_server``: 127.0.0.1, localhost, ::1. RFC1918 / CGNAT / link-local + are deliberately treated as PUBLIC — a hostile device on the same LAN is + exactly the threat model the gate is designed for. + """ + return (host not in _LOOPBACK_HOST_VALUES) and (not allow_public) + + def _is_accepted_host(host_header: str, bound_host: str) -> bool: """True if the Host header targets the interface we bound to. @@ -234,9 +250,29 @@ async def host_header_middleware(request: Request, call_next): return await call_next(request) +# --------------------------------------------------------------------------- +# Dashboard OAuth auth gate — engaged only when start_server flags the +# bind as non-loopback-without-insecure. No-op pass-through in loopback +# mode so the legacy auth_middleware (below) handles those binds via +# the injected ``_SESSION_TOKEN``. Registered between host_header and +# auth_middleware so the order is: host check → cookie auth → token auth. +# --------------------------------------------------------------------------- + + +@app.middleware("http") +async def _dashboard_auth_gate(request: Request, call_next): + from hermes_cli.dashboard_auth.middleware import gated_auth_middleware + return await gated_auth_middleware(request, call_next) + + @app.middleware("http") async def auth_middleware(request: Request, call_next): """Require the session token on all /api/ routes except the public list.""" + # When the OAuth gate is active, cookie-based auth (gated_auth_middleware + # above) is authoritative. The legacy _SESSION_TOKEN path is loopback-only + # and is skipped here so the gate's session attachment isn't overridden. + if getattr(request.app.state, "auth_required", False): + return await call_next(request) path = request.url.path if path.startswith("/api/") and path not in _PUBLIC_API_PATHS: if not _has_valid_session_token(request): @@ -266,12 +302,7 @@ async def auth_middleware(request: Request, call_next): "terminal.backend": { "type": "select", "description": "Terminal execution backend", - "options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"], - }, - "terminal.vercel_runtime": { - "type": "select", - "description": "Vercel Sandbox runtime", - "options": ["node24", "node22", "python3.13"], # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py + "options": ["local", "docker", "ssh", "modal", "daytona", "singularity"], }, "terminal.modal_mode": { "type": "select", @@ -622,6 +653,19 @@ async def get_status(): except Exception: pass + # Dashboard auth gate (Phase 7): surface whether the gate is engaged + # and which providers are registered so ``hermes status`` and the + # SPA's StatusPage can show "OAuth gate ON via Nous Research" or + # "loopback only — no auth gate" with no extra round trips. + auth_required = bool(getattr(app.state, "auth_required", False)) + auth_providers: list[str] = [] + try: + from hermes_cli.dashboard_auth import list_providers as _list_providers + auth_providers = [p.name for p in _list_providers()] + except Exception: + # Module not importable yet (early startup) — leave as []. + pass + return { "version": __version__, "release_date": __release_date__, @@ -638,6 +682,8 @@ async def get_status(): "gateway_exit_reason": gateway_exit_reason, "gateway_updated_at": gateway_updated_at, "active_sessions": active_sessions, + "auth_required": auth_required, + "auth_providers": auth_providers, } @@ -3330,8 +3376,20 @@ class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] def _ws_client_is_allowed(ws: "WebSocket") -> bool: """Check if the WebSocket client IP is acceptable. - Allows loopback clients only. + Loopback mode: only loopback clients allowed — the legacy + ``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we + don't want LAN hosts guessing tokens. + + Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True`` + (enabled when the OAuth gate is active so cookies can pick up + ``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the + X-Forwarded-For value, which is the real internet client IP. The + OAuth gate + single-use ``?ticket=`` is the auth at that point; the + Host/Origin guard in :func:`_ws_host_origin_is_allowed` is what + blocks DNS-rebinding here, not the peer IP. """ + if getattr(app.state, "auth_required", False): + return True client_host = ws.client.host if ws.client else "" if not client_host: return True @@ -3370,6 +3428,50 @@ def _ws_request_is_allowed(ws: "WebSocket") -> bool: """Return True when the WebSocket upgrade matches dashboard boundaries.""" return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws) + +def _ws_auth_ok(ws: "WebSocket") -> bool: + """Validate WS-upgrade auth in either loopback or gated mode. + + Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query + parameter, constant-time compared. + + Gated (public bind, no ``--insecure``): ``?ticket=`` query + parameter consumed against the dashboard-auth ticket store. The legacy + token path is unconditionally rejected in this mode (the SPA bundle + isn't carrying the token any longer). + + Returns True if the WS should be accepted; callers close with the + appropriate WS code (4401) on False. Audit-logs the rejection so + operators can debug "WS keeps closing" issues from the log. + """ + auth_required = bool(getattr(app.state, "auth_required", False)) + if auth_required: + ticket = ws.query_params.get("ticket", "") + if not ticket: + return False + # Lazy import — keeps this function importable in test harnesses + # that don't bring in the dashboard_auth layer. + from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log + from hermes_cli.dashboard_auth.ws_tickets import ( + TicketInvalid, + consume_ticket, + ) + + try: + consume_ticket(ticket) + return True + except TicketInvalid as exc: + audit_log( + AuditEvent.WS_TICKET_REJECTED, + reason=str(exc), + ip=(ws.client.host if ws.client else ""), + path=ws.url.path, + ) + return False + + token = ws.query_params.get("token", "") + return hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()) + # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id # the chat tab generates on mount; entries auto-evict when the last subscriber @@ -3424,7 +3526,21 @@ def _resolve_chat_argv( def _build_sidecar_url(channel: str) -> Optional[str]: - """ws:// URL the PTY child should publish events to, or None when unbound.""" + """ws:// URL the PTY child should publish events to, or None when unbound. + + Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``. + + Gated mode: mints a single-use ticket via the dashboard-auth ticket + store (server-side mint, no HTTP round trip — the PTY child is a + server-spawned process and we trust it). The ticket binds to the + pseudo-user ``"pty-sidecar"`` so audit logs can distinguish these from + browser-initiated tickets. + + The single-use lifetime means the PTY child cannot reconnect without a + new sidecar URL. PTY children open ``/api/pub`` once at startup; if + reconnect semantics ever become important, this should be upgraded to + a long-lived process-scoped token. + """ host = getattr(app.state, "bound_host", None) port = getattr(app.state, "bound_port", None) @@ -3432,7 +3548,15 @@ def _build_sidecar_url(channel: str) -> Optional[str]: return None netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}" - qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel}) + + if getattr(app.state, "auth_required", False): + # Gated mode — mint a ticket so the WS upgrade survives _ws_auth_ok. + from hermes_cli.dashboard_auth.ws_tickets import mint_ticket + + ticket = mint_ticket(user_id="pty-sidecar", provider="server-internal") + qs = urllib.parse.urlencode({"ticket": ticket, "channel": channel}) + else: + qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel}) return f"ws://{netloc}/api/pub?{qs}" @@ -3465,9 +3589,7 @@ async def pty_ws(ws: WebSocket) -> None: return # --- auth + loopback check (before accept so we can close cleanly) --- - token = ws.query_params.get("token", "") - expected = _SESSION_TOKEN - if not hmac.compare_digest(token.encode(), expected.encode()): + if not _ws_auth_ok(ws): await ws.close(code=4401) return @@ -3585,8 +3707,7 @@ async def gateway_ws(ws: WebSocket) -> None: await ws.close(code=4403) return - token = ws.query_params.get("token", "") - if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): + if not _ws_auth_ok(ws): await ws.close(code=4401) return @@ -3617,8 +3738,7 @@ async def pub_ws(ws: WebSocket) -> None: await ws.close(code=4403) return - token = ws.query_params.get("token", "") - if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): + if not _ws_auth_ok(ws): await ws.close(code=4401) return @@ -3646,8 +3766,7 @@ async def events_ws(ws: WebSocket) -> None: await ws.close(code=4403) return - token = ws.query_params.get("token", "") - if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): + if not _ws_auth_ok(ws): await ws.close(code=4401) return @@ -3687,24 +3806,13 @@ async def events_ws(ws: WebSocket) -> None: def _normalise_prefix(raw: Optional[str]) -> str: """Normalise an X-Forwarded-Prefix header value. - Returns a string like ``"/hermes"`` (no trailing slash) or ``""`` when - no prefix is set / the header is malformed. We deliberately reject - anything containing ``..`` or non-printable bytes so a hostile proxy - can't inject HTML via the prefix. + Thin re-export of :func:`hermes_cli.dashboard_auth.prefix.normalise_prefix` + — the single source of truth lives in the dashboard_auth package so + the gate middleware, the OAuth routes, the cookie helpers, and the + SPA mount all agree on validation rules. """ - if not raw: - return "" - p = raw.strip() - if not p: - return "" - if not p.startswith("/"): - p = "/" + p - p = p.rstrip("/") - if "//" in p or ".." in p or any(c in p for c in ('"', "'", "<", ">", " ", "\n", "\r", "\t")): - return "" - if len(p) > 64: - return "" - return p + from hermes_cli.dashboard_auth.prefix import normalise_prefix + return normalise_prefix(raw) def mount_spa(application: FastAPI): @@ -3737,14 +3845,33 @@ def _serve_index(prefix: str = ""): ``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``) or empty string when served at root. + + When the OAuth auth gate is active (``app.state.auth_required``), + the legacy ``_SESSION_TOKEN`` is NOT injected — the SPA reads + identity from ``/api/auth/me`` over cookie auth instead. The + ``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right + auth scheme for /api/pty and /api/ws (ticket vs token). """ html = _index_path.read_text() chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" - token_script = ( - f'' - ) + gated = bool(getattr(app.state, "auth_required", False)) + gated_js = "true" if gated else "false" + if gated: + bootstrap_script = ( + f"" + ) + else: + bootstrap_script = ( + f'" + ) if prefix: # Rewrite absolute asset URLs baked into the Vite build so the # browser fetches them through the same proxy prefix. @@ -3754,7 +3881,7 @@ def _serve_index(prefix: str = ""): html = html.replace('href="/fonts/', f'href="{prefix}/fonts/') html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/') html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/') - html = html.replace("", f"{token_script}", 1) + html = html.replace("", f"{bootstrap_script}", 1) return HTMLResponse( html, headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, @@ -4688,6 +4815,13 @@ def _mount_plugin_api_routes(): # Mount plugin API routes before the SPA catch-all. _mount_plugin_api_routes() +# Mount the dashboard auth routes (/login, /auth/*, /api/auth/*) before the +# SPA catch-all so /{full_path:path} doesn't swallow them. These are +# always mounted — the gate middleware decides whether to enforce auth, +# not whether the routes exist. +from hermes_cli.dashboard_auth.routes import router as _dashboard_auth_router # noqa: E402 +app.include_router(_dashboard_auth_router) + mount_spa(app) @@ -4705,14 +4839,65 @@ def start_server( global _DASHBOARD_EMBEDDED_CHAT_ENABLED _DASHBOARD_EMBEDDED_CHAT_ENABLED = embedded_chat - _LOCALHOST = ("127.0.0.1", "localhost", "::1") - if host not in _LOCALHOST and not allow_public: - raise SystemExit( - f"Refusing to bind to {host} — the dashboard exposes API keys " - f"and config without robust authentication.\n" - f"Use --insecure to override (NOT recommended on untrusted networks)." + # Phase 0: stash the auth-gate flag on app.state so middleware / SPA-token + # injection / WS-auth paths can branch on it consistently. Phase 3.5 + # uses this to decide whether to refuse the bind, log the gate-on + # banner, and enable uvicorn proxy_headers. + app.state.auth_required = should_require_auth(host, allow_public) + + if app.state.auth_required: + # Phase 3.5: the gate engages on non-loopback binds. The legacy + # "refusing to bind" guard is replaced by "require at least one + # provider to be registered, else fail closed". + from hermes_cli.dashboard_auth import list_providers + if not list_providers(): + # Surface the *specific* reason any bundled provider declined + # to register (e.g. missing HERMES_DASHBOARD_OAUTH_CLIENT_ID). + # Each provider plugin that ships with Hermes Agent exposes a + # module-level ``LAST_SKIP_REASON`` string for this purpose; + # without it the operator would only see "no providers" which + # is misleading when the provider IS installed but unconfigured. + skip_reasons: list[str] = [] + try: + from plugins.dashboard_auth import nous as _nous_plugin + + if _nous_plugin.LAST_SKIP_REASON: + skip_reasons.append( + f" • nous: {_nous_plugin.LAST_SKIP_REASON}" + ) + except Exception: + pass + + if skip_reasons: + raise SystemExit( + f"Refusing to bind dashboard to {host} — the OAuth auth " + f"gate engages on non-loopback binds, but no auth " + f"providers are registered.\n" + f"\n" + f"Bundled providers reported these issues:\n" + + "\n".join(skip_reasons) + + "\n" + f"\n" + f"Or pass --insecure to skip the auth gate (NOT " + f"recommended on untrusted networks)." + ) + raise SystemExit( + f"Refusing to bind dashboard to {host} — the OAuth auth " + f"gate engages on non-loopback binds, but no auth providers " + f"are registered and no bundled plugin reported a reason " + f"(was the dashboard_auth/nous plugin removed?).\n" + f"Install a DashboardAuthProvider plugin, or pass --insecure " + f"to skip the auth gate (NOT recommended on untrusted " + f"networks)." + ) + _log.info( + "Dashboard binding to %s with OAuth auth gate enabled. " + "Providers: %s", + host, + ", ".join(p.name for p in list_providers()), ) - if host not in _LOCALHOST: + elif host not in _LOOPBACK_HOST_VALUES and allow_public: + # --insecure path — no auth, loud warning. _log.warning( "Binding to %s with --insecure — the dashboard has no robust " "authentication. Only use on trusted networks.", host, @@ -4757,7 +4942,13 @@ def _open(): ) print(f" Hermes Web UI → http://{host}:{port}") - # proxy_headers=False so _ws_client_is_allowed sees the real connection peer - # rather than X-Forwarded-For's rewritten value (which would defeat the - # loopback gate when behind a reverse proxy). - uvicorn.run(app, host=host, port=port, log_level="warning", proxy_headers=False) + # proxy_headers defaults to False so _ws_client_is_allowed sees the real + # connection peer rather than X-Forwarded-For's rewritten value (which + # would defeat the loopback gate when behind a reverse proxy). When the + # OAuth gate is active we are explicitly running behind a TLS terminator + # (Fly.io) and need X-Forwarded-Proto to decide cookie Secure flags, so + # we flip proxy_headers on for that mode. + uvicorn.run( + app, host=host, port=port, log_level="warning", + proxy_headers=bool(app.state.auth_required), + ) diff --git a/hermes_constants.py b/hermes_constants.py index b54adf017865d..3ec977441e1f3 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -174,6 +174,25 @@ def get_optional_skills_dir(default: Path | None = None) -> Path: return get_hermes_home() / "optional-skills" +def get_optional_mcps_dir(default: Path | None = None) -> Path: + """Return the optional-mcps directory, honoring package-manager wrappers. + + Mirrors :func:`get_optional_skills_dir` for the MCP catalog (Nous-approved + Model Context Protocol servers shipped with the repo but disabled by + default). Packaged installs may ship ``optional-mcps`` outside the Python + package tree and expose it via ``HERMES_OPTIONAL_MCPS``. + """ + override = os.getenv("HERMES_OPTIONAL_MCPS", "").strip() + if override: + return Path(override) + packaged = _get_packaged_data_dir("optional-mcps") + if packaged is not None: + return packaged + if default is not None: + return default + return get_hermes_home() / "optional-mcps" + + def get_bundled_skills_dir(default: Path | None = None) -> Path: """Return the bundled skills directory for source and packaged installs. @@ -442,5 +461,3 @@ def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0): OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models" - -AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1" diff --git a/nix/checks.nix b/nix/checks.nix index 49955a6c5fd9b..e847ef26cbd61 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -260,6 +260,19 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) echo "ok" > $out/result ''; + # Regression guard: messaging deps live outside [all], so the + # #messaging variant must actually ship discord.py — otherwise + # `nix profile install .#messaging` regresses to the broken default. + messaging-variant = pkgs.runCommand "hermes-messaging-variant" { } '' + set -e + echo "=== Checking discord.py importable from messaging variant ===" + ${self'.packages.messaging.hermesVenv}/bin/python3 -c \ + "import discord; print(discord.__version__)" + echo "PASS: discord.py importable from messaging variant venv" + mkdir -p $out + echo "ok" > $out/result + ''; + # ── Config merge + round-trip test ──────────────────────────────── # Tests the merge script (Nix activation behavior) across 7 # scenarios, then verifies Python's load_config() reads correctly. diff --git a/nix/packages.nix b/nix/packages.nix index d95133d26aed7..a72a0d41437d1 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -2,7 +2,7 @@ { inputs, ... }: { perSystem = - { pkgs, inputs', ... }: + { pkgs, lib, inputs', ... }: let hermesAgent = pkgs.callPackage ./hermes-agent.nix { inherit (inputs) uv2nix pyproject-nix pyproject-build-systems; @@ -15,6 +15,39 @@ { packages = { default = hermesAgent; + + # Ships discord.py + python-telegram-bot + slack-sdk so a plain + # `nix profile install .#messaging` connects to Discord/Telegram/Slack + # on first run — lazy-install can't write to the read-only /nix/store. + messaging = hermesAgent.override { + extraDependencyGroups = [ "messaging" ]; + }; + + # All platform-portable optional integrations pre-built. + # matrix is Linux-only (oqs/liboqs lacks aarch64-darwin wheels). + full = hermesAgent.override { + extraDependencyGroups = [ + "anthropic" + "azure-identity" + "bedrock" + "daytona" + "dingtalk" + "edge-tts" + "exa" + "fal" + "feishu" + "firecrawl" + "hindsight" + "honcho" + "messaging" + "modal" + "parallel-web" + "tts-premium" + "vercel" + "voice" + ] ++ lib.optionals pkgs.stdenv.isLinux [ "matrix" ]; + }; + tui = hermesAgent.hermesTui; web = hermesAgent.hermesWeb; diff --git a/optional-mcps/linear/manifest.yaml b/optional-mcps/linear/manifest.yaml new file mode 100644 index 0000000000000..849ebec888ac6 --- /dev/null +++ b/optional-mcps/linear/manifest.yaml @@ -0,0 +1,38 @@ +# Nous-approved MCP catalog entry. +# Presence in this directory = approval. Merged via PR review. +manifest_version: 1 + +name: linear +description: Find, create, and update Linear issues, projects, and comments. +source: https://linear.app/docs/mcp + +# Linear ships a remote MCP server with native OAuth 2.1 + Dynamic Client +# Registration over Streamable HTTP. Hermes's MCP client + mcp_oauth_manager +# handle discovery, PKCE, token exchange, and refresh — nothing to install +# locally. +transport: + type: http + url: https://mcp.linear.app/mcp + +auth: + type: oauth + # No `provider:` — this is native MCP OAuth (case 1), not a third-party + # provider like Google. The MCP client triggers the browser flow on the + # first probe / first connect. + +# Tool selection at install time: +# Linear's MCP server exposes a moderate-sized tool surface (find/get/list + +# create/update across issues/projects/comments). We leave `default_enabled` +# unset so the install-time checklist starts with everything pre-checked — +# users prune what they don't want. +# +# If you want to encode a curated subset here once it stabilizes, list the +# tool names under `tools.default_enabled`. Probe failure would then apply +# that list directly. + +post_install: | + On first connection, Hermes will open a browser to authenticate with Linear. + After auth, restart your Hermes session so the Linear tools are loaded. + + You can re-run the tool checklist any time with: + hermes mcp configure linear diff --git a/optional-mcps/n8n/manifest.yaml b/optional-mcps/n8n/manifest.yaml new file mode 100644 index 0000000000000..468efd1ddafbf --- /dev/null +++ b/optional-mcps/n8n/manifest.yaml @@ -0,0 +1,77 @@ +# Nous-approved MCP catalog entry. +# Presence in this directory = approval. Merged via PR review. +# +# Schema version 1. +manifest_version: 1 + +name: n8n +description: Manage and inspect n8n workflows from Hermes (stdio bridge, no public port). +source: https://github.com/CyberSamuraiX/hermes-n8n-mcp + +# How to launch the server once installed. The keys here map 1:1 to the +# `mcp_servers.` block written into ~/.hermes/config.yaml by the +# existing `_save_mcp_server()` helper in hermes_cli/mcp_config.py. +transport: + type: stdio + # For git-installed servers, ${INSTALL_DIR} is substituted at install time + # with the path the catalog cloned the repo into. The catalog never + # auto-updates: the user re-runs `hermes mcp install official/n8n` to + # refresh. + command: "${INSTALL_DIR}/.venv/bin/python" + args: + - "${INSTALL_DIR}/server.py" + +# Optional install step. Omit for npm/uvx servers where transport.command +# is the install (`npx -y package`). Use for repos that need a local clone +# + dependency install. +install: + type: git + url: https://github.com/CyberSamuraiX/hermes-n8n-mcp.git + # Pin to a commit/tag. Required — manifests do not float HEAD. + ref: main + # Bootstrap commands run inside the cloned directory after clone. + bootstrap: + - "python3 -m venv .venv" + - ".venv/bin/pip install -r requirements.txt" + +# Authentication. Three shapes: +# type: api_key — prompt for env vars, write to ~/.hermes/.env +# type: oauth — provider-mediated or remote MCP native OAuth (case 1/2) +# type: none — no credentials needed +auth: + type: api_key + env: + - name: N8N_BASE_URL + prompt: "n8n instance URL" + default: "http://127.0.0.1:5678" + required: true + secret: false + - name: N8N_API_KEY + prompt: "n8n API key (generate under Settings → API)" + required: true + secret: true + +# Tool selection at install time: +# n8n's bridge exposes 11 tools. Mutating ones (activate/deactivate, docker +# container_logs) are pruned from the default so a user who installs casually +# gets a read-mostly safe surface. Users see the full list in the install-time +# checklist and can opt into the mutating tools per their threat model. +tools: + default_enabled: + - health + - list_workflows + - get_workflow + - find_workflows + - list_executions + - get_execution + - recent_failures + - export_workflow + +post_install: | + The n8n bridge expects to talk to a running n8n instance over the URL you + provided. Generate an API key in n8n under Settings → API. + + Workflow activate/deactivate calls are real mutations against your live n8n. + Treat them carefully. + + Start a new Hermes session to load the n8n tools. diff --git a/plugins/dashboard_auth/nous/__init__.py b/plugins/dashboard_auth/nous/__init__.py new file mode 100644 index 0000000000000..c9d4b744cf050 --- /dev/null +++ b/plugins/dashboard_auth/nous/__init__.py @@ -0,0 +1,582 @@ +"""NousDashboardAuthProvider — Nous Portal OAuth (authorization-code + PKCE). + +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 a client_id is configured — either via ``config.yaml`` or +via the Portal-injected env var — so loopback / ``--insecure`` operators +are unaffected. + +Configuration surfaces (env wins over config.yaml when set non-empty): + + ``config.yaml`` — canonical surface:: + + dashboard: + oauth: + client_id: agent:{agent_instance_id} # required + portal_url: https://portal.example # optional + + 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: + + - client_id is per-instance (``agent:{instance_id}``); the suffix is also + cross-checked against the token's ``agent_instance_id`` claim as + defense-in-depth. + - scope is ``agent_dashboard:access`` only (no OIDC scopes). + - tokens are RS256 JWTs verified against ``/.well-known/jwks.json``; + JWKS is cached for 5 minutes. + - V1 has NO refresh tokens — ``refresh_session`` always raises + ``RefreshExpiredError`` so the middleware redirects to ``/auth/login``. + - audience claim is the bare ``client_id`` (no ``hermes-cli:`` prefix). + - tolerant ``oauth_contract_version`` check: missing → warn + proceed; + present and ``!= 1`` → refuse. + +The cookie payload returned by ``start_login`` stashes the PKCE +``code_verifier`` and the OAuth ``state`` parameter for the +``/auth/callback`` handler to retrieve. The auth-route layer is the owner +of cookie names; this provider just hands back ``{"code_verifier": …, +"state": …}`` and the route serializes those into the ``hermes_session_pkce`` +cookie. + +Forward compatibility: if a future Portal contract starts issuing refresh +tokens, ``complete_login`` already captures the value forward-compatibly +(populates ``Session.refresh_token``). Wiring the RT cookie back into the +middleware's near-expiry refresh path lives in the host application, not +here. + +Skip reasons: + The plugin exposes a module-level ``LAST_SKIP_REASON`` that the gate's + fail-closed branch reads to surface a useful operator error message + ("Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …") instead of the bare "no + providers registered" the gate would otherwise emit. +""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +import os +import secrets +import urllib.parse +from typing import Any, Dict, Optional + +import httpx + +from hermes_cli.dashboard_auth import ( + DashboardAuthProvider, + InvalidCodeError, + LoginStart, + ProviderError, + RefreshExpiredError, + Session, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- + +# Production Portal URL. Override via HERMES_DASHBOARD_PORTAL_URL for +# staging (portal.rewbs.uk) or a custom deployment. Contract docs name +# this as the production issuer. +_DEFAULT_PORTAL_URL = "https://portal.nousresearch.com" + + +# --------------------------------------------------------------------------- +# Skip-reason channel for operator-friendly error messages +# --------------------------------------------------------------------------- +# +# When the plugin loads but refuses to register (missing / malformed +# env vars), the auth gate downstream just sees "zero providers" and +# emits a generic "install a provider" error. That's misleading for the +# common case where the provider IS installed but mis-configured. The +# plugin writes the *specific* reason to this module-level slot; the +# gate reads it back when building its fail-closed SystemExit message. +# +# Cleared on every register() call so repeated dashboard starts in the +# same process (tests, hot-reload) don't leak stale reasons. + +LAST_SKIP_REASON: str = "" + + +# --------------------------------------------------------------------------- +# Contract constants +# --------------------------------------------------------------------------- + +# Contract C3: scope name for the dashboard flow. +_SCOPE = "agent_dashboard:access" + +# Contract C11: emitted claim should equal 1; tolerant (warn) if missing. +_EXPECTED_CONTRACT_VERSION = 1 + +# Contract C7: JWKS Cache-Control max-age=300. +_JWKS_CACHE_SECONDS = 300 + +# httpx timeout for the token endpoint POST. +_TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _b64url_no_pad(raw: bytes) -> str: + """Base64url-encode without ``=`` padding (RFC 7636 §4).""" + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +# --------------------------------------------------------------------------- +# Provider +# --------------------------------------------------------------------------- + + +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 point already filters, but + # the provider should never be constructible with a malformed id. + raise ValueError( + "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 is lazily imported so plugin discovery doesn't pay the + # crypto-import cost when the provider isn't activated. + self._jwks_client: Any = None + + # ---- public API (DashboardAuthProvider) ------------------------------- + + def start_login(self, *, redirect_uri: str) -> LoginStart: + self._validate_redirect_uri(redirect_uri) + + code_verifier = _b64url_no_pad(secrets.token_bytes(64)) # ~86 chars + code_challenge = _b64url_no_pad( + hashlib.sha256(code_verifier.encode("ascii")).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", + } + redirect_url = f"{self._authorize_url}?{urllib.parse.urlencode(params)}" + # The auth-route layer expects ``cookie_payload[\"hermes_session_pkce\"]`` + # as a single semicolon-delimited string of ``key=value`` segments, + # matching the stub provider's shape. The route handler prepends + # ``provider=`` so the callback knows which plugin to dispatch to. + cookie_payload = { + "hermes_session_pkce": f"state={state};verifier={code_verifier}", + } + return LoginStart(redirect_url=redirect_url, cookie_payload=cookie_payload) + + def complete_login( + self, + *, + code: str, + state: str, + code_verifier: str, + redirect_uri: str, + ) -> Session: + # ``state`` is verified by the auth-route layer before this call + # (it checks the cookie-stashed state matches the query-param state); + # we just receive it for symmetry with the protocol. Nous Portal + # doesn't re-check state at the token endpoint, so we ignore it here. + _ = state + + 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=_TOKEN_ENDPOINT_TIMEOUT_SEC, + ) + 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 as 400 with an OAuth-shaped JSON error envelope. + body = self._parse_json_body(response) + 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]!r}" + ) + + payload = self._parse_json_body(response) + access_token = payload.get("access_token") + if not access_token or not isinstance(access_token, str): + raise ProviderError("Portal token response missing access_token") + + token_type = str(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) + # Contract V1: no refresh token expected. If a future Portal ever + # adds one, capture it forward-compatibly. + refresh_token = payload.get("refresh_token") or "" + if not isinstance(refresh_token, str): + refresh_token = "" + return self._session_from_claims(access_token, refresh_token, claims) + + def refresh_session(self, *, refresh_token: str) -> Session: + # Contract V1 has no refresh tokens — always force re-auth. If a + # future Portal contract starts issuing them, this method needs to + # be re-implemented; until then it's an unconditional refusal. + raise RefreshExpiredError( + "Nous Portal does not issue refresh tokens in OAuth contract v1; " + "user must re-authenticate via /auth/login." + ) + + def verify_session(self, *, access_token: str) -> Optional[Session]: + # Contract: returns None on expiry/invalidity (middleware then + # triggers redirect-to-login since refresh_session can never succeed + # under V1); raises ProviderError if the IDP is unreachable. + try: + claims = self._verify_jwt(access_token) + except InvalidCodeError: + # Expired/invalid token — middleware contract is None, not raise. + return None + except ProviderError: + # JWKS unreachable, etc. Bubble up so middleware emits 503. + raise + # verify_session has no access to the original refresh_token; pass + # "" because in contract V1 there is none anyway. + return self._session_from_claims(access_token, "", claims) + + def revoke_session(self, *, refresh_token: str) -> None: + # Contract V1: no refresh tokens to revoke, and no Portal revocation + # endpoint documented for dashboard tokens. Logout is purely + # client-side cookie clearing; this is a best-effort no-op. + _ = refresh_token + return None + + # ---- internals -------------------------------------------------------- + + def _validate_redirect_uri(self, redirect_uri: str) -> None: + """Surface obviously-broken redirect_uris before bouncing to Portal. + + The Portal-side check (``agent-redirect-uri.ts``) is authoritative; + this is a fast-fail for the common operator-error case. + """ + 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( + "redirect_uri may only use http:// for localhost/127.0.0.1, " + f"got {redirect_uri!r}" + ) + if not parsed.path or not parsed.path.endswith("/auth/callback"): + raise ProviderError( + "redirect_uri path must end with '/auth/callback', " + f"got {redirect_uri!r}" + ) + + def _parse_json_body(self, response: httpx.Response) -> Dict[str, Any]: + ctype = response.headers.get("content-type", "") + if not ctype.startswith("application/json"): + return {} + try: + body = response.json() + except ValueError: + return {} + return body if isinstance(body, dict) else {} + + def _get_jwks_client(self) -> Any: + if self._jwks_client is None: + from jwt import PyJWKClient # lazy import + + self._jwks_client = PyJWKClient( + self._jwks_url, + cache_keys=True, + lifespan=_JWKS_CACHE_SECONDS, + ) + return self._jwks_client + + def _verify_jwt(self, access_token: str) -> Dict[str, Any]: + # Lazy import — keeps startup fast for operators who never trigger + # the gated path. + import jwt + + try: + signing_key = self._get_jwks_client().get_signing_key_from_jwt( + access_token + ) + except jwt.PyJWKClientError as exc: + raise ProviderError(f"JWKS lookup failed: {exc}") from exc + except Exception as exc: # pragma: no cover - defensive + raise ProviderError(f"JWKS lookup failed: {exc!r}") from exc + + try: + claims = jwt.decode( + access_token, + signing_key.key, + algorithms=["RS256"], + # Contract C2: aud is the bare client_id. + audience=self._client_id, + # Contract: issuer is the Portal base URL. + issuer=self._portal_url, + options={"require": ["exp", "iat", "aud", "iss", "sub"]}, + ) + except jwt.ExpiredSignatureError as exc: + # verify_session() catches this and returns None per protocol. + raise InvalidCodeError(f"access token expired: {exc}") from exc + except jwt.InvalidTokenError as exc: + # Surface the actual claim values that failed verification so + # operators don't have to dig into the JWT to debug config drift + # between HERMES_DASHBOARD_PORTAL_URL / HERMES_DASHBOARD_OAUTH_CLIENT_ID + # and what Portal is actually emitting. Decoding without verification + # is safe here: we've already failed to verify, and we never trust + # these values — they're surfaced for diagnostics only. + details = "" + try: + unverified = jwt.decode( + access_token, + options={"verify_signature": False, "verify_exp": False}, + ) + details = ( + f" [token iss={unverified.get('iss')!r} " + f"aud={unverified.get('aud')!r}; " + f"expected iss={self._portal_url!r} " + f"aud={self._client_id!r}]" + ) + except Exception: + pass + raise ProviderError( + f"access token verification failed: {exc}{details}" + ) from exc + + self._check_agent_instance_id(claims) + self._check_contract_version(claims) + return claims + + def _check_agent_instance_id(self, claims: Dict[str, Any]) -> None: + """Contract C9: cross-check agent_instance_id against our config.""" + token_instance_id = claims.get("agent_instance_id") + if token_instance_id is None: + # Tolerated — the claim is documented as "should" not "must". + # Our audience check on the bare client_id already binds the + # token to this instance; agent_instance_id is defense-in-depth. + return + if 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}" + ) + + def _check_contract_version(self, claims: Dict[str, Any]) -> None: + """Contract C11 — tolerant treatment per OQ-C2.""" + contract_version = claims.get("oauth_contract_version") + if contract_version is None: + logger.warning( + "Nous Portal token missing oauth_contract_version claim " + "(contract says it should be %d); proceeding anyway.", + _EXPECTED_CONTRACT_VERSION, + ) + return + if contract_version != _EXPECTED_CONTRACT_VERSION: + raise ProviderError( + f"unsupported oauth_contract_version={contract_version!r}, " + f"expected {_EXPECTED_CONTRACT_VERSION}" + ) + + def _session_from_claims( + self, + access_token: str, + refresh_token: str, + claims: Dict[str, Any], + ) -> Session: + # Contract C4: no email / display_name in tokens. AuthWidget will + # show user_id (truncated). Session fields kept for forward-compat. + user_id = str(claims.get("sub", "")) + if not user_id: + raise ProviderError("token missing 'sub' (user_id) claim") + return Session( + user_id=user_id, + email="", + display_name="", + org_id=str(claims.get("org_id") or ""), + provider=self.name, + expires_at=int(claims["exp"]), + access_token=access_token, + refresh_token=refresh_token, + ) + + +# --------------------------------------------------------------------------- +# Plugin entry point +# --------------------------------------------------------------------------- + + +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 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. The + reason mentions BOTH configuration surfaces so operators don't + guess wrong about which one to populate. + + 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 = _resolve_client_id() + portal_url = _resolve_portal_url() + + if not client_id: + LAST_SKIP_REASON = ( + "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 + + if not client_id.startswith("agent:"): + LAST_SKIP_REASON = ( + f"HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id!r} doesn't match " + f"the contract shape 'agent:{{instance_id}}'. The Nous Portal " + f"provisions this value at deploy time; check your Fly app's " + f"secrets or override with the value from the Portal admin UI." + ) + logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON) + return + + try: + provider = NousDashboardAuthProvider( + client_id=client_id, portal_url=portal_url + ) + except ValueError as exc: + LAST_SKIP_REASON = f"NousDashboardAuthProvider construction failed: {exc}" + logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON) + return + + ctx.register_dashboard_auth_provider(provider) + logger.info( + "dashboard-auth-nous: registered provider (client_id=%s, portal=%s)", + client_id, + portal_url, + ) diff --git a/plugins/dashboard_auth/nous/plugin.yaml b/plugins/dashboard_auth/nous/plugin.yaml new file mode 100644 index 0000000000000..c395c0c916502 --- /dev/null +++ b/plugins/dashboard_auth/nous/plugin.yaml @@ -0,0 +1,7 @@ +name: nous +version: 1.0.0 +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: + - HERMES_DASHBOARD_OAUTH_CLIENT_ID diff --git a/plugins/image_gen/openai-codex/__init__.py b/plugins/image_gen/openai-codex/__init__.py index ab524dbdd7591..339e390be1f54 100644 --- a/plugins/image_gen/openai-codex/__init__.py +++ b/plugins/image_gen/openai-codex/__init__.py @@ -19,6 +19,7 @@ from __future__ import annotations +import json import logging from typing import Any, Dict, List, Optional, Tuple @@ -142,39 +143,18 @@ def _read_codex_access_token() -> Optional[str]: return None -def _build_codex_client(): - """Return an OpenAI client pointed at the ChatGPT/Codex backend, or None.""" - token = _read_codex_access_token() - if not token: - return None - try: - import openai - from agent.auxiliary_client import _codex_cloudflare_headers - - return openai.OpenAI( - api_key=token, - base_url=_CODEX_BASE_URL, - default_headers=_codex_cloudflare_headers(token), - ) - except Exception as exc: - logger.debug("Could not build Codex image client: %s", exc) - return None - - -def _collect_image_b64(client: Any, *, prompt: str, size: str, quality: str) -> Optional[str]: - """Stream a Codex Responses image_generation call and return the b64 image.""" - image_b64: Optional[str] = None - - with client.responses.stream( - model=_CODEX_CHAT_MODEL, - store=False, - instructions=_CODEX_INSTRUCTIONS, - input=[{ +def _build_responses_payload(*, prompt: str, size: str, quality: str) -> Dict[str, Any]: + """Build the Codex Responses request body for an image_generation call.""" + return { + "model": _CODEX_CHAT_MODEL, + "store": False, + "instructions": _CODEX_INSTRUCTIONS, + "input": [{ "type": "message", "role": "user", "content": [{"type": "input_text", "text": prompt}], }], - tools=[{ + "tools": [{ "type": "image_generation", "model": API_MODEL, "size": size, @@ -183,33 +163,114 @@ def _collect_image_b64(client: Any, *, prompt: str, size: str, quality: str) -> "background": "opaque", "partial_images": 1, }], - tool_choice={ + "tool_choice": { "type": "allowed_tools", "mode": "required", "tools": [{"type": "image_generation"}], }, - ) as stream: - for event in stream: - event_type = getattr(event, "type", "") - if event_type == "response.output_item.done": - item = getattr(event, "item", None) - if getattr(item, "type", None) == "image_generation_call": - result = getattr(item, "result", None) - if isinstance(result, str) and result: - image_b64 = result - elif event_type == "response.image_generation_call.partial_image": - partial = getattr(event, "partial_image_b64", None) - if isinstance(partial, str) and partial: - image_b64 = partial - final = stream.get_final_response() - - # Final-response sweep covers the case where the stream finished before - # we observed the ``output_item.done`` event for the image call. - for item in getattr(final, "output", None) or []: - if getattr(item, "type", None) == "image_generation_call": - result = getattr(item, "result", None) + "stream": True, + } + + +def _extract_image_b64(value: Any) -> Optional[str]: + """Return the newest image b64 embedded in a Responses event payload.""" + found: Optional[str] = None + if isinstance(value, dict): + if value.get("type") == "image_generation_call": + result = value.get("result") if isinstance(result, str) and result: - image_b64 = result + found = result + partial = value.get("partial_image_b64") + if isinstance(partial, str) and partial: + found = partial + for child in value.values(): + nested = _extract_image_b64(child) + if nested: + found = nested + elif isinstance(value, list): + for child in value: + nested = _extract_image_b64(child) + if nested: + found = nested + return found + + +def _iter_sse_json(response: Any): + """Yield JSON payloads from an SSE response without OpenAI SDK parsing. + + The ChatGPT/Codex backend can emit image-generation events newer than the + pinned Python SDK understands. Parsing raw SSE keeps this provider tolerant + of those event-shape changes. + """ + event_name: Optional[str] = None + data_lines: List[str] = [] + + def flush(): + nonlocal event_name, data_lines + if not data_lines: + event_name = None + return None + raw = "\n".join(data_lines).strip() + event = event_name + event_name = None + data_lines = [] + if not raw or raw == "[DONE]": + return None + payload = json.loads(raw) + if isinstance(payload, dict) and event and "type" not in payload: + payload["type"] = event + return payload + + for line in response.iter_lines(): + if isinstance(line, bytes): + line = line.decode("utf-8", errors="replace") + line = str(line) + if line == "": + payload = flush() + if payload is not None: + yield payload + continue + if line.startswith(":"): + continue + if line.startswith("event:"): + event_name = line[len("event:"):].strip() + elif line.startswith("data:"): + data_lines.append(line[len("data:"):].lstrip()) + + payload = flush() + if payload is not None: + yield payload + + +def _collect_image_b64(token: str, *, prompt: str, size: str, quality: str) -> Optional[str]: + """Stream a Codex Responses image_generation call and return the b64 image.""" + import httpx + from agent.auxiliary_client import _codex_cloudflare_headers + + headers = _codex_cloudflare_headers(token) + headers.update({ + "Accept": "text/event-stream", + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }) + payload = _build_responses_payload(prompt=prompt, size=size, quality=quality) + timeout = httpx.Timeout(300.0, connect=30.0, read=300.0, write=30.0, pool=30.0) + + image_b64: Optional[str] = None + with httpx.Client(timeout=timeout, headers=headers) as http: + with http.stream("POST", f"{_CODEX_BASE_URL}/responses", json=payload) as response: + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + exc.response.read() + body = exc.response.text[:500] + raise RuntimeError( + f"Codex Responses API returned HTTP {exc.response.status_code}: {body}" + ) from exc + for event in _iter_sse_json(response): + found = _extract_image_b64(event) + if found: + image_b64 = found return image_b64 @@ -234,7 +295,7 @@ def is_available(self) -> bool: if not _read_codex_access_token(): return False try: - import openai # noqa: F401 + import httpx # noqa: F401 except ImportError: return False return True @@ -295,10 +356,10 @@ def generate( ) try: - import openai # noqa: F401 + import httpx # noqa: F401 except ImportError: return error_response( - error="openai Python package not installed (pip install openai)", + error="httpx Python package not installed (pip install httpx)", error_type="missing_dependency", provider="openai-codex", aspect_ratio=aspect, @@ -307,10 +368,13 @@ def generate( tier_id, meta = _resolve_model() size = _SIZES.get(aspect, _SIZES["square"]) - client = _build_codex_client() - if client is None: + token = _read_codex_access_token() + if not token: return error_response( - error="Could not initialize Codex image client", + error=( + "No Codex/ChatGPT OAuth credentials available. Run " + "`hermes auth codex` (or `hermes setup` → Codex) to sign in." + ), error_type="auth_required", provider="openai-codex", model=tier_id, @@ -320,7 +384,7 @@ def generate( try: b64 = _collect_image_b64( - client, + token, prompt=prompt, size=size, quality=meta["quality"], diff --git a/plugins/model-providers/ai-gateway/__init__.py b/plugins/model-providers/ai-gateway/__init__.py deleted file mode 100644 index 9d01ab9824605..0000000000000 --- a/plugins/model-providers/ai-gateway/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Vercel AI Gateway provider profile. - -AI Gateway routes to multiple backends. Hermes sends attribution -headers and full reasoning config passthrough. -""" - -from typing import Any - -from providers import register_provider -from providers.base import ProviderProfile - - -class VercelAIGatewayProfile(ProviderProfile): - """Vercel AI Gateway — attribution headers + reasoning passthrough.""" - - def build_api_kwargs_extras( - self, - *, - reasoning_config: dict | None = None, - supports_reasoning: bool = True, - **ctx: Any, - ) -> tuple[dict[str, Any], dict[str, Any]]: - extra_body: dict[str, Any] = {} - if supports_reasoning and reasoning_config is not None: - extra_body["reasoning"] = dict(reasoning_config) - elif supports_reasoning: - extra_body["reasoning"] = {"enabled": True, "effort": "medium"} - return extra_body, {} - - -vercel = VercelAIGatewayProfile( - name="ai-gateway", - aliases=("vercel", "vercel-ai-gateway", "ai_gateway", "aigateway"), - env_vars=("AI_GATEWAY_API_KEY",), - base_url="https://ai-gateway.vercel.sh/v1", - default_headers={ - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-Title": "Hermes Agent", - }, - default_aux_model="google/gemini-3-flash", -) - -register_provider(vercel) diff --git a/plugins/model-providers/ai-gateway/plugin.yaml b/plugins/model-providers/ai-gateway/plugin.yaml deleted file mode 100644 index 252ca42ed6c1f..0000000000000 --- a/plugins/model-providers/ai-gateway/plugin.yaml +++ /dev/null @@ -1,5 +0,0 @@ -name: ai-gateway-provider -kind: model-provider -version: 1.0.0 -description: Vercel AI Gateway -author: Nous Research diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index efe0b5d1de70c..0ffe1abac7a2e 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -68,6 +68,26 @@ from tools.url_safety import is_safe_url +def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[str]: + """Return discord.py's bundled Windows opus DLL path when present.""" + if sys.platform != "win32": + return None + discord_module = discord if discord_module is None else discord_module + if discord_module is None: + return None + + opus_module = getattr(discord_module, "opus", None) + opus_file = getattr(opus_module, "__file__", None) + if not opus_file: + return None + + target = "x64" if struct.calcsize("P") * 8 > 32 else "x86" + bundled = _Path(opus_file).resolve().parent / "bin" / f"libopus-0.{target}.dll" + if bundled.is_file(): + return str(bundled) + return None + + def _clean_discord_id(entry: str) -> str: """Strip common prefixes from a Discord user ID or username entry. @@ -403,7 +423,13 @@ def _on_packet(self, data: bytes): self._buffers[ssrc].extend(pcm) self._last_packet_time[ssrc] = time.monotonic() except Exception as e: - logger.debug("Opus decode error for SSRC %s: %s", ssrc, e) + with self._lock: + self._decoders.pop(ssrc, None) + logger.debug( + "Opus decode error for SSRC %s; reset decoder: %s", + ssrc, + e, + ) return # ------------------------------------------------------------------ @@ -604,7 +630,13 @@ async def connect(self) -> bool: # Load opus codec for voice channel support if not discord.opus.is_loaded(): import ctypes.util + opus_candidates = [] + bundled_opus = _find_discord_windows_bundled_opus(discord) + if bundled_opus: + opus_candidates.append(bundled_opus) opus_path = ctypes.util.find_library("opus") + if opus_path: + opus_candidates.append(opus_path) # ctypes.util.find_library fails on macOS with Homebrew-installed libs, # so fall back to known Homebrew paths if needed. if not opus_path: @@ -615,11 +647,13 @@ async def connect(self) -> bool: if sys.platform == "darwin": for _hp in _homebrew_paths: if os.path.isfile(_hp): - opus_path = _hp + opus_candidates.append(_hp) break - if opus_path: + for opus_path in opus_candidates: try: discord.opus.load_opus(opus_path) + if discord.opus.is_loaded(): + break except Exception: logger.warning("Opus codec found at %s but failed to load", opus_path) if not discord.opus.is_loaded(): @@ -6163,7 +6197,7 @@ def register(ctx) -> None: check_fn=check_discord_requirements, is_connected=_is_connected, required_env=["DISCORD_BOT_TOKEN"], - install_hint="pip install 'hermes-agent[discord]'", + install_hint="pip install 'hermes-agent[messaging]'", # Interactive setup wizard — replaces the central # hermes_cli/setup.py::_setup_discord function. Same shape as Teams. setup_fn=interactive_setup, diff --git a/plugins/security-guidance/LICENSE b/plugins/security-guidance/LICENSE new file mode 100644 index 0000000000000..d645695673349 --- /dev/null +++ b/plugins/security-guidance/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/security-guidance/NOTICE b/plugins/security-guidance/NOTICE new file mode 100644 index 0000000000000..cb5067c3d0850 --- /dev/null +++ b/plugins/security-guidance/NOTICE @@ -0,0 +1,30 @@ +Hermes Agent security-guidance plugin +===================================== + +This plugin (plugins/security-guidance/) includes work originally +published in the claude-plugins-official repository by Anthropic, PBC., +licensed under the Apache License, Version 2.0. + + Source: https://github.com/anthropics/claude-plugins-official + Subpath: plugins/security-guidance/hooks/patterns.py + Commit: 0bde168 (2026-05-26) + License: Apache License 2.0 (see LICENSE in this directory) + +Forked content +-------------- + +The file patterns.py in this directory is a verbatim copy of the upstream +patterns.py at the commit above, with a modified module docstring noting +this attribution. The pattern data — 25 regex/substring rules covering +unsafe deserialization, command injection, XSS sinks, crypto footguns, +XXE, GitHub Actions injection, and TLS-verification disablement — is +unmodified. + +Original work +------------- + +The Hermes-side plugin glue code (__init__.py, plugin.yaml, README.md, +tests) is original work by NousResearch and is licensed under the MIT +License that applies to the rest of the hermes-agent project, except +where it imports from patterns.py — that import does not change the +license of either file. diff --git a/plugins/security-guidance/README.md b/plugins/security-guidance/README.md new file mode 100644 index 0000000000000..ca5a8e367180c --- /dev/null +++ b/plugins/security-guidance/README.md @@ -0,0 +1,88 @@ +# security-guidance + +Pattern-matched security warnings for code the agent writes. When the agent +calls `write_file`, `patch`, or `skill_manage` with content that matches a +known-dangerous code pattern (eval, pickle.load, yaml.load, os.system, +subprocess with `shell=True`, `dangerouslySetInnerHTML`, `verify=False`, ECB +mode, GitHub Actions `${{ github.event.* }}` injection, `torch.load` without +`weights_only=True`, ...), the plugin appends a warning to the tool's result. +The file is still written; the model sees the warning in the next turn and +can fix the code or briefly document why the construct is safe. + +This is layer 1 of Anthropic's `security-guidance` plugin design — a fast +first-pass that runs locally with zero LLM tokens spent. Layers 2 and 3 (LLM +diff review on turn end, agentic commit review) are not ported; the agent +can already run those kinds of reviews on demand via `delegate_task`. + +## Coverage (25 rules) + +The pattern set is forked verbatim from Anthropic's `claude-plugins-official` +under Apache-2.0. Categories: + +| Category | Rules | +|---|---| +| Unsafe deserialization | `pickle.load`, `cPickle/cloudpickle/dill.load`, `marshal.loads`, `shelve.open`, `yaml.load`, `yaml.unsafe_load`, `torch.load` (without `weights_only=True`), `joblib.load`, `pandas.read_pickle`, `numpy.load(allow_pickle=True)` | +| Command injection | `os.system`, `subprocess(..., shell=True)`, JS `child_process.exec`, Go `exec.Command("sh"...)` | +| Code injection | `eval(`, JS `new Function(...)` | +| XSS sinks | `.innerHTML =`, `.outerHTML =`, `.insertAdjacentHTML(`, `document.write`, React `dangerouslySetInnerHTML` | +| Crypto footguns | AES ECB mode, Node `crypto.createCipher` (no IV), TLS verification disabled (`verify=False`, `rejectUnauthorized: false`, `InsecureSkipVerify: true`, ...) | +| XXE | `xml.etree`, `minidom`, `xml.sax` without `defusedxml` | +| Supply chain | `"}, + follow_redirects=False, + ) + assert r.status_code == 302 + assert "