Skip to content

fix(gateway): scope /credits & /usage under profile multiplexing - #98

Merged
exiao merged 3 commits into
live-configfrom
fix/credits-usage-secret-scope
Jul 14, 2026
Merged

fix(gateway): scope /credits & /usage under profile multiplexing#98
exiao merged 3 commits into
live-configfrom
fix/credits-usage-secret-scope

Conversation

@exiao

@exiao exiao commented Jul 12, 2026

Copy link
Copy Markdown
Owner

The gap

Under gateway.multiplex_profiles: true, /credits and /usage for a secondary profile can show partial/empty account data (the default profile's balance, or not_logged_in/[]) instead of that profile's real balance. Unlike the /model bug #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 in gateway/run.py). So:

  • /credits_handle_credits_commandagent.account_usage.build_credits_view
  • /usage_handle_usage_commandfetch_account_usage + nous_credits_lines

...resolve get_hermes_home() (auth.json via get_provider_auth_state("nous")) and get_secret-backed provider keys (OpenRouter/Codex usage) against the module-level default profile.

Fix

Added a shared helper _profile_secret_scope_for_source(source) on GatewaySlashCommandsMixin that returns _profile_runtime_scope(self._resolve_profile_home_for_source(source)) under multiplex_profiles, else a nullcontext() no-op. Wrapped the three secret/home-reading asyncio.to_thread calls in the two handlers. Contextvars propagate into the to_thread worker via copy_context(), so the scope is live on the thread that reads.

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 returns nullcontext when multiplexing is off.
  • test_scope_redirects_home_to_requesting_profile — under multiplex the scope redirects get_hermes_home() to profile B and exits cleanly.
  • test_scope_installs_profile_secret_scope — under the scope, profile B's own .env OPENROUTER_API_KEY wins over os.environ.

Verified: 12 passed (new file + test_usage_command.py regression), 0 failed, via scripts/run_tests.sh.

Patch note: ~/.hermes/plans/hermes-patches/credits-usage-secret-scope.md

Follow-up to #97. Refs gemini thread PRRT_kwDOSE5m_86QOz2J.

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
@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown

Claude finished @exiao's task in 0s —— View job


I'll analyze this and get back to you.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread gateway/slash_commands.py
Comment thread gateway/slash_commands.py

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread gateway/slash_commands.py
…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).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread gateway/slash_commands.py
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).
@exiao

exiao commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: d80dca3f84

ℹ️ 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".

@exiao
exiao merged commit e3cc74f into live-config Jul 14, 2026
44 checks passed
@exiao
exiao deleted the fix/credits-usage-secret-scope branch July 14, 2026 00:14
exiao added a commit that referenced this pull request Jul 14, 2026
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant