Skip to content

fix(gateway): manual /compress dies with UnscopedSecretError under multiplexing — run it under the profile secret scope - #74964

Closed
CocaKova wants to merge 1 commit into
NousResearch:mainfrom
CocaKova:fix/compress-secret-scope
Closed

fix(gateway): manual /compress dies with UnscopedSecretError under multiplexing — run it under the profile secret scope#74964
CocaKova wants to merge 1 commit into
NousResearch:mainfrom
CocaKova:fix/compress-secret-scope

Conversation

@CocaKova

Copy link
Copy Markdown
Contributor

Problem

On any deployment with gateway.multiplex_profiles: true, the manual /compress command fails 100% of the time:

Manual compress failed: get_secret('OPENROUTER_BASE_URL') called with no profile secret
scope active while multiplexing is on. This credential read must run inside a
set_secret_scope(...) block (the per-turn / per-adapter profile scope).

Root cause: the fail-closed per-profile secret scope (agent/secret_scope.py, Workstream A) is installed by _run_agent's profile-scoping wrapper — but slash-command dispatch in _handle_message runs outside that wrapper. _handle_compress_command therefore hits _resolve_session_agent_runtimeresolve_runtime_providerget_secret('OPENROUTER_BASE_URL') with no scope, and the guard raises exactly as designed ("an un-migrated call site fails loud").

There's a second, independent gap on the same path: the compressor runs via a bare loop.run_in_executor(None, lambda: tmp_agent._compress_context(...)), which does not propagate contextvars — so even a scoped caller would lose the scope before the aux-client's provider resolution runs in the worker thread.

Same bug class as the cron scheduler (salvaged from #57692) and the /v1/runs agent path.

Fix

Two changes, both required:

  1. _handle_compress_command becomes a profile-scoping wrapper around the existing handler (renamed _handle_compress_command_inner), mirroring _run_agent: gated on multiplex_profiles, resolves the source profile's home, and runs the whole handler inside _profile_runtime_scope. Single-profile gateways take the pass-through branch — zero behavior change.
  2. The compressor call switches to the existing _run_in_executor_with_context helper (copy_context + gateway-owned executor) so the scope survives the thread hop.

Tests

  • test_compress_command_multiplexed_runs_under_profile_secret_scope — arms fail-closed multiplexing, reads get_secret('OPENROUTER_BASE_URL') inside the executor'd _compress_context, and asserts the profile's .env value is seen. Pre-fix this reproduces the field failure verbatim.
  • test_compress_command_single_profile_skips_profile_resolution — pins the pass-through contract (no profile resolution when multiplexing is off).

2 added; 162 gateway compress/multiplex-scope tests green.

Note for #61689 (multiplex_routing_only)

In routing-only mode every profile shares the default credential set by definition, so the fail-closed guard has nothing to protect there while still crashing any unscoped path. It may be worth having multiplex_routing_only skip set_multiplex_active(True) (unscoped reads fall back to the shared os.environ; installed scopes still take precedence). Happy to add that to #61689 if wanted — this PR fixes the general case where profiles do hold distinct keys.

🤖 Generated with Claude Code

Multiplexed gateways resolve credentials through the fail-closed
per-profile secret scope (agent/secret_scope.py, Workstream A): any
get_secret() read outside a set_secret_scope(...) block raises
UnscopedSecretError. The agent turn installs the scope via _run_agent's
profile-scoping wrapper, but slash-command dispatch does not — so manual
/compress reached provider resolution unscoped and every invocation on a
gateway.multiplex_profiles: true deployment failed with:

  Manual compress failed: get_secret('OPENROUTER_BASE_URL') called with
  no profile secret scope active while multiplexing is on.

Same bug class as the cron scheduler (NousResearch#57692) and the /v1/runs agent
path — an un-migrated call site the fail-closed design is meant to catch.

Two changes, both required:

- _handle_compress_command becomes a profile-scoping wrapper around the
  existing handler (renamed _handle_compress_command_inner), mirroring
  _run_agent: gated on multiplex_profiles, resolves the source profile's
  home and runs the whole handler inside _profile_runtime_scope. Covers
  the coroutine-side read (_resolve_session_agent_runtime).
- The compressor call switches from a bare loop.run_in_executor(None, …)
  to the existing _run_in_executor_with_context helper, so the scope
  contextvar survives the thread hop into _compress_context, where the
  aux-client provider resolution reads credentials.

Single-profile gateways take the pass-through branch — zero behavior
change (pinned by test).

Tests: 2 added (scoped read inside the executor under fail-closed
multiplexing reproduces the field failure pre-fix; single-profile
pass-through). 162 gateway compress/multiplex-scope tests green.
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery area/auth Authentication, OAuth, credential pools P2 Medium — degraded but workaround exists sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Jul 30, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused regression fix. The premise remains present on the inspected main checkout: gateway/run.py:14498-14499 dispatches /compress directly, while gateway/slash_commands.py:3877-3880 resolves provider runtime state and gateway/slash_commands.py:3978-3989 uses a bare executor hop. In multiplex mode, that can reach the fail-closed unscoped-secret branch at agent/secret_scope.py:166-174.

The proposed wrapper follows the existing scoped agent-run pattern at gateway/run.py:22991-23004, and the executor substitution uses the existing copy_context() implementation at gateway/run.py:20278-20287. The added regression exercises the credential read inside the worker thread, covering both required boundaries.

Automated hermes-sweeper review.

@teknium1

teknium1 commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Salvaged and merged in #76573 — your commit is on main as 651c516 with your authorship. Review verdict was salvage-as-is: this was the textbook lens-correct fix (wrap the handler in _profile_runtime_scope + swap the bare run_in_executor(None,…) for the copy_context helper — scope the spawn, never silence the error), and your regression test that reads get_secret inside the executor thread pins exactly the right invariant. The sibling bare-executor hops your fix pattern applies to (/insights, draft contract, reload_skills, collect-and-upload) are tracked in #76574. Thanks @CocaKova!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools area/compression Context compression and continuation sessions area/profiles Multi-profile isolation, HERMES_HOME scoping comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants