feat: support explicit api_key in config for Codex provider - #2
Conversation
032445f to
066aa4e
Compare
When config.yaml contains an explicit `api_key` under the openai-codex model section, use it directly as Authorization: Bearer instead of falling through to the OAuth hermes-auth-store. This enables hosted deployments (e.g. Clawdi) to pre-configure a proxy API key without requiring interactive `hermes auth`. Priority: config api_key > OAuth auth-store > env-var fallback. Co-Authored-By: Hang Yin Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
066aa4e to
967a261
Compare
|
|
When config.yaml contains an explicit `api_key` under the openai-codex model section, use it directly as Authorization: Bearer instead of falling through to the OAuth hermes-auth-store. This enables hosted deployments (e.g. Clawdi) to pre-configure a proxy API key without requiring interactive `hermes auth`. Priority: config api_key > OAuth auth-store > env-var fallback. Co-Authored-By: Hang Yin Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ed use Two narrow additive changes that only affect deployments that have already opted in via PR #2's Direct config path. Single-user CLI flows and OAuth users are byte-identical to before. ## auxiliary_client.py: codex aux direct config `_try_codex()` learned a new entry point — `_try_codex_from_config()` — that mirrors the Direct config path runtime_provider.py added in PR #2 for the main inference flow. When `model.provider == "openai-codex"` AND `model.api_key` is set, build a Codex auxiliary client straight from those values instead of going through the device-code OAuth flow. This unblocks every auxiliary task (vision, web_extract, compression, session_search, skills_hub, approval, mcp, flush_memories) for reverse-proxied deployments where there's no local OAuth state. The existing pool / `_read_codex_access_token` chain is untouched and runs unchanged whenever `api_key` is empty, so single-user CLI workflows see no behavior change. The auxiliary model defaults to `model.default` (the operator's main model) rather than `_CODEX_AUX_MODEL`. In direct-config mode the only models guaranteed to exist are the ones the operator's reverse proxy serves, which is what `model.default` describes; `_CODEX_AUX_MODEL` is hardcoded for ChatGPT-backed Codex compatibility and may not exist on a custom endpoint. Per-task `auxiliary.{task}.model` overrides still win because the caller composes them via `model or default`. Net result: operators only have to set `model.default + model.base_url + model.api_key` once. All eight auxiliary tasks pick the same model automatically — same zero-config UX an OAuth user gets, just sourced from explicit config instead of OAuth tokens. ## web_server.py: plain `value` for non-secret env vars `GET /api/env` now returns a `value` field alongside `redacted_value`. For env vars marked `password: False` in OPTIONAL_ENV_VARS (allow-lists, mode flags, account IDs, etc.), `value` is the plain on-disk string; for `password: True` fields it stays None. Backward compatible: `redacted_value` is unchanged so existing clients keep working. Dashboards that want to round-trip non-secret fields through form inputs can now read `value` instead of forcing the user to retype on every edit. Password-flagged fields still require the rate-limited `/api/env/reveal` endpoint with audit logging — no change there.
…#13354) Classic-CLI /steer typed during an active agent run was queued through self._pending_input alongside ordinary user input. process_loop, which drains that queue, is blocked inside self.chat() for the entire run, so the queued command was not pulled until AFTER _agent_running had flipped back to False — at which point process_command() took the idle fallback ("No agent running; queued as next turn") and delivered the steer as an ordinary next-turn user message. From Utku's bug report on PR NousResearch#13205: mid-run /steer arrived minutes later at the end of the turn as a /queue-style message, completely defeating its purpose. Fix: add _should_handle_steer_command_inline() gating — when _agent_running is True and the user typed /steer, dispatch process_command(text) directly from the prompt_toolkit Enter handler on the UI thread instead of queueing. This mirrors the existing _should_handle_model_command_inline() pattern for /model and is safe because agent.steer() is thread-safe (uses _pending_steer_lock, no prompt_toolkit state mutation, instant return). No changes to the idle-path behavior: /steer typed with no active agent still takes the normal queue-and-drain route so the fallback "No agent running; queued as next turn" message is preserved. Validation: - 7 new unit tests in tests/cli/test_cli_steer_busy_path.py covering the detector, dispatch path, and idle-path control behavior. - All 21 existing tests in tests/run_agent/test_steer.py still pass. - Live PTY end-to-end test with real agent + real openrouter model: 22:36:22 API call #1 (model requested execute_code) 22:36:26 ENTER FIRED: agent_running=True, text='/steer ...' 22:36:26 INLINE STEER DISPATCH fired 22:36:43 agent.log: 'Delivered /steer to agent after tool batch' 22:36:44 API call #2 included the steer; response contained marker Same test on the tip of main without this fix shows the steer landing as a new user turn ~20s after the run ended.
The MCP circuit breaker previously had no path back to the closed state: once _server_error_counts[srv] reached _CIRCUIT_BREAKER_THRESHOLD the gate short-circuited every subsequent call, so the only reset path (on successful call) was unreachable. A single transient 3-failure blip (bad network, server restart, expired token) permanently disabled every tool on that MCP server for the rest of the agent session. Introduce a classic closed/open/half-open state machine: - Track a per-server breaker-open timestamp in _server_breaker_opened_at alongside the existing failure count. - Add _CIRCUIT_BREAKER_COOLDOWN_SEC (60s). Once the count reaches threshold, calls short-circuit for the cooldown window. - After the cooldown elapses, the *next* call falls through as a half-open probe that actually hits the session. Success resets the breaker via _reset_server_error; failure re-bumps the count via _bump_server_error, which re-stamps the open timestamp and re-arms the cooldown. The error message now includes the live failure count and an "Auto-retry available in ~Ns" hint so the model knows the breaker will self-heal rather than giving up on the tool for the whole session. Covers tests 1 (half-opens after cooldown) and 2 (reopens on probe failure); test 3 (cleared on reconnect) still fails pending fix #2.
* ci(nix): auto-fix stale npm hashes on push to main When a PR merges to main with updated package-lock.json or package.json in ui-tui/ or web/, the new auto-fix-main job detects stale npmDepsHash values and pushes a fix commit directly to main. This eliminates the recurring manual hash-bump PRs (NousResearch#15420, NousResearch#15314, NousResearch#15272, NousResearch#15244) by reusing the existing fix-lockfiles --apply pipeline. The fix commit only touches nix/*.nix files, which are outside the push path filter (package-lock.json / package.json), so it cannot re-trigger itself. Closes NousResearch#15314 * fix(ci): use GitHub App token for auto-fix-main push GITHUB_TOKEN commits are invisible to workflow triggers (GitHub's infinite-loop prevention). The auto-fix-main job pushes directly to main, so the fix commit never triggered downstream nix.yml verification. Mint a short-lived token via the repo's GitHub App (daimon-nous, APP_ID + APP_PRIVATE_KEY secrets) so the push is treated as a real event and nix.yml fires to verify the corrected hashes. Tested via workflow_dispatch dry-run: app token minted successfully, checkout with app token succeeded, fix job correctly gated. Resolves review feedback from Bugbot (r3144569551). * ci(nix): rename lockfile check job for required status check Rename 'check' → 'nix-lockfile-check' so the status check name is unambiguous when added as a required check on main. * fix(ci): harden auto-fix-main against races, loops, and silent failures Address adversarial review findings: 1. Race condition (#1): Job-level concurrency with cancel-in-progress collapses back-to-back pushes; ref: main checkout always gets latest branch state; explicit push target (origin HEAD:main). 2. Loop prevention (#2): File-whitelist check before commit aborts if any file outside nix/{tui,web}.nix was modified, preventing accidental self-triggering. 3. Silent infra failures (#8): nix-lockfile-check now fails explicitly when fix-lockfiles exits without reporting stale status (catches nix setup failures, network errors, script bugs that bypass continue-on-error). 4. Commit traceability (NousResearch#11): Auto-fix commits include source SHA and workflow run URL in the commit body. 5. Explicit push target (NousResearch#12): git push origin HEAD:main instead of bare git push. --------- Co-authored-by: alt-glitch <alt-glitch@users.noreply.github.com>
… contract Three test classes lock in the NousResearch#30963 fix: 1. TestPartialStreamStubFinishReason — drives _interruptible_streaming_api_call through the two recovery branches and asserts: - text-only partial → finish_reason="length" (the new behaviour), - mid-tool-call partial → finish_reason="stop" (unchanged on purpose). 2. TestLengthContinuationPromptBranching — pure-Python check on the branch that picks the continuation prompt by response.id. Locks the network error wording for partial-stream-stub vs. the output-length wording for everything else. 3. TestConversationLoopPartialStreamContinuation — feeds a stub + continuation pair into run_conversation, verifies the loop makes a second API call (instead of exiting with text_response(stop)), confirms the network-error continuation prompt actually reaches the model on call #2, and that final_response stitches both halves. Refs: NousResearch#30963
… OAuth gates
Two parallel public-path allowlists drifted: _PUBLIC_API_PATHS in
hermes_cli/web_server.py (legacy _SESSION_TOKEN middleware) and
_GATE_PUBLIC_PREFIXES in hermes_cli/dashboard_auth/middleware.py
(OAuth gate). The legacy list included /api/status (documented as a
non-sensitive read-only liveness target); the OAuth gate's list did not.
Effect: every wildcard-subdomain agent surfaced as STARTING/down to the
portal even though the dashboard was serving correctly. Nous account
service (src/server/agents/fly-provider.ts
getInstanceRuntimeStatus) fetches ``/api/status`` without a cookie
as its sole liveness probe; the OAuth gate's 401 looked identical to
'agent dead' on the portal side.
Fix: lift the allowlist into hermes_cli/dashboard_auth/public_paths.py
and have both middlewares import it. _path_is_public now consults
the shared frozenset first, then falls back to the gate's
auth-bootstrap/static prefix list. Future additions to the public list
hit both gates automatically.
Endpoint inventory (verified safe to remain public):
* /api/status — version, gateway state, active session count,
auth-gate shape. Portal liveness probe target.
* /api/config/defaults — config-defaults feed for the SPA's Config page
* /api/config/schema — config schema for the SPA's Config page
* /api/model/info — model catalogue metadata (context windows)
* /api/dashboard/themes — theme manifests for the skin engine
* /api/dashboard/plugins — plugin manifests for the dashboard
No user data, no session content, no secrets. Same shape an external
monitoring agent would hit on /healthz.
Tests:
* New: test_gated_status_is_public (regression guard with the NAS
fly-provider.ts liveness-probe rationale spelled out in the docstring)
* New: test_other_public_api_paths_are_public_under_gate (parametrised
over the rest of PUBLIC_API_PATHS — proves 401 / 302-to-login is
never the response)
* New: docker integration check #3 in
test_dashboard_oauth_gate_engaged_by_default — /api/status
remains 200 under the gate AND reports auth_required=True so the
portal can distinguish modes
* Updated: test_full_login_round_trip_unlocks_gated_api now probes
/api/sessions instead of /api/status (status is public, so it
can no longer distinguish 'logged in' from 'gate accidentally
disabled')
* Updated: TestApi401Envelope (the no-cookie / invalid-cookie /
dead-cookie tests) probes /api/sessions for the same reason
* Updated: docker integration check #2 in
test_dashboard_oauth_gate_engaged_by_default probes
/api/sessions to prove the gate is intercepting
* Removed: dead _login() helper in
test_dashboard_auth_status_endpoint.py (no longer needed since
/api/status is reachable cold)
Companion to docs/handover/hermes-agent-dashboard-s6-insecure-fix.md
(the --insecure flag fix that shipped earlier).
…NousResearch#34192) (NousResearch#34382) NousResearch#34192 reports Hostinger's 'Hermes WebUI' catalog crashes on startup with: /usr/bin/tini: No such file or directory The image moved from tini to s6-overlay as PID 1 (/init) earlier in 2026. Orchestration templates that still pin /usr/bin/tini as the entrypoint \u2014 like the Hostinger Hermes WebUI catalog \u2014 have no binary to exec and the container crashes immediately. Hermes has no control over the Hostinger catalog template, but we can make the image backward-compatible by symlinking /usr/bin/tini -> /init during the s6-overlay install step. External wrappers that exec /usr/bin/tini will land on the same s6-overlay reaper they would have landed on if they'd used the canonical /init entrypoint. The image's own ENTRYPOINT continues to be /init verbatim \u2014 the shim is purely for legacy external wrappers, not for the image's own runtime path. Once affected catalogs are updated, the symlink can be removed. Other issues NousResearch#34192 raises that are NOT addressed by this PR: * Problem #2 (UID 1024 vs 10000 mismatch): already fixed by NousResearch#33148 (S6_KEEP_ENV=1) and NousResearch#32412 (with-contenv shebangs). The Hostinger template likely needs to update its env-var propagation. * Problem #3 (incompatible session formats): RFC for pluggable SessionDB is tracked in NousResearch#23717. * Problem #4 (Telegram polling conflict): an operations problem on Hostinger's side, not in this codebase. This PR is scoped to the one issue that can be fixed inside Dockerfile: the missing /usr/bin/tini binary. Tests (3 in test_dockerfile_tini_compat_shim.py): - test_tini_compat_symlink_present Guard: the symlink line must exist in Dockerfile. - test_tini_compat_comment_explains_why The NousResearch#34192 anchor comment must be present so future readers know why the shim is there (avoid accidental removal). - test_entrypoint_still_init_not_tini Sanity check: ENTRYPOINT remains /init (s6-overlay). The shim is only for external wrappers. Refs: NousResearch#34192 Partial fix: addresses the immediate tini-binary crash. Catalog-side fixes still needed by Hostinger for the UID and session-format problems documented in the issue. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Single-file, pure-additive patch that lets
config.yamlprovide the Codex provider'sapi_keydirectly, instead of requiring OAuth credentials inhermes-auth-store.Needed for hosted deployments (e.g. Clawdi pods) that pre-configure a sub2api bearer token per deployment — the pod has no interactive OAuth login and no
~/.hermes/auth/codex.json.Diff
1 file, +16 / -0,
hermes_cli/runtime_provider.py:Priority order
config.yamlmodel.api_key(new) — hosted deploymentshermes-auth-store(upstream) — interactive OAuth (ChatGPT Plus/Pro)AuthErrorZero behavior change when
model.api_keyis empty — the existing OAuth path runs unchanged.Why patch instead of using
custom_providersThe
provider: custompath does acceptmodel.api_keyfrom config, but:api_modefrom URL; sub2api's internal hostname doesn't match theapi.openai.comheuristic → defaults tochat_completions, which is the wrong shape for Codex Responses API.openai-codex-specific routing (GPT-5 reasoning params, Codex Responses details).custom_providers: [...]) than themodel.*convention users expect.The 16-line patch keeps
provider: openai-codexfirst-class while satisfying the hosted-deployment use case.Rebase hygiene
upstream/main(currently677f1227) — clean, no conflictsopenai-codexbranch; existing OAuth code path untouchedNot for upstream
Internal-only PR against
Clawdi-AI/hermes-agent:main. Not submitting upstream toNousResearch/hermes-agent.