Skip to content

feat(providers): anthropic subscription oauth - #2285

Open
cjfit wants to merge 9 commits into
NVIDIA:mainfrom
cjfit:feat/anthropic-subscription-oauth
Open

feat(providers): anthropic subscription oauth#2285
cjfit wants to merge 9 commits into
NVIDIA:mainfrom
cjfit:feat/anthropic-subscription-oauth

Conversation

@cjfit

@cjfit cjfit commented Jul 15, 2026

Copy link
Copy Markdown

Summary

Adds an anthropic-oauth provider type (alias: claude-subscription) that lets sandboxed agents run Claude Code against an Anthropic Pro/Max subscription instead of an API key. The OAuth access token is held gateway-side and injected only at the egress boundary (inference.local) and it never enters the sandbox environment, filesystem, or logs.

Related Issue

Closes #1925

Human Notes

  • Claude Fable 5 was used to help prepare the changes and PR, but all changes were prompted and reviewed by a human.
  • I chose to edit existing GCP provider functions because they already implemented functionality that would otherwise need to be duplicated for Anthropic OAuth (e.g. ensuring env vars have real values at sandbox initialization).
  • I used providers v2 as it seems this is the path that the project is standardizing on, and it handles OAuth refreshes well.
  • I chose not to make this fully "magic" (i.e. doing auto-discovery and initialization of the provider without the user explicitly opting in). I'm open to thoughts here, but this seems a bit intrusive due to the fact that the Claude OAuth token is read from the user's Keychain on macOS. Incorporating into the main README in a future PR with instructions could help users do the setup (currently 3 lines).

These screenshots show how the new provider works in a new sandbox. osl is an alias for my locally-compiled OpenShell executable. Providers v2 must be enabled first: osl settings set --global --key providers_v2_enabled --value true --yes.

Screenshot 2026-07-14 at 9 04 49 PM Screenshot 2026-07-14 at 9 04 35 PM

