feat(server): configurable runtime-token header to avoid gateway Authorization collision [HYBIM-866] - #258
Merged
namrataghadi-galileo merged 8 commits intoAug 14, 2026
Conversation
…ization collision HYBIM-741. When Agent Control runs behind the O11y gateway, the gateway overwrites `Authorization` with its own downstream identity JWT, clobbering AC's runtime-eval token on the hot path. Make LocalJwtVerifyProvider read the runtime token from a configurable header, selected by AGENT_CONTROL_RUNTIME_TOKEN_HEADER (default `Authorization`). Behind the gateway, point it at a dedicated header (e.g. `X-Agent-Control-Runtime-Token`) so the runtime token and the gateway's Authorization JWT no longer collide. - `Authorization` keeps the mandatory `Bearer` scheme (existing contract). - A dedicated header accepts the raw token (Bearer optional). - Default unchanged: with the env unset, behavior is identical to before. Server-side only; the SDK send-side change is tracked separately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… gateway Authorization collision HYBIM-741. When Agent Control runs behind the O11y gateway, the gateway overwrites `Authorization` with its own downstream identity JWT, clobbering AC's runtime-eval token on the hot path. Make the runtime token ride a configurable header on both sides, selected by AGENT_CONTROL_RUNTIME_TOKEN_HEADER (default `Authorization`): - Server: LocalJwtVerifyProvider reads the token from the configured header; Bearer stays mandatory on Authorization, raw token accepted on a dedicated header. - SDK: AgentControlClient sends the token on the configured header (raw on a dedicated header, Bearer on Authorization) and suppresses the API-key fallback when the runtime token rides its own header, so a runtime request carries a single credential. Default unchanged: with the env unset, behavior is identical to before. Point both sides at a dedicated header (e.g. X-Agent-Control-Runtime-Token) behind the gateway so the runtime token and the gateway Authorization JWT no longer collide. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Preserve the outer gateway credential: _AgentControlAuth no longer suppresses the API key when the runtime token rides a dedicated header. The existing same-header guard already prevents collisions, so the API key remains available as the outer credential a gateway may require. - Preserve positional argument order: move runtime_token_header to the end of AgentControlClient.__init__ so existing positional callers are unaffected. - Expose runtime_token_header through the high-level SDK: agent_control.init() accepts it, stores it in session state, and threads it into the evaluation clients (evaluation.py, control_decorators.py); cleared on reset. - Add app-level /api/v1/evaluation tests exercising config wiring + Operation.RUNTIME_USE routing: runtime token on a dedicated header with an outer Authorization gateway JWT is accepted; a token on Authorization is ignored when a custom header is configured. Co-Authored-By: Claude <noreply@anthropic.com>
…HYBIM-866] Move runtime_token_header to the end of agent_control.init()'s signature (after target_id, before **kwargs) so existing positional callers are not shifted — previously it sat before controls_file, binding a controls path to the header and shifting every later arg. Mirrors the AgentControlClient fix. Add a positional-compatibility test. Addresses PR review. Co-Authored-By: Claude <noreply@anthropic.com>
…ate gateway 401 [HYBIM-866] Addresses PR review (Namrata): - Validate the runtime-token header against the RFC 7230 field-name grammar, not just non-blank (P2). New shared validate_http_field_name / resolve_runtime_token_header in the SDK runtime_auth, and a matching validate_http_field_name on the server (local_jwt). Applied in AgentControlClient.__init__, agent_control.init() (before stopping the refresh loop / mutating session state), LocalJwtVerifyProvider, and config._resolve_runtime_token_header — so an invalid header fails at construction/startup instead of on the first evaluation. - Distinguish a gateway 401 from a runtime-token failure (P1). When the runtime token rides a dedicated header (Authorization then carries the gateway's own identity), a bare 401 is ambiguous; _should_refresh_runtime_token now only refreshes on 401 there when WWW-Authenticate flags the token invalid, so a gateway 401 no longer evicts a valid runtime token and masks the original error. Default (token on Authorization) behavior is unchanged. Tests: SDK field-name validation (param + env), 401-disambiguation unit tests; server field-name validation for provider + resolver. SDK 59 pass, server 138 pass. Co-Authored-By: Claude <noreply@anthropic.com>
The positional-order regression guard stopped at api_key_header (6th positional), but the compatibility regression begins at controls_file (7th), so the prior test would also pass against the broken signature. Supply positionals through target_id (16th) and assert target_type/ target_id bind to their pre-change slots; runtime_token_header stays last and unset. Verified this fails if the header is inserted before target_id. Addresses review comment on PR #253. Co-Authored-By: Claude <noreply@anthropic.com>
Two import blocks in the new test file (top-level, and the local import in test_runtime_token_rejects_management_token_passed_to_runtime_verify) were un-sorted per ruff I001. Auto-fixed so CI lint passes. Co-Authored-By: Claude <noreply@anthropic.com>
namrataghadi-galileo
approved these changes
Aug 14, 2026
namrataghadi-galileo
deleted the
hybim-741-runtime-token-configurable-header
branch
August 14, 2026 17:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Recreated from #253 on an upstream branch (not a fork) so CI secrets (SPEAKEASY_API_KEY) are available. Same commits, signed. Original PR: #253.
Problem
When Agent Control runs behind the O11y gateway, the gateway overwrites
Authorizationwith its own downstream identity JWT — clobbering AC's runtime-eval token on the hot path (HYBIM-866, under epic HYBIM-741). The runtime token and the gateway's identity JWT both want theAuthorizationheader.Fix
Make the runtime token ride a configurable header on both sides, selected by
AGENT_CONTROL_RUNTIME_TOKEN_HEADER(defaultAuthorization). Behind the gateway, point both sides at a dedicated header (e.g.X-Agent-Control-Runtime-Token); the runtime token rides that header while the gateway keepsAuthorizationfor its identity JWT — no collision.Authorization: keeps the mandatoryBearerscheme (existing contract).Bearerprefix).Server (verify side)
auth_framework/providers/local_jwt.py:LocalJwtVerifyProvidertakes aheader_name(defaultAuthorization) and reads the token from it.Bearerrequired only onAuthorization; raw token accepted on a dedicated header.auth_framework/config.py:_resolve_runtime_token_header()readsAGENT_CONTROL_RUNTIME_TOKEN_HEADER(blank → default), passed into the provider when runtime mode isjwt.SDK (send side)
sdks/python/.../client.py:AgentControlClientgains aruntime_token_headerparam (+ same env var). Sends the runtime token on the configured header — raw on a dedicated header,BeareronAuthorization(single_format_runtime_tokenhelper is the sole authority for that rule).X-API-Key), so a request can authenticate at the gateway while the runtime JWT is verified by Agent Control. The existing same-header guard prevents any collision.agent_control.init()acceptsruntime_token_header, stores it in session state, and threads it into the evaluation clients (evaluation.py,control_decorators.py); it is cleared on reset.Configuration (behind the gateway)
Tests
test_auth_framework.py): default Bearer path; default rejects raw onAuthorization; dedicated header reads raw token and coexists with a gatewayAuthorizationJWT; Bearer also accepted on dedicated header; missing-header error names the configured header; blankheader_namerejected; env resolver (unset → default, set → trimmed, whitespace → default).test_runtime_token_exchange_endpoint.py): end-to-end through/api/v1/evaluationexercising config wiring +Operation.RUNTIME_USErouting — runtime token on a dedicated header with an outerAuthorizationgateway JWT is accepted; a token presented onAuthorizationis rejected (401) when a custom header is configured.test_client.py): header resolution (param/env/default, blank rejected, whitespace-env fallback); raw token on dedicated header withAuthorizationfree while the API key is preserved on its own header; default sendsBeareronAuthorization; auto-mode fallback keeps the API key when the exchange is unavailable.test_init_validation.py):init()storesruntime_token_headerin session state; defaults toNonewhen unset; validates the header up front (blank and bad field-name rejected before state is mutated); a positional call throughtarget_idproves the new param is appended last and does not shift any existing slot (controls_fileonward).Live validation on lab0 (through the real O11y gateway)
Validated the branch on lab0 behind the actual O11y api-gateway (the gateway that
owns
Authorizationfor its own identity JWT — the real collision scenario).docker-test.repo.splunkdev.net/user-<name>/agent-control:<tag>namespace (apath a personal Artifactory token can write and lab0 can pull), then
kubectl set imagethe lab0agent-controldeploy at it withAGENT_CONTROL_RUNTIME_TOKEN_HEADER=X-Agent-Control-Runtime-Token. Rolled backto the released image afterward.
runtime-token-exchangereturns 200 and mints a real server-issued runtime JWT for the log_stream; the
evaluation call carries that token on the custom header through the gateway
and returns 200 with a real result (
is_safe: true).Authorizationisrejected with 401 ("Missing X-Agent-Control-Runtime-Token"), because the server
reads only the dedicated header. So even when the gateway overwrites
Authorizationwith its own identity JWT, the runtime JWT survives onX-Agent-Control-Runtime-Token.502(upstream
422) on the exchange, which looked like a capability/provisioning gap.That was a test-input error, not a real gap: the request used a made-up
target_idthat was not a real log_stream, so the exchange could not resolve it.With a real log_stream id the whole flow returns 200. Nothing is blocked on
provisioning.
spans landing in Galileo (no control was bound to the log_stream, so the eval ran
clean). That is separate from the header change and was already shown on the
devstack.
Security notes
Authorizationis ignored, so it can't be smuggled past the gateway boundary.verify_runtime_token) are unchanged.Backward compatibility
Safe. No backward compatibility issue.
The change is default-preserving.
runtime_token_headerdefaults toAuthorizationon both the SDK and server, and an unset env var falls back to that default. With no config, behavior is byte-identical to today: the SDK still sendsBearer <token>onAuthorization, and the server still requires the Bearer scheme there. All four default-path branches were traced to confirm.One behavior change on the default path, and it's an improvement: a malformed
Authorization: Bearerwith only trailing whitespace used to return an empty token and fail deep in signature verification. Now it's rejected up front with a cleanAUTH_MISSING_KEY. No valid request changes.Operational notes (expected tradeoffs, not design risks)
AGENT_CONTROL_RUNTIME_TOKEN_HEADERon only one side breaks runtime auth (401 on every eval). This is intrinsic to any configurable-header feature, not a regression, and it fails closed: a mismatch denies auth, never bypasses it. Neither side can validate the other's value since they run as separate processes, so the intended safeguard is a startup log of the resolved header on both sides, making a mismatch a quick log diff rather than a debugging session._AgentControlAuthnow leaves the API key in place when the runtime token rides a dedicated header, so the key can act as the outer gateway credential. This is deliberate and required by the two-credential model: the API key authenticates at the gateway, and the runtime JWT is verified by Agent Control. The default path is unchanged. It is called out only because it is the one change on every request's auth path, which makes it the right spot to focus review.Notes / scope
noneruntime modes.X-Agent-Control-Runtime-Tokenand the server reading the same header, runtime JWT exchange, control steering, and control-span ingestion all worked. This confirms the SDK and server agree on the custom header. It does not yet exercise the gateway-collision path (the devstack has no O11y gateway overwritingAuthorization).runtime-token-exchangereturns 200, the evaluation call ridesX-Agent-Control-Runtime-Tokenthrough the real O11y gateway and returns 200 (is_safe: true), and the same token onAuthorizationis rejected 401. Control firing and span ingestion were not exercised in this run (no control bound to the log_stream), and are separate from the header change.