fix(gateway): scope /credits & /usage under profile multiplexing - #98
Conversation
Slash-command dispatch runs outside the multiplexer's per-turn agent scope, so build_credits_view / fetch_account_usage / nous_credits_lines resolve get_hermes_home() (auth.json) and get_secret-backed provider keys against the DEFAULT profile — a secondary profile's /credits & /usage show partial/empty data instead of its real balance. Both paths fail-open, so they degrade silently rather than error. Add a shared _profile_secret_scope_for_source(source) helper that installs _profile_runtime_scope for the requesting profile under multiplex_profiles (nullcontext no-op otherwise), and wrap the three secret/home-reading to_thread calls in the two handlers. Contextvars propagate into the worker via copy_context. Follow-up to #97 (same bug class as _switch_model_scoped). Patch note: ~/.hermes/plans/hermes-patches/credits-usage-secret-scope.md
There was a problem hiding this comment.
Code Review
This pull request introduces profile-specific secret scoping for slash commands (such as /credits and /usage) to ensure that secondary profiles resolve their own credentials and home directories instead of falling back to the default profile. It adds a context manager helper _profile_secret_scope_for_source and applies it to the credit and usage command handlers, accompanied by new unit tests. The review feedback highlights a critical issue where the underlying ThreadPoolExecutor used inside build_credits_view and nous_credits_lines does not automatically propagate ContextVars to worker threads, causing the profile-specific context to be lost. It is recommended to update the executor submissions using contextvars.copy_context().run to ensure proper context propagation.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 30d92ae7d9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…enders
The profile-scope wrapper reads event.source; the pre-existing _FakeEvent
stub had no such attribute, so the read raised inside the handler's
try/except and view fell to None ('Not logged into Nous Portal'). Real
MessageEvents always carry .source. Adds source=None to the stub. Fixes the
CI failure on this PR (tests/agent/test_credits_view.py slice).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bc412ea77d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
build_credits_view and nous_credits_lines run get_nous_portal_account_info in a bare concurrent.futures.ThreadPoolExecutor. asyncio.to_thread carried the per-profile _SECRET_SCOPE / _HERMES_HOME_OVERRIDE ContextVars this far, but a bare executor does not propagate ContextVars, so the portal fetch resolved get_hermes_home()/auth.json against the DEFAULT profile under multiplexing -- showing a secondary profile the wrong balance/identity. Wrap both submissions in contextvars.copy_context().run so the active scope reaches the worker thread. Adds regression tests proving the override survives into the nested executor (fail before, pass after).
|
@codex review |
|
Codex Review: Didn't find any major issues. Nice work! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
…ecret scope (#99) * fix(anthropic): scope resolve_anthropic_token to active profile secret scope Under gateway profile multiplexing, /usage for a secondary Anthropic profile could show the DEFAULT profile's limits. resolve_anthropic_token read ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY from os.getenv directly, never via agent.secret_scope.get_secret, so even with the scope contextvar propagated into the worker thread the token still resolved to the process/default value. Route the three reads through get_secret(..., '') so the active _SECRET_SCOPE wins under multiplexing; with no scope / multiplex-off get_secret transparently reads os.environ (single-profile deployments and non-gateway callers stay byte-identical). run_oauth_setup_token's os.getenv reads are left alone (interactive setup, not a per-turn multiplexed path). Regression test mirrors tests/gateway/test_credits_usage_profile_scope.py: scoped profile-B token wins, no-scope falls back to os.environ. Verified fail-before/pass-after. Patch note: ~/.hermes/plans/hermes-patches/anthropic-token-secret-scope.md Task: t_601f23d1 (follow-up to PR #98 Codex Anthropic P1) * fix(anthropic): don't let global Claude creds override scoped token under multiplexing Codex P1 on PR #99: resolve_anthropic_token reads the host's global ~/.claude / Keychain credentials, then _prefer_refreshable_claude_code_token could return that DEFAULT-profile refreshable credential in place of the requesting profile's scoped ANTHROPIC_TOKEN (or resolve it as source #3), authenticating /usage and Anthropic calls as the wrong profile. Under an active secret scope the global cred record is not the requesting profile's, so drop it to None; only the profile's own scoped secrets resolve. Adds a regression test with refreshable global creds present (the prior test stubbed creds to None and missed this). * fix(anthropic): skip global Claude Code source under active secret scope Codex second P1 on PR #99 (source-#3 gap): dropping creds to None under a secret scope did not actually suppress the global Claude Code fallback. A scoped profile with only ANTHROPIC_API_KEY (or no Anthropic secret) still reached _resolve_claude_code_token_from_credentials(None), which re-reads the global ~/.claude file and returns the DEFAULT profile's token before the scoped API-key fallback — authenticating /usage and Anthropic calls as the wrong profile. Skip source #3 entirely while a secret scope is active so only the profile's own scoped secrets (sources #1/#2/#4/#5) resolve. Adds a regression test for a scoped ANTHROPIC_API_KEY with a refreshable global Claude Code cred present (fails without the guard: returns GLOBAL-DEFAULT; passes with it). * fix(anthropic): scope global-cred suppression to non-default profiles Codex P2 on PR #99: under gateway.multiplex_profiles the DEFAULT profile's own turns also run inside _profile_runtime_scope, so current_secret_scope() is non-None even for the profile that owns the global ~/.claude / Keychain credential. The prior guard suppressed the global Claude Code source for ANY active scope, breaking Anthropic auth and /usage for a default profile that relies on Claude Code OAuth (rather than .env/pool creds). Gate the suppression on _scope_is_non_default_profile(): only a non-default scoped profile drops the global creds and skips the source-#3 fallback; the default profile (owner of ~/.claude) keeps resolving it. get_active_profile_name reads the per-turn HERMES_HOME override, so it reflects the active profile. Updates the two prior regression tests to a non-default profile scope and adds a default-profile regression (fails with the old scope-active gate: returns None; passes now: resolves the global Claude Code credential). * fix: isolate scoped Anthropic pool credentials * fix: honor suppressed profile OAuth credentials * fix: preserve scoped Anthropic credential priority * fix: preserve local OAuth pool precedence * fix: scope usage lookups without breaking cron * fix: preserve default env token under scoped usage * fix(anthropic): prune stale env OAuth pool entries under explicit API key On the profile_only pool path, an explicit ANTHROPIC_API_KEY only dropped the hermes_pkce entry, so an auto-seeded env:ANTHROPIC_TOKEN / env:CLAUDE_CODE_OAUTH_TOKEN OAuth singleton left in the profile auth.json could still be returned by source #4 — reviving the Claude Code OAuth masquerade that picking the API-key path explicitly opts out of. Match load_pool()'s api_key_path_explicit pruning: require the API key be set AND no OAuth env tokens present, then drop every auto-seeded OAuth entry (env:* sources + hermes_pkce + claude_code) while keeping manual pool entries and the api_key entry. Regression test seeds a stale env:ANTHROPIC_TOKEN OAuth entry + a manual entry in a non-default profile pool with a scoped API key and asserts the stale env OAuth token is not what resolves (red before: it was returned). * fix(anthropic): honor a blank scoped Anthropic slot as an explicit clear _read_anthropic_secret tested scope values for stripped truthiness, so a profile that intentionally disabled a path by writing a blank slot to its .env (ANTHROPIC_TOKEN= / ANTHROPIC_API_KEY=, as the setup helpers do) looked like a scope with no Anthropic secret. The default-profile fallback then read os.environ, letting a service-level ANTHROPIC_TOKEN override the profile's explicit clear. Treat any Anthropic slot PRESENT in the scope (blank or not) as the profile having spoken and suppress the process-env fallback. The empty-scope ({}, no slot declared) case still falls back, which is the legitimate default-profile-from-service-env path. Regression test: a default-profile scope with a blank ANTHROPIC_TOKEN slot must resolve to None, not the service-env token (red before). * fix(anthropic): keep manual pool OAuth precedence for default multiplex scope The pool-consult gate skipped _resolve_anthropic_pool_token whenever a scoped ANTHROPIC_API_KEY was present and the scope was NOT a non-default profile. Under multiplexing the DEFAULT profile also runs inside a secret scope and owns ~/.hermes/auth.json, so a manually-added OAuth entry there lost its documented source-#4-before-#5 precedence and /usage + Anthropic calls resolved the API key instead of the configured OAuth credential. Consult the pool for any multiplex scope (default included) while keeping a non-multiplex single-profile cron scope's API key authoritative (no borrowed global pool token). Regression test added (red before/green after). * fix(anthropic): normalize pool priorities on profile-only reads The profile_only pool path builds CredentialPool directly (to skip load_pool's global seeders on the isolated profile) but also skipped _normalize_pool_priorities, so a manually-added OAuth entry that hermes auth add appended with a larger priority number sorted behind a seeded singleton (env:ANTHROPIC_TOKEN / hermes_pkce) and the seeded token resolved first. Apply the same manual-over-seeded normalization load_pool runs before constructing the pool, restoring manual precedence. Regression test: a profile pool with a seeded OAuth (priority 0) and a manual OAuth (priority 1) must resolve to the manual token (red before: seeded won).
The gap
Under
gateway.multiplex_profiles: true,/creditsand/usagefor a secondary profile can show partial/empty account data (the default profile's balance, ornot_logged_in/[]) instead of that profile's real balance. Unlike the/modelbug #97 fixed, this is a correctness gap, not a crash — both paths are fail-open (try/except -> not_logged_in / []), so under multiplexing they degrade silently.Root cause
Slash-command dispatch runs outside the multiplexer's per-turn agent scope (
_profile_runtime_scope, installed only around the agent RUN ingateway/run.py). So:/credits→_handle_credits_command→agent.account_usage.build_credits_view/usage→_handle_usage_command→fetch_account_usage+nous_credits_lines...resolve
get_hermes_home()(auth.json viaget_provider_auth_state("nous")) andget_secret-backed provider keys (OpenRouter/Codex usage) against the module-level default profile.Fix
Added a shared helper
_profile_secret_scope_for_source(source)onGatewaySlashCommandsMixinthat returns_profile_runtime_scope(self._resolve_profile_home_for_source(source))undermultiplex_profiles, else anullcontext()no-op. Wrapped the three secret/home-readingasyncio.to_threadcalls in the two handlers. Contextvars propagate into theto_threadworker viacopy_context(), so the scope is live on the thread that reads._profile_runtime_scope(no new isolation seam); never mutatesos.environ._switch_model_scopedestablished in fix(gateway): scope /model switch credentials under profile multiplexing #97 into one reusable helper (three call sites need it).Tests
tests/gateway/test_credits_usage_profile_scope.py(3 tests, exercise the shared scope helper via a minimal stand-in runner, no full gateway):test_scope_noop_when_multiplex_off— helper returnsnullcontextwhen multiplexing is off.test_scope_redirects_home_to_requesting_profile— under multiplex the scope redirectsget_hermes_home()to profile B and exits cleanly.test_scope_installs_profile_secret_scope— under the scope, profile B's own.envOPENROUTER_API_KEYwins overos.environ.Verified: 12 passed (new file +
test_usage_command.pyregression), 0 failed, viascripts/run_tests.sh.Patch note:
~/.hermes/plans/hermes-patches/credits-usage-secret-scope.mdFollow-up to #97. Refs gemini thread PRRT_kwDOSE5m_86QOz2J.