Changes

  • New provider plugin (openshell-providers): anthropic-oauth with claude-subscription alias. Seeds sandbox env with ANTHROPIC_BASE_URL=https://inference.local and an ANTHROPIC_AUTH_TOKEN placeholder. Deliberately does not inject ANTHROPIC_API_KEY — Claude Code interrupts startup to confirm env API keys, but accepts ANTHROPIC_AUTH_TOKEN silently; the router strips both headers at egress anyway and injects the real subscription Bearer token plus the oauth-2025-04-20 beta header, so the two are wire-identical.
  • Static-config env resolution (openshell-core): the credential pipeline placeholderizes all provider env values, but SDKs must parse ANTHROPIC_BASE_URL at process startup. child_env_with_gcp_resolved is generalized to child_env_with_static_config_resolved and now resolves non-secret Anthropic config keys back to real values in the sandbox env, following the existing GCP pattern. Auth tokens stay egress-time placeholders. (Call sites and a doc-comment in google_cloud_metadata.rs updated for the rename.)
  • CLI setup flow (openshell-cli): openshell provider create --from-claude-login harvests the subscription OAuth material from the host keychain; --name/--type now default to claude-subscription/anthropic-oauth when that flag is set, a default inference route is configured if none exists, and the missing-provider message during sandbox create points at the correct setup command (the credentials can't be configured from inside a sandbox).
  • Token refresh (openshell-server): the refresh worker rotates the access token ahead of expiry and persists rotated refresh tokens. When the token endpoint rejects the refresh grant with HTTP 4xx (revoked login, lapsed plan), the stored error now carries recovery guidance (claude login on the host, recreate the provider); transient network errors and 5xx are excluded.
  • Docs: provider guide (docs/providers/anthropic-subscription.mdx) covering setup, the env preset, why Claude Code's "API Usage Billing" banner is cosmetic (billing goes to the subscription), and refresh-failure recovery; architecture/gateway.md updated for the env projection behavior.

Testing

  • Unit suites green: provider plugin, provider_credentials static-config resolution, gRPC resolve_provider_environment (asserts the access token is never injected into sandbox env), and provider refresh (re-login hint applies only to anthropic-oauth 4xx grant rejections).
  • Manual golden path on a locally built gateway: provider create --from-claude-login → sandbox create --provider claude-subscription → bare claude inside the sandbox authenticates without prompts and answers requests; Console API usage stays flat (billing hits the subscription).
  • mise run pre-commit green.

Local test checklist

Prereqs: Docker running, a Claude Pro/Max subscription with claude login completed on the host.

  • Build: cargo build -p openshell-cli and mise run gateway:docker (rebuilds the gateway + supervisor image — required, the env injection changed)
  • Lint/format: mise run pre-commit
  • Unit tests: mise run test (note: ssh::tests::wait_for_forward_listener_rejects_missing_listener and sbom test_same_domain_requests_are_spaced are pre-existing flakes under parallel load; both pass isolated)
  • e2e: mise run e2e
  • Golden path:
    • openshell provider create --from-claude-login — succeeds without --name/--type, prints the created claude-subscription provider and default inference route
    • openshell sandbox create --provider claude-subscription then run bare claude inside — no login prompt, no "Do you want to use this API key?" prompt, responds to a message
    • Inside the sandbox: echo $ANTHROPIC_AUTH_TOKEN shows an openshell:resolve:env:... placeholder (never a real token); echo $ANTHROPIC_BASE_URL shows https://inference.local; ANTHROPIC_API_KEY is unset
    • openshell provider refresh status shows the token rotating
  • Negative path: openshell sandbox create --provider claude-subscription before creating the provider — error poim-claude-login`

One caveat for macOS reviewers: a pre-existing bash 3.2 empty-array bug in e2e/mcp-conformance.sh (unrelated to this PR)

Checklist

  • Conventional Commits, DCO sign-off
  • Architecture and published docs updated
  • e2e run on final revision

@cjfit
cjfit requested review from a team, derekwaynecarr, maxamillion and mrunalp as code owners July 15, 2026 01:18
@copy-pr-bot

copy-pr-bot Bot commented Jul 15, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions

github-actions Bot commented Jul 15, 2026

Copy link
Copy Markdown

All contributors have signed the DCO ✍️ ✅
Posted by the DCO Assistant Lite bot.

@cjfit

cjfit commented Jul 15, 2026

Copy link
Copy Markdown
Author

I have read the DCO document and I hereby sign the DCO.

@cjfit

cjfit commented Jul 15, 2026

Copy link
Copy Markdown
Author

recheck

@purp

purp commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Personally super excited that you're tackling this! It was on my list of annoyances to fix once I had a bit more understanding of the provider system.

Note that CLAUDE_CODE_OAUTH_TOKEN is also an environment variable which Claude will recognize for a long-lived (1 year) token as noted in their authentication documentation.

As noted in #620 you can create a provider based on it which is recognized by claude ... CLI commands in a sandbox, but starting a claude type sandbox still forces a copy-paste web login. To give that (also broken) flow a try:

openshell provider create --name anthropic --type claude --credential CLAUDE_CODE_OAUTH_TOKEN
openshell sandbox create --name claude-is-sad --provider anthropic -- claude 
# ... and then ignore the Claude setup screen which will want you to pick a login method
# instead, in another shell:
openshell sandbox exec --name claude-is-sad -- bash -lc 'echo $(claude auth status)'
# returns: { "loggedIn": true, "authMethod": "oauth_token", "apiProvider": "firstParty" }

Looking forward to this!

@cjfit

cjfit commented Jul 15, 2026

Copy link
Copy Markdown
Author

Personally super excited that you're tackling this! It was on my list of annoyances to fix once I had a bit more understanding of the provider system.

Note that CLAUDE_CODE_OAUTH_TOKEN is also an environment variable which Claude will recognize for a long-lived (1 year) token as noted in their authentication documentation.

As noted in #620 you can create a provider based on it which is recognized by claude ... CLI commands in a sandbox, but starting a claude type sandbox still forces a copy-paste web login. To give that (also broken) flow a try:

openshell provider create --name anthropic --type claude --credential CLAUDE_CODE_OAUTH_TOKEN

openshell sandbox create --name claude-is-sad --provider anthropic -- claude 

# ... and then ignore the Claude setup screen which will want you to pick a login method

# instead, in another shell:

openshell sandbox exec --name claude-is-sad -- bash -lc 'echo $(claude auth status)'

# returns: { "loggedIn": true, "authMethod": "oauth_token", "apiProvider": "firstParty" }

Looking forward to this!

Thanks @purp! Will play around with this secret. Ideally if it works it could shrink this PR by removing some redundant code for configuring the provider.

@cjfit

cjfit commented Jul 15, 2026

Copy link
Copy Markdown
Author

I've investigated #620 and my findings were consistent trying to use CLAUDE_CODE_OAUTH_TOKEN. I'm not sure that's a path worth going down further

@drew drew changed the title Feat/anthropic subscription oauth feat(providers): anthropic subscription oauth Jul 16, 2026
@johntmyers

Copy link
Copy Markdown
Collaborator

gator-agent

Blocked

Gator is blocked because PR #2285 currently has merge conflicts with main (mergeable: CONFLICTING, mergeStateStatus: DIRTY).

Head SHA: 195c591c44a920c545fdd8d2dee1d27f48bb3c2d

Next action: @cjfit, please update the branch from main, resolve the conflicts, and push the resolution. Gator will run the independent code review on the new head and use one batched review for any actionable diff-anchorable findings.

@johntmyers johntmyers added the gator:blocked Gator is blocked by process or repository gates label Jul 17, 2026
@johntmyers

Copy link
Copy Markdown
Collaborator

New provider plugin (openshell-providers): anthropic-oauth with claude-subscription alias. Seeds sandbox env with ANTHROPIC_BASE_URL=https://inference.local/ and an ANTHROPIC_AUTH_TOKEN placeholder. Deliberately does not inject ANTHROPIC_API_KEY

Is this necessary? The future of inference.local is up in the air, so curious if we could just map to the actual Anthropic endpoints instead.

cjfit added 6 commits July 17, 2026 10:57
Add an `anthropic-oauth` provider type (alias `claude-plan`) so sandboxes can
run Claude Code on an Anthropic Pro/Max subscription. The OAuth token is
harvested, stored, and refreshed entirely outside the sandbox and injected only
at the egress boundary; it is never written into the sandbox environment,
filesystem, or logs.

- inference: new anthropic-oauth profile (Authorization: Bearer plus a mandatory
  anthropic-beta: oauth-2025-04-20 default header)
- router: merge the mandatory anthropic-beta flag with any client-sent value so
  the sandbox cannot drop it
- server: mark ANTHROPIC_OAUTH_TOKEN non-injectable (proxy-only)
- cli: add `provider create --from-claude-login` to read the local Claude Code
  login (macOS Keychain or ~/.claude/.credentials.json) and wire the gateway's
  OAuth2 background refresh
- docs: add the Anthropic Subscription provider page and update gateway
  architecture and provider tables

The macOS path (Keychain) is validated end-to-end. Linux reads the same
~/.claude/.credentials.json file and should work unchanged, but has not yet been
validated on Linux.

Signed-off-by: Cedric Fitzgerald <soulcedric2@gmail.com>
Project ANTHROPIC_BASE_URL and a placeholder ANTHROPIC_AUTH_TOKEN into
sandboxes bound to an anthropic-oauth provider so agent CLIs reach the
gateway inference endpoint without manual configuration, login flows, or
API-key confirmation prompts. The auth token is a non-secret constant;
inference.local replaces caller auth with the real subscription token at
the egress boundary.

Resolve ANTHROPIC_BASE_URL to its real value in the sandbox env: the
credential pipeline placeholderizes all provider env values, and a
placeholderized URL cannot be parsed by SDKs at process startup. Auth
values stay egress-time placeholders. Renames
child_env_with_gcp_resolved to child_env_with_static_config_resolved
since it now covers non-GCP static config.

Signed-off-by: Cedric Fitzgerald <soulcedric2@gmail.com>
Make --name and --type optional for provider create with
--from-claude-login, defaulting to claude-subscription/anthropic-oauth.
After creating the provider, configure the user inference route
automatically when none is set (best-effort, unverified), removing the
separate inference set step.

When sandbox create hits a missing anthropic-oauth provider, stop
offering env-credential discovery (subscription login material lives in
the host keychain and cannot be configured from inside a sandbox) and
point at provider create --from-claude-login instead.

Signed-off-by: Cedric Fitzgerald <soulcedric2@gmail.com>
When the anthropic-oauth token endpoint rejects the refresh grant with HTTP 4xx (revoked login, lapsed plan), append recovery guidance to the stored refresh error so provider refresh status tells the operator to re-authenticate on the host and recreate the provider. Transient network errors and 5xx responses are excluded.

Signed-off-by: Cedric Fitzgerald <soulcedric2@gmail.com>
…d recovery

Describe the ANTHROPIC_AUTH_TOKEN env preset and why an API key is not injected, note that the API-usage billing banner in Claude Code is cosmetic while billing goes to the subscription, and add recovery steps for a rejected refresh token. Update the gateway architecture doc to match the env projection behavior.

Signed-off-by: Cedric Fitzgerald <soulcedric2@gmail.com>
@johntmyers
johntmyers force-pushed the feat/anthropic-subscription-oauth branch from 195c591 to c2d3218 Compare July 17, 2026 17:58

@johntmyers johntmyers left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

PR Review Status

Validation: This providers-v2 contribution implements the maintainer-welcomed capability in #1925 while preserving the intended gateway-side credential boundary.
Head SHA: c2d3218e2bbd220eb4a55195e8bec60305e1e814

Review findings: Seven actionable findings are attached inline. The main blockers are cluster-wide subscription authorization and ownership of Claude Code's rotating refresh token. Integration coverage is also needed for the import/refresh/rollback path and for route authorization.

Docs: The new provider guide and gateway architecture update cover the user-facing flow, and docs/index.yml already includes the provider folder. Two accuracy fixes are requested inline.

Thanks @johntmyers. I checked your question about avoiding inference.local: the current implementation needs that route because the real token is excluded from sandbox environment injection and added by the egress router. Mapping directly to Anthropic would require a different endpoint-bound authentication mechanism. Because this PR bakes inference.local into provider injection, automatic cluster routing, architecture, and published UX, its future is a maintainer design decision that should be resolved before merge.

Next state: gator:in-review

Comment thread crates/openshell-cli/src/run.rs
"client_id".to_string(),
ANTHROPIC_OAUTH_CLIENT_ID.to_string(),
);
material.insert("refresh_token".to_string(), creds.refresh_token);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: This copies Claude Code's rotating refresh token into a second independent consumer. The background worker will eventually rotate it, leaving Claude Code's stored copy stale; if Claude Code rotates first, OpenShell becomes stale instead. Acquire a distinct OAuth grant for OpenShell, or make this an explicit ownership-transfer flow rather than duplicating one rotating token.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Valid callout. Anthropic doesn't expose public OAuth client registration for subscription auth. This would be the ideal.

I'll update this to use the claude setup-token flow instead.

Comment thread crates/openshell-cli/src/run.rs Outdated
Comment thread crates/openshell-server/src/provider_refresh.rs Outdated
None
};

if from_claude_login && provider_type != ANTHROPIC_OAUTH_PROVIDER_TYPE {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Low: The advertised claude-subscription provider-type alias is rejected by --from-claude-login because this compares the raw type to the canonical spelling. Validate through normalize_inference_provider_type and consistently pass or store the canonical type.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

cjfit-agent

The type is normalized before the check: run.rs:4886 calls normalize_provider_type, which delegates to normalize_inference_provider_type (openshell-core/src/inference.rs:265) where "claude-subscription" → "anthropic-oauth". So by line 4943, provider_type is already canonical and the guard passes. The bot appears to have missed the normalization at the top of provider_create.

Comment thread docs/providers/anthropic-subscription.mdx Outdated
Comment thread docs/providers/anthropic-subscription.mdx Outdated
@johntmyers johntmyers added gator:in-review Gator is reviewing or awaiting PR review feedback and removed gator:blocked Gator is blocked by process or repository gates labels Jul 17, 2026
@cjfit

cjfit commented Jul 21, 2026

Copy link
Copy Markdown
Author

New provider plugin (openshell-providers): anthropic-oauth with claude-subscription alias. Seeds sandbox env with ANTHROPIC_BASE_URL=https://inference.local/ and an ANTHROPIC_AUTH_TOKEN placeholder. Deliberately does not inject ANTHROPIC_API_KEY

Is this necessary? The future of inference.local is up in the air, so curious if we could just map to the actual Anthropic endpoints instead.

Traffic to api.anthropic.com is TLS end to end, so the proxy can't inject auth in it. The router also adds a required header that subscription tokens require. We'd have to do TLS interception to do this which adds a ton of complexity and security pitfalls. Traffic wouldn't be TLS encrypted to the proxy, plus even if we went that route it'd fail open and not closed creating a debugging nightmare.

it's a pretty thin coupling to inference.local so if were ever renamed or reworked later it's a one-line change.

@pierzchalski

Copy link
Copy Markdown

HTTPS MITM rewriting proxies exist (e.g. hudsucker) so it might not be too difficult to support keeping ANTHROPIC_BASE_URL unchanged and just setting HTTPS_PROXY, right?

But also that's something you could do after merging this inference.local version anyways.

@johntmyers johntmyers left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

PR Review Status

Validation: This providers-v2 contribution implements the maintainer-welcomed capability in #1925 and has a clear user path, security-boundary goal, and published docs.
Head SHA: 664b0a147c21fee2542ff359e71207e55478ca9e

Review findings: Six actionable diff-anchored findings are attached inline. Two additional high-severity general findings remain:

  • GetInferenceBundle authorizes only that the caller is some sandbox, then returns the gateway-wide route and personal subscription credential without checking that the provider is attached to that sandbox (CWE-862/CWE-269). Load the caller's sandbox and scope the user route to its provider attachments, with allow/deny coverage.
  • Inference bundle resolution ignores credential_expires_at_ms, so an expired subscription token can remain distributed and cached after refresh enters reauth_required (CWE-613). Omit expired routes and publish a changed bundle so supervisors clear stale cached credentials.

Thanks @cjfit. I checked your current-head fixes and replies: the missing-provider path now aborts, terminal refresh failures stop automatic retries, alias normalization was already correct, and the token-storage and upstream-revocation docs are corrected. The current head still imports Claude Code's rotating refresh token despite your planned claude setup-token change, and documenting gateway-wide subscription access does not enforce sandbox authorization.

Docs: The Fern provider page, provider-management link, architecture update, and folder-based navigation cover the direct UX. Two documentation corrections are attached inline.

Process blocker: this head also conflicts with main (mergeable: CONFLICTING, mergeStateStatus: DIRTY).

Next state: gator:blocked until the merge conflict is resolved; the code-review findings also require author changes before pipeline watch.

"client_id".to_string(),
ANTHROPIC_OAUTH_CLIENT_ID.to_string(),
);
material.insert("refresh_token".to_string(), creds.refresh_token);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: This gives Claude Code and the gateway shared ownership of one rotating refresh token. When either consumer refreshes, the other copy becomes stale; delaying the first gateway refresh only postpones the failure. Please use the dedicated claude setup-token or another separate credential flow and do not copy Claude Code's live login refresh token.

state.next_refresh_at_ms =
now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000));
persist_refresh_state_if_current(store, &state, expected_version).await?;
put_refresh_state(store, &state).await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: This must remain a version-matched update. An unconditional put can resurrect refresh secret material deleted during the HTTP call or overwrite a newer reconfiguration. Use persist_refresh_state_if_current(..., expected_version), abort on None, and retain the delete/supersede race tests.

openshell_core::inference::normalize_inference_provider_type(&provider.r#type)
== Some("anthropic-oauth")
&& err.code() == tonic::Code::FailedPrecondition
&& err.message().contains("token endpoint returned HTTP 4")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: This also makes 408, 429, and every other 4xx permanently terminal. Parse the OAuth error and reserve reauth_required for terminal grant failures such as invalid_grant; rate limits and timeouts should be rescheduled with bounded backoff.

);
}

#[test]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: This replacement removes extensive unrelated AWS STS, additional-output, collision, session-token, and refresh-race regression coverage. Please restore those tests and add the Anthropic terminal-state test alongside them.


The subscription OAuth token represents your whole plan, so it is shared across every sandbox bound to the provider, and revoking it is provider-wide. Anthropic rotates the refresh token on each refresh; the gateway persists the rotated token so refresh keeps working. The long-lived refresh material lives only in gateway state, never in a sandbox.

To rotate the credential, delete and recreate the provider, or rotate through `openshell provider refresh`. Deleting the provider only removes the gateway's copy of the tokens — it does not revoke the OAuth session at Anthropic. Revocation happens via `claude /logout` or Anthropic's session management.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Low: openshell provider refresh is not executable without a subcommand, provider, and credential key. Please show the exact refresh rotate claude-subscription --credential-key ANTHROPIC_OAUTH_TOKEN and corresponding refresh status commands.


## Prerequisites

- A local Claude Code login on an Anthropic Pro or Max plan. Run `claude login` (or `/login` inside Claude Code) on the host before creating the provider.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Low: This flow depends on undocumented Claude Code credential storage and Anthropic OAuth details. Please identify the integration as experimental, warn that Claude Code upgrades may break import or refresh, and document the supported recovery path.

@johntmyers johntmyers added gator:blocked Gator is blocked by process or repository gates and removed gator:in-review Gator is reviewing or awaiting PR review feedback labels Jul 21, 2026

@johntmyers johntmyers left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

PR Review Status

Validation: This providers-v2 contribution implements the project-valid capability tracked in #1925 with a clear UX path, a gateway-side credential boundary, and published Fern documentation.
Head SHA: 664b0a147c21fee2542ff359e71207e55478ca9e

Fresh independent review: Request changes. Five actionable diff-anchored findings are attached. Three additional general blockers remain:

  • GetInferenceBundle authorizes only that the caller is some sandbox, discards its sandbox_id, and returns the gateway-wide subscription credential to sandboxes that do not attach this provider (CWE-862/CWE-269). Scope the route to the authenticated sandbox's provider attachments and add allow/deny coverage.
  • Expired or reauth_required credentials remain eligible for inference bundle resolution, so supervisors can retain and send stale bearer tokens. Make credential expiry/refresh status part of route validity and return a changed successful bundle with the unusable route omitted so caches are cleared.
  • Direct CreateProvider can store the claude-subscription alias, while refresh-profile lookup uses the raw type and misses the canonical anthropic-oauth defaults. Canonicalize on storage or normalize every catalog lookup, with a gRPC alias test.

Thanks @cjfit. I checked your explanation that the token must be injected through inference.local; that routing choice is compatible with keeping the access token outside the sandbox, but documenting gateway-wide access does not enforce sandbox authorization. I also verified your acknowledged plan to switch to claude setup-token: the current head still copies Claude Code's live rotating refresh token, so that change has not landed yet.

Docs: The new provider page is reachable through the existing folder navigation, but its refresh/status examples do not match the CLI and the credential-source guidance must be revised with the independent credential flow.

Process blocker: this head conflicts with main (mergeable: CONFLICTING, mergeStateStatus: DIRTY).

No local tests were run as part of this code-only review.

Next state: gator:blocked until the merge conflict is resolved; the code-review findings also require author changes before pipeline watch.

"client_id".to_string(),
ANTHROPIC_OAUTH_CLIENT_ID.to_string(),
);
material.insert("refresh_token".to_string(), creds.refresh_token);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Critical: This copies Claude Code's rotating refresh token into a second independent refresher. Delaying the initial rotation does not solve the conflict: whichever process refreshes first invalidates the other copy, so normal Claude Code or gateway activity eventually breaks one side. Import an independent Anthropic-supported credential, such as the planned claude setup-token flow, rather than sharing Claude Code's rotating refresh state.

state.next_refresh_at_ms =
now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000));
persist_refresh_state_if_current(store, &state, expected_version).await?;
put_refresh_state(store, &state).await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Critical: Do not replace the version-matched persist with an unconditional put. If refresh is deleted or reconfigured while the token request is in flight, this recreates deleted state—including secret refresh material—or overwrites the newer generation (CWE-362). Use persist_refresh_state_if_current(store, &state, expected_version) here and abort when it returns None, matching the success path.

openshell_core::inference::normalize_inference_provider_type(&provider.r#type)
== Some("anthropic-oauth")
&& err.code() == tonic::Code::FailedPrecondition
&& err.message().contains("token endpoint returned HTTP 4")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: Treating every HTTP 4xx as terminal misclassifies transient responses such as 408 and 429, plus configuration failures such as invalid_client, as a revoked subscription. Preserve and parse the OAuth error response, enter reauth_required only for an explicit terminal grant error such as invalid_grant, and retry rate limits and other transient statuses with bounded backoff.


To rotate the credential, delete and recreate the provider, or rotate through `openshell provider refresh`. Deleting the provider only removes the gateway's copy of the tokens — it does not revoke the OAuth session at Anthropic. Revocation happens via `claude /logout` or Anthropic's session management.

If the refresh token becomes invalid — you logged out of Claude Code, revoked the session, or the plan lapsed — the provider enters the `reauth_required` state and the gateway stops retrying automatically. `openshell provider refresh status` reports the failure with recovery instructions: run `claude login` on the host, then delete and recreate the provider with `--from-claude-login`. An explicit `openshell provider refresh` retries the stored grant manually.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: These recovery commands do not match the CLI. Status requires a provider name, and rotation is a subcommand requiring the credential key. Document openshell provider refresh status claude-subscription and openshell provider refresh rotate claude-subscription --credential-key ANTHROPIC_OAUTH_TOKEN; fix the bare openshell provider refresh on line 50 as well.

}

#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rotation_does_not_resurrect_refresh_deleted_mid_flight() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: Restore this race regression test and the unrelated AWS refresh tests removed with it. This exact invariant is now violated by the new unconditional error-state write, and deleting the test hides that regression. Add the Anthropic tests alongside the existing AWS, collision, output-mapping, and concurrency coverage.

@johntmyers johntmyers left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

PR Review Status

Validation: This providers-v2 contribution implements the project-valid, maintainer-welcomed capability tracked in #1925, with a clear user path and gateway-side credential boundary.
Head SHA: 664b0a147c21fee2542ff359e71207e55478ca9e

Fresh independent review: Request changes. Seven actionable diff-anchored findings are attached. Three additional general blockers remain:

  • GetInferenceBundle verifies only that the caller is some sandbox, then returns the gateway-wide personal subscription route without checking that the provider is attached to that sandbox (CWE-862/CWE-269). Resolve against the authenticated sandbox ID or an explicit route ACL, and add allow/deny coverage.
  • Inference route resolution ignores credential_expires_at_ms, so an expired subscription token remains distributable and cacheable. Make non-injectable inference credential lookup expiry-aware and publish a changed bundle with the unusable route omitted (CWE-613).
  • Anthropic refresh persistence can crash after saving a rotated refresh token but before installing the matching access token. Make the transition atomic or persist a recoverable pending state that is replayed after restart.

Thanks @cjfit. I checked your stated plan to move to claude setup-token; this head still copies Claude Code's live rotating refresh token, so the two consumers can invalidate one another. I also checked your gateway-wide access explanation: documenting the exposure does not enforce sandbox authorization.

@johntmyers, I re-checked your question about inference.local. A local egress route is compatible with keeping the token out of the sandbox, but this provider currently also accepts an ANTHROPIC_BASE_URL override for the final upstream, which can redirect the Bearer token and must be constrained.

Docs: The Fern page is reachable through the existing provider-folder navigation, but its retry command is not executable and the credential-source, authorization, and recovery text must follow the corrected implementation.

Process blocker: this head still conflicts with main (mergeable: CONFLICTING, mergeStateStatus: DIRTY). No local tests were run as part of this code-only review.

Next state: gator:blocked until the merge conflict is resolved; the code-review findings also require author changes before pipeline watch.

state.next_refresh_at_ms =
now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000));
persist_refresh_state_if_current(store, &state, expected_version).await?;
put_refresh_state(store, &state).await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Critical: Keep this write version-matched. An unconditional upsert can overwrite a concurrent successful rotation with stale refresh material or resurrect state deleted while the token request was in flight (CWE-362). Use persist_refresh_state_if_current(..., expected_version) and abort when the generation was deleted or superseded.

"client_id".to_string(),
ANTHROPIC_OAUTH_CLIENT_ID.to_string(),
);
material.insert("refresh_token".to_string(), creds.refresh_token);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: This still gives OpenShell Claude Code's rotating login refresh token. Whichever consumer refreshes first invalidates the other's copy. Implement the acknowledged dedicated claude setup-token flow, or another grant owned exclusively by OpenShell, before enabling background refresh.

default_base_url: "https://api.anthropic.com/v1",
protocols: ANTHROPIC_PROTOCOLS,
credential_key_names: &[ANTHROPIC_OAUTH_TOKEN_KEY],
base_url_config_keys: &["ANTHROPIC_BASE_URL"],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: Do not honor a configurable ANTHROPIC_BASE_URL for this subscription Bearer credential. Pin anthropic-oauth to the supported HTTPS Anthropic host and validate the final URL before attaching auth; otherwise provider config can redirect the personal subscription token to an arbitrary upstream (CWE-200/CWE-918).

gateway_remote_label, gateway_select_with, gateway_to_json, gateway_type_label,
git_sync_files, http_health_check, import_local_package_mtls_bundle,
inferred_provider_type, mtls_certs_exist_for_gateway, package_managed_tls_dirs,
gateway_auth_label, gateway_env_override_warning, gateway_select_with, gateway_to_json,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: Restore gateway_info_to_json and gateway_remote_label in this use super::{...} list. Tests later call both names unqualified, and this module has no use super::*, so the test target will not compile.

openshell_core::inference::normalize_inference_provider_type(&provider.r#type)
== Some("anthropic-oauth")
&& err.code() == tonic::Code::FailedPrecondition
&& err.message().contains("token endpoint returned HTTP 4")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: Parse the OAuth error payload and enter reauth_required only for a terminal grant error such as invalid_grant. Matching every HTTP 4xx permanently stops retries for transient 408/429 responses and server-fixable request or client errors.

}

#[test]
fn refresh_strategy_name_includes_aws_sts() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: Restore the unrelated AWS STS and refresh-race regression tests removed by this change. The deletion drops coverage for multi-output minting, partial credentials, session tokens, attached-sandbox collisions, and delete/supersede races; add the Anthropic tests alongside that coverage.


To rotate the credential, delete and recreate the provider, or rotate through `openshell provider refresh`. Deleting the provider only removes the gateway's copy of the tokens — it does not revoke the OAuth session at Anthropic. Revocation happens via `claude /logout` or Anthropic's session management.

If the refresh token becomes invalid — you logged out of Claude Code, revoked the session, or the plan lapsed — the provider enters the `reauth_required` state and the gateway stops retrying automatically. `openshell provider refresh status` reports the failure with recovery instructions: run `claude login` on the host, then delete and recreate the provider with `--from-claude-login`. An explicit `openshell provider refresh` retries the stored grant manually.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Low: Replace the nonexistent bare retry command with openshell provider refresh rotate claude-subscription --credential-key ANTHROPIC_OAUTH_TOKEN. provider refresh requires a subcommand, so the documented recovery path currently only prints help or an error.

@johntmyers johntmyers left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

PR Review Status

Validation: This Providers v2 contribution implements the project-valid, maintainer-welcomed capability tracked in #1925, with a clear user path and gateway-side credential goal.
Head SHA: 664b0a147c21fee2542ff359e71207e55478ca9e

Fresh independent review: Request changes. Ten actionable diff-anchored findings are attached. The main blockers are sandbox authorization for the gateway-wide subscription route, endpoint-scoped bearer-token injection, expiry enforcement for cached routes, and race-safe ownership/persistence of rotating credentials.

One additional general compatibility finding remains: the implementation depends on a hard-coded public client ID, private Claude credential schema, token endpoint, and beta header without a stable upstream contract. Prefer the dedicated claude setup-token workflow @cjfit already acknowledged; otherwise mark the provider experimental, centralize the versioned contract, and provide compatibility-specific recovery.

Thanks @cjfit. I checked your current-head claims: CLI alias normalization and the missing-provider abort are present, and the storage/revocation docs were updated. The direct provider RPC alias path remains incomplete, the 4xx terminal classification is too broad, and the acknowledged claude setup-token change has not landed.

@johntmyers, I re-checked your inference.local question. A local egress route is compatible with keeping the token outside the sandbox, but the OAuth profile accepts an unrestricted final ANTHROPIC_BASE_URL, so endpoint scoping must be enforced before bearer auth is attached.

Docs: Fern navigation covers the new provider page, but the gateway-wide warning documents rather than fixes authorization exposure, and the logout/recovery guidance does not reliably revoke a gateway-rotated grant.

Process blocker: this head conflicts with main (mergeable: CONFLICTING, mergeStateStatus: DIRTY). No local tests were run as part of this code-only review.

Next state: gator:blocked until the merge conflict is resolved; the review findings also require author changes before pipeline watch.

state.next_refresh_at_ms =
now_ms.saturating_add(REFRESH_ERROR_RETRY_SECONDS.saturating_mul(1000));
persist_refresh_state_if_current(store, &state, expected_version).await?;
put_refresh_state(store, &state).await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Critical: put_refresh_state is an unconditional upsert. If a refresh is deleted or reconfigured while the token request is in flight and that request fails, this resurrects or overwrites refresh state, including secret material. Restore persist_refresh_state_if_current(store, &state, expected_version), abort on None, and add failure-path delete/supersede race tests (CWE-362).

default_base_url: "https://api.anthropic.com/v1",
protocols: ANTHROPIC_PROTOCOLS,
credential_key_names: &[ANTHROPIC_OAUTH_TOKEN_KEY],
base_url_config_keys: &["ANTHROPIC_BASE_URL"],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: Subscription credentials must not honor an unrestricted ANTHROPIC_BASE_URL. The generic resolver accepts this config verbatim, after which bearer auth sends the gateway-held token to that origin. Pin this profile to https://api.anthropic.com/v1, or strictly allow-list scheme, host, port, and path before producing the bundle; add a hostile-override test (CWE-918/CWE-200).

// The Anthropic subscription OAuth access token is injected as a Bearer
// header at the egress boundary only. It must never reach the sandbox
// environment, so the user never authenticates inside the sandbox.
Some("anthropic-oauth") => key == openshell_core::inference::ANTHROPIC_OAUTH_TOKEN_KEY,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: Protecting only ANTHROPIC_OAUTH_TOKEN is insufficient because inference resolution can fall back from the preferred key to any provider credential. An anthropic-oauth provider created with another secret key can export that key to the sandbox and also use it as the upstream bearer token. Require preferred-only lookup for this type and reject unexpected credential keys at create/update; cover direct gRPC creation (CWE-200).

}

match client
.set_cluster_inference(SetClusterInferenceRequest {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: Do not automatically expose a personal subscription through the cluster-wide route without caller authorization. GetInferenceBundle returns managed routes to any authenticated sandbox, even when that sandbox did not attach this provider. Filter bundles using the sandbox principal and provider bindings, or introduce sandbox-scoped routes, then test that an unattached sandbox is denied (CWE-862; OWASP LLM06).

credentials: credential_map,
config: config_map,
credential_expires_at_ms: HashMap::new(),
credential_expires_at_ms,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: Recording the expiry does not make inference fail closed. Route resolution ignores credential_expires_at_ms, and supervisors can retain cached tokens when bundle refresh fails. Carry expiry in the bundle, omit expired routes gateway-side, and enforce the deadline locally so the cached route is disabled even during gateway failure; add a refresh-failure-then-expiry test (CWE-613).

"client_id".to_string(),
ANTHROPIC_OAUTH_CLIENT_ID.to_string(),
);
material.insert("refresh_token".to_string(), creds.refresh_token);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

High: Do not copy Claude Code's live rotating refresh token. Whichever process refreshes first invalidates the other copy, and a gateway crash after upstream rotation but before persistence can lose the valid grant. Implement the acknowledged claude setup-token flow with gateway-owned material; otherwise this needs explicit ownership transfer and crash-recovery semantics (CWE-362/CWE-664).

openshell_core::inference::normalize_inference_provider_type(&provider.r#type)
== Some("anthropic-oauth")
&& err.code() == tonic::Code::FailedPrecondition
&& err.message().contains("token endpoint returned HTTP 4")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: This classifies every 4xx, including transient 408 and 429 responses, as permanent reauthentication failure. Parse a bounded OAuth error response and terminalize only an explicit invalid_grant or equivalent authentication rejection; retain retry scheduling for rate limiting and transient statuses (CWE-755).

match input.trim().to_ascii_lowercase().as_str() {
"openai" => Some("openai"),
"anthropic" => Some("anthropic"),
"anthropic-oauth" | "claude-subscription" => Some("anthropic-oauth"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: The alias is not normalized across direct provider RPCs. A provider stored as claude-subscription resolves for inference and environment injection, but refresh-default lookup uses the raw type and misses the profile/token URL. Canonicalize provider types at create time or normalize every catalog lookup; add direct gRPC create/configure/rotate coverage.

}

#[tokio::test]
async fn aws_sts_assume_role_mints_three_credentials_from_mock_endpoint() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: Restore the unrelated AWS STS and refresh-concurrency tests removed by this hunk and add the Anthropic test separately. The deleted coverage verifies multi-output minting, partial-source rejection, collision checks, session-token handling, and delete/supersede races.


The subscription OAuth token represents your whole plan, so it is shared across every sandbox bound to the provider, and revoking it is provider-wide. Anthropic rotates the refresh token on each refresh; the gateway persists the rotated token so refresh keeps working. The long-lived refresh material lives only in gateway state, never in a sandbox.

To rotate the credential, delete and recreate the provider, or rotate through `openshell provider refresh`. Deleting the provider only removes the gateway's copy of the tokens — it does not revoke the OAuth session at Anthropic. Revocation happens via `claude /logout` or Anthropic's session management.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

gator-agent

Warning: claude /logout cannot reliably revoke the gateway's grant after the gateway rotates the copied refresh token, because Claude Code then retains the invalidated predecessor. Remove this recovery claim or switch to the dedicated setup-token/session flow and document a revocation mechanism that targets the gateway-owned session.

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

Labels

gator:blocked Gator is blocked by process or repository gates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: oauth support for Claude Pro/Max subscription plans

4 participants