From fd79b7cc26ea16fb42b22ee7382bec7ef71ccee9 Mon Sep 17 00:00:00 2001 From: Cedric Fitzgerald Date: Mon, 15 Jun 2026 22:00:37 -0400 Subject: [PATCH 1/6] feat(providers): add anthropic-oauth provider for subscription plan auth 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 --- architecture/gateway.md | 17 +- crates/openshell-cli/src/main.rs | 18 +- crates/openshell-cli/src/run.rs | 327 +++++++++++++++++- .../tests/provider_commands_integration.rs | 1 + crates/openshell-core/src/inference.rs | 73 ++++ crates/openshell-providers/src/profiles.rs | 1 + crates/openshell-router/src/backend.rs | 131 ++++++- crates/openshell-server/src/grpc/provider.rs | 72 +++- docs/providers/anthropic-subscription.mdx | 100 ++++++ docs/sandboxes/manage-providers.mdx | 3 +- providers/anthropic-oauth.yaml | 36 ++ 11 files changed, 766 insertions(+), 13 deletions(-) create mode 100644 docs/providers/anthropic-subscription.mdx create mode 100644 providers/anthropic-oauth.yaml diff --git a/architecture/gateway.md b/architecture/gateway.md index d873b2a105..0c1ec26e31 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -282,7 +282,8 @@ Cluster inference routes store only `provider_name`, `model_id`, and optional timeout. The gateway resolves endpoint URLs, protocols, credentials, auth style, and route-shaping metadata from the provider record when supervisors call `GetInferenceBundle`. Supported provider types for cluster inference are -`openai`, `anthropic`, `nvidia`, `deepinfra`, and `google-vertex-ai`. +`openai`, `anthropic`, `anthropic-oauth`, `nvidia`, `deepinfra`, and +`google-vertex-ai`. The bundle carries enough information for sandbox-local routers to construct upstream URLs without re-deriving provider-specific routing logic. Each resolved @@ -340,6 +341,20 @@ back the provider record. Service-account JSON and private keys are gateway-side refresh bootstrap material only; sandbox runtime inference receives minted access tokens, not raw service-account material. +The `anthropic-oauth` provider type (alias `claude-plan`) routes the direct +Anthropic API with a subscription OAuth token instead of an API key. Its +inference profile uses `Authorization: Bearer` plus a mandatory +`anthropic-beta: oauth-2025-04-20` default header; the sandbox router merges that +flag with any client-sent `anthropic-beta` value so the sandbox cannot drop it. +The CLI `--from-claude-login` harvests the local Claude Code login (macOS +Keychain `Claude Code-credentials` or `~/.claude/.credentials.json`) on the host, +stores the access token under the non-injectable `ANTHROPIC_OAUTH_TOKEN` +credential, and calls `ConfigureProviderRefresh` with the subscription's refresh +token (Anthropic public `client_id` only, no secret). The background refresh +worker rotates the access token ahead of expiry and persists Anthropic's rotated +refresh token. The access token is never exported into sandbox environments; it +rides the inference route bundle and is injected only at the egress boundary. + ## Supervisor Relay Sandbox workloads maintain an outbound supervisor session to the gateway. This diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 66484562d4..96c3763459 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -747,7 +747,7 @@ impl From for openshell_cli::ssh::Editor { #[derive(Subcommand, Debug)] enum ProviderCommands { /// Create a provider config. - #[command(group = clap::ArgGroup::new("cred_source").required(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + #[command(group = clap::ArgGroup::new("cred_source").required(true).args(["from_existing", "credentials", "from_gcloud_adc", "from_claude_login", "runtime_credentials"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Create { /// Provider name. #[arg(long)] @@ -758,25 +758,31 @@ enum ProviderCommands { provider_type: String, /// Load provider credentials/config from existing local state. - #[arg(long, conflicts_with_all = ["credentials", "from_gcloud_adc", "runtime_credentials"])] + #[arg(long, conflicts_with_all = ["credentials", "from_gcloud_adc", "from_claude_login", "runtime_credentials"])] from_existing: bool, /// Provider credential pair (`KEY=VALUE`) or env lookup key (`KEY`). #[arg( long = "credential", value_name = "KEY[=VALUE]", - conflicts_with_all = ["from_existing", "from_gcloud_adc", "runtime_credentials"] + conflicts_with_all = ["from_existing", "from_gcloud_adc", "from_claude_login", "runtime_credentials"] )] credentials: Vec, /// Configure credentials from gcloud Application Default Credentials /// (`~/.config/gcloud/application_default_credentials.json`). /// Valid for providers whose profile declares an ADC-compatible credential. - #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "runtime_credentials"])] + #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "from_claude_login", "runtime_credentials"])] from_gcloud_adc: bool, + /// Configure credentials from the local Claude Code subscription login + /// (macOS Keychain or `~/.claude/.credentials.json`). + /// Only valid for anthropic-oauth providers. + #[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials"])] + from_claude_login: bool, + /// Create a provider whose required credentials are resolved at runtime by the gateway/sandbox. - #[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc"])] + #[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc", "from_claude_login"])] runtime_credentials: bool, /// Provider config key/value pair. @@ -2893,6 +2899,7 @@ async fn main() -> Result<()> { from_existing, credentials, from_gcloud_adc, + from_claude_login, runtime_credentials, config, } => { @@ -2903,6 +2910,7 @@ async fn main() -> Result<()> { from_existing, &credentials, from_gcloud_adc, + from_claude_login, runtime_credentials, &config, &tls, diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 0182e1b668..a487f76f7c 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4463,6 +4463,178 @@ fn read_gcloud_adc() -> Result<(String, String, String)> { Ok((client_id, client_secret, refresh_token)) } +/// Canonical provider type string for the Anthropic subscription OAuth flow. +const ANTHROPIC_OAUTH_PROVIDER_TYPE: &str = "anthropic-oauth"; + +/// Anthropic's public OAuth client ID used by Claude Code. The refresh grant +/// requires only this client ID (no client secret). +const ANTHROPIC_OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; + +/// OAuth credentials harvested from a local Claude Code subscription login. +struct ClaudeOauthCredentials { + access_token: String, + refresh_token: String, + /// Access-token expiry in epoch milliseconds (0 if unknown). + expires_at_ms: i64, +} + +/// Read the Anthropic subscription OAuth credentials from the local Claude Code +/// login. Prefers the macOS Keychain item, falling back to +/// `~/.claude/.credentials.json`. +fn read_claude_oauth() -> Result { + if let Some(raw) = read_claude_credentials_keychain()? { + return parse_claude_oauth_json(&raw); + } + if let Some(raw) = read_claude_credentials_file()? { + return parse_claude_oauth_json(&raw); + } + Err(miette::miette!( + "no local Claude Code subscription login found. \ + Run `claude login` (or `claude /login`) on this host first, then retry. \ + Looked in the macOS Keychain item 'Claude Code-credentials' and \ + ~/.claude/.credentials.json" + )) +} + +/// Read the raw credentials JSON from `~/.claude/.credentials.json`, if present. +fn read_claude_credentials_file() -> Result> { + let home = std::env::var("HOME") + .map_err(|_| miette::miette!("HOME is not set; cannot locate the Claude Code login"))?; + let path = PathBuf::from(home) + .join(".claude") + .join(".credentials.json"); + match std::fs::read_to_string(&path) { + Ok(content) => Ok(Some(content)), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), + Err(err) => Err(miette::miette!( + "failed to read Claude Code credentials file at {}: {err}", + path.display() + )), + } +} + +/// Read the raw credentials JSON from the macOS Keychain. Returns `None` on +/// non-macOS hosts or when the item is absent. +#[cfg(target_os = "macos")] +fn read_claude_credentials_keychain() -> Result> { + let output = Command::new("security") + .args([ + "find-generic-password", + "-s", + "Claude Code-credentials", + "-w", + ]) + .output(); + match output { + Ok(out) if out.status.success() => { + let raw = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if raw.is_empty() { + Ok(None) + } else { + Ok(Some(raw)) + } + } + // Non-zero exit means the item was not found; fall through to the file. + Ok(_) => Ok(None), + Err(err) => Err(miette::miette!( + "failed to query the macOS Keychain for the Claude Code login: {err}" + )), + } +} + +#[cfg(not(target_os = "macos"))] +fn read_claude_credentials_keychain() -> Result> { + Ok(None) +} + +/// Parse the Claude Code credentials JSON, extracting the access token, refresh +/// token, and normalized access-token expiry. +fn parse_claude_oauth_json(raw: &str) -> Result { + let json: serde_json::Value = serde_json::from_str(raw) + .map_err(|err| miette::miette!("failed to parse Claude Code credentials: {err}"))?; + + let oauth = json.get("claudeAiOauth").ok_or_else(|| { + miette::miette!( + "Claude Code credentials are missing the 'claudeAiOauth' object; \ + the local login may be from an incompatible Claude Code version" + ) + })?; + + let access_token = oauth + .get("accessToken") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| miette::miette!("Claude Code login is missing 'accessToken'"))? + .to_string(); + + let refresh_token = oauth + .get("refreshToken") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + miette::miette!( + "Claude Code login is missing 'refreshToken'; \ + gateway-managed refresh requires it" + ) + })? + .to_string(); + + let expires_at_ms = oauth + .get("expiresAt") + .and_then(serde_json::Value::as_i64) + .map_or(0, normalize_expires_at_ms); + + Ok(ClaudeOauthCredentials { + access_token, + refresh_token, + expires_at_ms, + }) +} + +/// Normalize an `expiresAt` value to epoch milliseconds. Claude Code stores +/// milliseconds, but tolerate a seconds-valued timestamp by scaling it up. +fn normalize_expires_at_ms(value: i64) -> i64 { + if value > 0 && value < 1_000_000_000_000 { + value * 1000 + } else { + value + } +} + +async fn rollback_provider_create_after_claude_login_failure( + client: &mut crate::tls::GrpcClient, + provider_name: &str, + source: &Status, +) -> Result<()> { + match client + .delete_provider(DeleteProviderRequest { + name: provider_name.to_string(), + }) + .await + { + Ok(_) => Err(miette!( + "failed to configure Anthropic subscription refresh from the local Claude Code login \ + for provider '{provider_name}': {source}. The provider was rolled back successfully." + )), + Err(cleanup_err) => { + eprintln!( + "{} Failed to clean up provider '{}' after configuring refresh failed: {}. \ + Run 'openshell provider delete {}' to remove it manually.", + "⚠".yellow(), + provider_name, + cleanup_err, + provider_name + ); + Err(miette!( + "failed to configure Anthropic subscription refresh from the local Claude Code login \ + for provider '{provider_name}': {source}. \ + Cleanup also failed, so the provider may still exist. \ + Run 'openshell provider delete {provider_name}' to remove it manually." + )) + } + } +} + async fn rollback_provider_create_after_gcloud_adc_failure( client: &mut crate::tls::GrpcClient, provider_name: &str, @@ -4646,6 +4818,7 @@ pub async fn provider_create( credentials, from_gcloud_adc, false, + false, config, tls, ) @@ -4653,6 +4826,7 @@ pub async fn provider_create( } #[allow(clippy::too_many_arguments)] +#[allow(clippy::fn_params_excessive_bools)] pub async fn provider_create_with_options( server: &str, name: &str, @@ -4660,6 +4834,7 @@ pub async fn provider_create_with_options( from_existing: bool, credentials: &[String], from_gcloud_adc: bool, + from_claude_login: bool, runtime_credentials: bool, config: &[String], tls: &TlsOptions, @@ -4669,6 +4844,11 @@ pub async fn provider_create_with_options( "--from-gcloud-adc cannot be combined with --from-existing or --credential; it also cannot be combined with --runtime-credentials" )); } + if from_claude_login && (from_existing || !credentials.is_empty() || runtime_credentials) { + return Err(miette::miette!( + "--from-claude-login cannot be combined with --from-existing, --credential, or --runtime-credentials" + )); + } if from_existing && (!credentials.is_empty() || runtime_credentials) { return Err(miette::miette!( "--from-existing cannot be combined with --credential or --runtime-credentials" @@ -4739,6 +4919,12 @@ pub async fn provider_create_with_options( None }; + if from_claude_login && provider_type != ANTHROPIC_OAUTH_PROVIDER_TYPE { + return Err(miette::miette!( + "--from-claude-login is only valid for anthropic-oauth providers" + )); + } + let mut credential_map = parse_credential_pairs(credentials)?; let mut config_map = parse_key_value_pairs(config, "--config")?; @@ -4758,6 +4944,22 @@ pub async fn provider_create_with_options( } } + // Read the local Claude Code login BEFORE creating the provider so a missing + // or malformed credential does not leave an orphan provider behind. The + // access token is stored as the provider credential; it is marked + // non-injectable server-side, so it is only ever injected at the egress + // boundary and never inside the sandbox. + let claude_login_material = if from_claude_login { + let creds = read_claude_oauth()?; + credential_map.insert( + openshell_core::inference::ANTHROPIC_OAUTH_TOKEN_KEY.to_string(), + creds.access_token.clone(), + ); + Some(creds) + } else { + None + }; + if credential_map.is_empty() { if from_existing { return Err(missing_credentials_error(&provider_type)); @@ -4796,6 +4998,19 @@ pub async fn provider_create_with_options( None }; + // Seed the initial access-token expiry so the gateway's background refresh + // worker knows when to mint a fresh token. Sourced from the local Claude + // Code login's `expiresAt`. + let mut credential_expires_at_ms = HashMap::new(); + if let Some(creds) = &claude_login_material + && creds.expires_at_ms > 0 + { + credential_expires_at_ms.insert( + openshell_core::inference::ANTHROPIC_OAUTH_TOKEN_KEY.to_string(), + creds.expires_at_ms, + ); + } + let response = client .create_provider(CreateProviderRequest { provider: Some(Provider { @@ -4809,7 +5024,7 @@ pub async fn provider_create_with_options( r#type: provider_type.clone(), credentials: credential_map, config: config_map, - credential_expires_at_ms: HashMap::new(), + credential_expires_at_ms, }), }) .await @@ -4873,6 +5088,42 @@ pub async fn provider_create_with_options( return Ok(()); } + if let Some(creds) = claude_login_material { + let mut material = HashMap::new(); + material.insert( + "client_id".to_string(), + ANTHROPIC_OAUTH_CLIENT_ID.to_string(), + ); + material.insert("refresh_token".to_string(), creds.refresh_token); + + // No initial rotate: the access token harvested from the local login is + // already valid, and rotating now would invalidate the user's local + // Claude Code refresh token (Anthropic rotates refresh tokens on use). + // The background worker refreshes only as the token nears expiry. + if let Err(configure_err) = client + .configure_provider_refresh(ConfigureProviderRefreshRequest { + provider: provider_name.clone(), + credential_key: openshell_core::inference::ANTHROPIC_OAUTH_TOKEN_KEY.to_string(), + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, + material, + secret_material_keys: vec!["refresh_token".to_string()], + expires_at_ms: (creds.expires_at_ms > 0).then_some(creds.expires_at_ms), + }) + .await + { + return rollback_provider_create_after_claude_login_failure( + &mut client, + &provider_name, + &configure_err, + ) + .await; + } + + println!("{} Created provider {}", "✓".green().bold(), provider_name); + println!("Configured Anthropic subscription credentials from the local Claude Code login"); + return Ok(()); + } + println!("{} Created provider {}", "✓".green().bold(), provider_name); Ok(()) } @@ -9377,6 +9628,80 @@ mod tests { ); } + #[test] + fn parse_claude_oauth_json_extracts_fields() { + let raw = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat-test", + "refreshToken": "sk-ant-ort-test", + "expiresAt": 1_750_000_000_000_i64, + "scopes": ["user:inference"], + "subscriptionType": "pro" + } + }) + .to_string(); + let creds = super::parse_claude_oauth_json(&raw).expect("valid login should parse"); + assert_eq!(creds.access_token, "sk-ant-oat-test"); + assert_eq!(creds.refresh_token, "sk-ant-ort-test"); + assert_eq!(creds.expires_at_ms, 1_750_000_000_000_i64); + } + + #[test] + fn parse_claude_oauth_json_normalizes_seconds_expiry() { + let raw = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat-test", + "refreshToken": "sk-ant-ort-test", + "expiresAt": 1_750_000_000_i64 + } + }) + .to_string(); + let creds = super::parse_claude_oauth_json(&raw).expect("valid login should parse"); + assert_eq!(creds.expires_at_ms, 1_750_000_000_000_i64); + } + + #[test] + fn parse_claude_oauth_json_missing_refresh_token_errors() { + let raw = serde_json::json!({ + "claudeAiOauth": { + "accessToken": "sk-ant-oat-test" + } + }) + .to_string(); + let err = super::parse_claude_oauth_json(&raw) + .err() + .expect("missing refresh token errors"); + assert!( + err.to_string().contains("refreshToken"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_claude_oauth_json_missing_oauth_object_errors() { + let raw = serde_json::json!({ "somethingElse": {} }).to_string(); + let err = super::parse_claude_oauth_json(&raw) + .err() + .expect("missing oauth object should error"); + assert!( + err.to_string().contains("claudeAiOauth"), + "unexpected error: {err}" + ); + } + + #[test] + fn normalize_expires_at_ms_scales_seconds_only() { + assert_eq!(super::normalize_expires_at_ms(0), 0); + assert_eq!( + super::normalize_expires_at_ms(1_700_000_000), + 1_700_000_000_000 + ); + assert_eq!( + super::normalize_expires_at_ms(1_700_000_000_000), + 1_700_000_000_000 + ); + } + #[test] fn empty_provider_credentials_allow_oauth2_refresh_token() { use openshell_core::proto::{ diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 53b178acbb..7093f224bb 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -1381,6 +1381,7 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() false, &[], false, + false, true, &[], &ts.tls, diff --git a/crates/openshell-core/src/inference.rs b/crates/openshell-core/src/inference.rs index 2be79d45ee..2e556a1c16 100644 --- a/crates/openshell-core/src/inference.rs +++ b/crates/openshell-core/src/inference.rs @@ -108,6 +108,40 @@ static ANTHROPIC_PROFILE: InferenceProviderProfile = InferenceProviderProfile { passthrough_headers: &["anthropic-version", "anthropic-beta"], }; +/// Credential key holding the Anthropic subscription ("plan") OAuth access token. +/// +/// Written by the gateway's `OAuth2` refresh worker via the `--from-claude-login` +/// CLI flow and consumed by [`ANTHROPIC_OAUTH_PROFILE`]. This credential is +/// proxy-only: it is injected as an `Authorization: Bearer` header at the egress +/// boundary and is never exported into the sandbox environment. +pub const ANTHROPIC_OAUTH_TOKEN_KEY: &str = "ANTHROPIC_OAUTH_TOKEN"; + +/// The `anthropic-beta` flag required for requests authenticated with an +/// Anthropic subscription OAuth token (as opposed to an API key). +pub const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20"; + +/// Inference profile for the Anthropic subscription ("plan") OAuth flow. +/// +/// Differs from [`ANTHROPIC_PROFILE`] in two ways the Anthropic API requires for +/// subscription tokens: the token is sent as `Authorization: Bearer ` +/// (not `x-api-key`), and every request must carry `anthropic-beta: +/// oauth-2025-04-20`. The beta flag is a default header so the boundary forces it +/// even when the sandbox client does not send it; the router merges it with any +/// client-supplied `anthropic-beta` value. +static ANTHROPIC_OAUTH_PROFILE: InferenceProviderProfile = InferenceProviderProfile { + provider_type: "anthropic-oauth", + 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"], + auth: AuthHeader::Bearer, + default_headers: &[ + ("anthropic-version", "2023-06-01"), + ("anthropic-beta", ANTHROPIC_OAUTH_BETA), + ], + passthrough_headers: &["anthropic-version", "anthropic-beta"], +}; + /// Credential environment variable names for the Vertex AI provider, in priority order. /// /// These are referenced by both the provider discovery logic in `openshell-providers` @@ -220,6 +254,7 @@ pub fn normalize_inference_provider_type(input: &str) -> Option<&'static str> { match input.trim().to_ascii_lowercase().as_str() { "openai" => Some("openai"), "anthropic" => Some("anthropic"), + "anthropic-oauth" | "claude-plan" => Some("anthropic-oauth"), "nvidia" => Some("nvidia"), "deepinfra" => Some("deepinfra"), "aws-bedrock" => Some("aws-bedrock"), @@ -238,6 +273,7 @@ pub fn profile_for(provider_type: &str) -> Option<&'static InferenceProviderProf match normalize_inference_provider_type(provider_type)? { "openai" => Some(&OPENAI_PROFILE), "anthropic" => Some(&ANTHROPIC_PROFILE), + "anthropic-oauth" => Some(&ANTHROPIC_OAUTH_PROFILE), "nvidia" => Some(&NVIDIA_PROFILE), "deepinfra" => Some(&DEEPINFRA_PROFILE), "google-vertex-ai" => Some(&VERTEX_AI_PROFILE), @@ -476,6 +512,43 @@ mod tests { assert!(headers.is_empty()); } + #[test] + fn profile_for_anthropic_oauth_types() { + for key in &["anthropic-oauth", "claude-plan", "Anthropic-OAuth"] { + let profile = profile_for(key).expect("anthropic-oauth profile should be Some"); + assert_eq!(profile.provider_type, "anthropic-oauth"); + } + } + + #[test] + fn normalize_aliases_claude_plan_to_anthropic_oauth() { + assert_eq!( + normalize_inference_provider_type("claude-plan"), + Some("anthropic-oauth") + ); + assert_eq!( + normalize_inference_provider_type("anthropic-oauth"), + Some("anthropic-oauth") + ); + } + + #[test] + fn auth_for_anthropic_oauth_uses_bearer_with_default_headers() { + let (auth, headers) = auth_for_provider_type("anthropic-oauth"); + assert_eq!(auth, AuthHeader::Bearer); + assert!( + headers + .iter() + .any(|(k, v)| *k == "anthropic-version" && *v == "2023-06-01") + ); + assert!( + headers + .iter() + .any(|(k, v)| *k == "anthropic-beta" && *v == ANTHROPIC_OAUTH_BETA), + "anthropic-beta must be a default header so the sandbox cannot omit it" + ); + } + #[test] fn route_headers_for_vertex_anthropic_route_forward_beta_only() { let (_, headers, passthrough_headers) = diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index ddfbcaf7dc..5666cda2de 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -21,6 +21,7 @@ use std::sync::OnceLock; const PATH_TEMPLATE_CREDENTIAL_PLACEHOLDER: &str = "{credential}"; const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ + include_str!("../../../providers/anthropic-oauth.yaml"), include_str!("../../../providers/aws-bedrock.yaml"), include_str!("../../../providers/claude-code.yaml"), include_str!("../../../providers/codex.yaml"), diff --git a/crates/openshell-router/src/backend.rs b/crates/openshell-router/src/backend.rs index 9fbd538c06..7d7469aa4b 100644 --- a/crates/openshell-router/src/backend.rs +++ b/crates/openshell-router/src/backend.rs @@ -82,6 +82,23 @@ const VERTEX_ANTHROPIC_VERSION: &str = "vertex-2023-10-16"; const COMMON_INFERENCE_REQUEST_HEADERS: [&str; 4] = ["content-type", "accept", "accept-encoding", "user-agent"]; +/// The Anthropic beta-flags header. Its value is a comma-separated token list, +/// so route-forced flags must be merged with client-supplied ones rather than +/// overwriting them. +const ANTHROPIC_BETA_HEADER: &str = "anthropic-beta"; + +/// Merge a comma-separated `anthropic-beta` value into `tokens`, preserving +/// order and dropping case-insensitive duplicates and empty entries. +fn merge_beta_tokens(tokens: &mut Vec, raw: &str) { + for token in raw.split(',') { + let token = token.trim(); + if token.is_empty() || tokens.iter().any(|t| t.eq_ignore_ascii_case(token)) { + continue; + } + tokens.push(token.to_string()); + } +} + impl StreamingProxyResponse { /// Create from a fully-buffered [`ProxyResponse`] (for mock routes). pub fn from_buffered(resp: ProxyResponse) -> Self { @@ -251,19 +268,38 @@ fn prepare_backend_request( // (SigV4 signing is a separate follow-up). } } + // `anthropic-beta` is handled separately below: a route may both forward a + // client-supplied value and force its own (e.g. the Anthropic OAuth profile + // must always send `oauth-2025-04-20`). Applying it in this loop would emit a + // duplicate header, so collect its tokens instead of forwarding here. + let mut beta_tokens: Vec = Vec::new(); for (name, value) in &headers { - builder = builder.header(name.as_str(), value.as_str()); + if name.eq_ignore_ascii_case(ANTHROPIC_BETA_HEADER) { + merge_beta_tokens(&mut beta_tokens, value); + } else { + builder = builder.header(name.as_str(), value.as_str()); + } } // Apply route-level default headers (e.g. anthropic-version) unless - // the client already sent them. + // the client already sent them. `anthropic-beta` defaults are merged into + // the client's value rather than skipped, so a forced beta flag survives + // alongside any betas the client requested. for (name, value) in &route.default_headers { + if name.eq_ignore_ascii_case(ANTHROPIC_BETA_HEADER) { + merge_beta_tokens(&mut beta_tokens, value); + continue; + } let already_sent = headers.iter().any(|(h, _)| h.eq_ignore_ascii_case(name)); if !already_sent { builder = builder.header(name.as_str(), value.as_str()); } } + if !beta_tokens.is_empty() { + builder = builder.header(ANTHROPIC_BETA_HEADER, beta_tokens.join(",")); + } + // Rewrite the JSON body for backend compatibility: // - Standard routes: set "model" to the route's configured model so the // backend receives the correct model ID regardless of what the client sent. @@ -1904,6 +1940,97 @@ mod tests { ); } + /// The anthropic-oauth route carries `anthropic-beta: oauth-2025-04-20` as a + /// mandatory default header. A client that also sends `anthropic-beta` must + /// not be able to drop it: the route default and the client value are merged + /// into a single comma-separated header. + #[tokio::test] + async fn anthropic_oauth_route_merges_mandatory_beta_with_client_betas() { + let mock_server = MockServer::start().await; + + let route = ResolvedRoute { + name: "inference.local".to_string(), + endpoint: mock_server.uri(), + model: "claude-sonnet-4-20250514".to_string(), + api_key: "sk-ant-oat-test".to_string(), + protocols: vec!["anthropic_messages".to_string()], + auth: AuthHeader::Bearer, + default_headers: vec![ + ("anthropic-version".to_string(), "2023-06-01".to_string()), + ("anthropic-beta".to_string(), "oauth-2025-04-20".to_string()), + ], + passthrough_headers: vec![ + "anthropic-version".to_string(), + "anthropic-beta".to_string(), + ], + timeout: DEFAULT_ROUTE_TIMEOUT, + model_in_path: false, + request_path_override: None, + }; + + Mock::given(method("POST")) + .and(path("/v1/messages")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": "m"}))) + .mount(&mock_server) + .await; + + let client = reqwest::Client::builder().build().unwrap(); + let body = bytes::Bytes::from_static(b"{}"); + let headers = vec![ + ("content-type".to_string(), "application/json".to_string()), + ( + "anthropic-beta".to_string(), + "tool-use-2024-10-22".to_string(), + ), + ]; + + let (builder, _url) = prepare_backend_request( + &client, + &route, + "POST", + "/v1/messages", + &headers, + body, + false, + ) + .unwrap(); + + let response = builder.send().await.unwrap(); + assert_eq!(response.status().as_u16(), 200); + + let received = mock_server.received_requests().await.unwrap(); + assert_eq!(received.len(), 1); + // The outgoing request must carry a single merged anthropic-beta header + // containing both the mandatory OAuth flag and the client's beta. + let beta_values: Vec<&str> = received[0] + .headers + .get_all("anthropic-beta") + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!( + beta_values.len(), + 1, + "anthropic-beta must be emitted exactly once, got: {beta_values:?}" + ); + let merged = beta_values[0]; + assert!( + merged.contains("oauth-2025-04-20"), + "mandatory OAuth beta must survive, got: {merged}" + ); + assert!( + merged.contains("tool-use-2024-10-22"), + "client beta must be preserved, got: {merged}" + ); + + // The Bearer token must be injected at the boundary as Authorization. + let auth = received[0] + .headers + .get("authorization") + .map(|v| v.to_str().unwrap()); + assert_eq!(auth, Some("Bearer sk-ant-oat-test")); + } + /// Claude Code and other Anthropic SDK clients always send "model" in the /// request body. For Vertex AI rawPredict routes the model is in the URL /// path; the body field must be stripped to avoid HTTP 400 diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d5a5f5c909..f58c86166d 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1096,9 +1096,16 @@ fn active_provider_credential_keys(provider: &Provider, now_ms: i64) -> Vec bool { - openshell_core::inference::normalize_inference_provider_type(&provider.r#type) - == Some("google-vertex-ai") - && key == "GOOGLE_SERVICE_ACCOUNT_KEY" + match openshell_core::inference::normalize_inference_provider_type(&provider.r#type) { + // The Vertex service-account key is used by the gateway to mint tokens; it + // must never be exported into the sandbox environment. + Some("google-vertex-ai") => key == "GOOGLE_SERVICE_ACCOUNT_KEY", + // 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, + _ => false, + } } pub(super) fn is_valid_env_key(key: &str) -> bool { @@ -3142,6 +3149,7 @@ mod tests { assert_eq!( ids, vec![ + "anthropic-oauth", "aws-bedrock", "claude-code", "codex", @@ -5347,6 +5355,64 @@ mod tests { ); } + #[test] + fn anthropic_oauth_access_token_is_non_injectable() { + let provider = Provider { + r#type: "anthropic-oauth".to_string(), + ..Default::default() + }; + assert!(is_non_injectable_provider_credential( + &provider, + openshell_core::inference::ANTHROPIC_OAUTH_TOKEN_KEY + )); + // The alias resolves to the same provider type and stays non-injectable. + let aliased = Provider { + r#type: "claude-plan".to_string(), + ..Default::default() + }; + assert!(is_non_injectable_provider_credential( + &aliased, + openshell_core::inference::ANTHROPIC_OAUTH_TOKEN_KEY + )); + // An unrelated key on the same provider is still injectable. + assert!(!is_non_injectable_provider_credential( + &provider, + "SOME_OTHER_KEY" + )); + } + + #[tokio::test] + async fn resolve_provider_env_anthropic_oauth_never_injects_access_token() { + let store = test_store().await; + create_provider_record( + &store, + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "anthropic-plan".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + }), + r#type: "anthropic-oauth".to_string(), + credentials: HashMap::from([( + openshell_core::inference::ANTHROPIC_OAUTH_TOKEN_KEY.to_string(), + "sk-ant-oat-secret".to_string(), + )]), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + }, + ) + .await + .unwrap(); + + let result = resolve_provider_environment(&store, &["anthropic-plan".to_string()]) + .await + .unwrap(); + + assert!(!result.contains_key(openshell_core::inference::ANTHROPIC_OAUTH_TOKEN_KEY)); + } + #[tokio::test] async fn resolve_provider_env_vertex_omits_agent_config_when_project_and_region_absent() { let store = test_store().await; diff --git a/docs/providers/anthropic-subscription.mdx b/docs/providers/anthropic-subscription.mdx new file mode 100644 index 0000000000..121f0acb91 --- /dev/null +++ b/docs/providers/anthropic-subscription.mdx @@ -0,0 +1,100 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Anthropic Subscription" +sidebar-title: "Anthropic Subscription" +description: "Run Claude Code in a sandbox on an Anthropic Pro or Max subscription, with the OAuth token acquired and refreshed outside the sandbox." +keywords: "Generative AI, AI Agents, Sandboxing, Anthropic, Claude, OAuth, Subscription, Inference Routing" +--- + +The `anthropic-oauth` provider routes `inference.local` traffic to the Anthropic API using a subscription ("plan") OAuth token instead of an API key. You authenticate once on the host with your Anthropic Pro or Max plan, and the gateway refreshes the token and injects it only at the egress boundary. The token is never written into the sandbox environment, filesystem, or logs. + +Use this provider when you want sandboxed agents to consume your Claude subscription rather than a metered API key. For API-key access, use a standard `anthropic` provider instead. + +## 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. +- The host running `openshell provider create` must be able to read the local login: the macOS Keychain item `Claude Code-credentials`, or `~/.claude/.credentials.json` on Linux. +- A gateway and sandbox supervisor image new enough to support the `anthropic-oauth` provider type. The supervisor applies the `Authorization: Bearer` and `anthropic-beta` headers inside the sandbox boundary, so a stale supervisor image silently drops them and Anthropic rejects the request. Keep the gateway and supervisor images on the same build. + +## Authentication + +Create the provider with `--from-claude-login`: + +```shell +openshell provider create \ + --name claude-plan \ + --type anthropic-oauth \ + --from-claude-login +``` + +`--from-claude-login` reads the local Claude Code login, stores the access token as the gateway-side `ANTHROPIC_OAUTH_TOKEN` credential, and configures an OAuth2 refresh-token flow using your subscription's refresh token. The gateway refreshes the access token ahead of expiry and updates running sandboxes without a restart. + +The CLI reads the macOS Keychain first, then falls back to `~/.claude/.credentials.json`. If neither contains a login, the command fails and directs you to run `claude login`. + + +`--from-claude-login` is only valid for `anthropic-oauth` providers. It cannot be combined with `--from-existing`, `--credential`, or `--runtime-credentials`. + + + +The access token is marked non-injectable: it is stored only as gateway refresh state and is never exported into sandboxes. Sandboxes reach the model through `inference.local`, where the gateway adds the `Authorization: Bearer` header and the required `anthropic-beta: oauth-2025-04-20` flag at the egress boundary. + + +## Token Lifecycle + +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 or revoke, delete and recreate the provider, or rotate the credential through `openshell provider refresh`. + +## Configure Inference Routing + +Enable provider endpoint injection so the Anthropic network endpoint is added to sandbox policies automatically: + +```shell +openshell settings set --global --key providers_v2_enabled --value true --yes +``` + +Then point `inference.local` at the provider: + +```shell +openshell inference set \ + --provider claude-plan \ + --model claude-sonnet-4-6 +``` + +Sandboxes on that gateway reach the model at `https://inference.local`. For full details, refer to [Inference Routing](/sandboxes/inference-routing). + +## Use from a Sandbox + +Agents inside sandboxes reach Anthropic through `inference.local`, not by connecting to the API directly. The gateway holds the subscription token and injects it on egress; the agent only points its SDK at the local endpoint. + +The complete setup from scratch: + +```shell +# 1. Enable provider endpoint injection +openshell settings set --global --key providers_v2_enabled --value true --yes + +# 2. Authenticate on the host, then create the provider +claude login +openshell provider create --name claude-plan --type anthropic-oauth --from-claude-login + +# 3. Configure inference routing +openshell inference set --provider claude-plan --model claude-sonnet-4-6 + +# 4. Create a sandbox with the provider attached +openshell sandbox create --name my-sandbox --provider claude-plan +``` + +Then inside the sandbox, launch Claude Code against the local endpoint: + +```shell +ANTHROPIC_BASE_URL="https://inference.local" ANTHROPIC_API_KEY=unused claude -p "Reply with exactly: pong" +``` + +Setting `ANTHROPIC_API_KEY` to any placeholder makes Claude Code talk to the gateway endpoint directly instead of starting its own OAuth login flow inside the sandbox. The placeholder key never reaches Anthropic — `inference.local` strips it and injects the real subscription token before forwarding. Drop the `-p "…"` to launch the interactive TUI instead. + +## Next Steps + +- To configure `inference.local` routing, refer to [Inference Routing](/sandboxes/inference-routing). +- To manage provider credentials and refresh, refer to [Providers](/sandboxes/manage-providers). +- To apply network policies to sandboxes using this provider, refer to [Policies](/sandboxes/policies). diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 1324859ed7..29349e2e36 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -308,6 +308,7 @@ The following providers have been tested with `inference.local`. Any provider th | AWS Bedrock (via bridge) | `bedrock-bridge` | `aws-bedrock` | Operator-supplied `BEDROCK_BASE_URL` | None at router level (bridge holds creds) | | NVIDIA API Catalog | `nvidia-prod` | `nvidia` | `https://integrate.api.nvidia.com/v1` | `NVIDIA_API_KEY` | | Anthropic | `anthropic-prod` | `anthropic` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | +| Anthropic (subscription) | `claude-plan` | `anthropic-oauth` | `https://api.anthropic.com` | OAuth — use `--from-claude-login` | | Google Vertex AI | `vertex-prod` | `google-vertex-ai` | Regional, global, or multi-region Vertex endpoint | `GOOGLE_VERTEX_AI_TOKEN` or `GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN` | | Baseten | `baseten` | `openai` | `https://inference.baseten.co/v1` | `OPENAI_API_KEY` | | Bitdeer AI | `bitdeer` | `openai` | `https://api-inference.bitdeer.ai/v1` | `OPENAI_API_KEY` | @@ -316,7 +317,7 @@ The following providers have been tested with `inference.local`. Any provider th | Ollama (local) | `ollama` | `openai` | `http://host.openshell.internal:11434/v1` | `OPENAI_API_KEY` | | LM Studio (local) | `lmstudio` | `openai` | `http://host.openshell.internal:1234/v1` | `OPENAI_API_KEY` | -Refer to your provider's documentation for the correct base URL, available models, and API key setup. For the Vertex-specific auth flows and config keys, refer to [Google Vertex AI](/providers/google-vertex-ai). To configure inference routing, refer to [Inference Routing](/sandboxes/inference-routing). +Refer to your provider's documentation for the correct base URL, available models, and API key setup. For the Vertex-specific auth flows and config keys, refer to [Google Vertex AI](/providers/google-vertex-ai). To run Claude Code on an Anthropic Pro or Max subscription with the token acquired outside the sandbox, refer to [Anthropic Subscription](/providers/anthropic-subscription). To configure inference routing, refer to [Inference Routing](/sandboxes/inference-routing). ## Next Steps diff --git a/providers/anthropic-oauth.yaml b/providers/anthropic-oauth.yaml new file mode 100644 index 0000000000..366dbda566 --- /dev/null +++ b/providers/anthropic-oauth.yaml @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: anthropic-oauth +display_name: Anthropic (Subscription OAuth) +description: Anthropic Claude inference using a subscription ("plan") OAuth token. The token is refreshed by the gateway and injected only at the egress boundary, never inside the sandbox. +category: inference +inference_capable: true +credentials: + - name: oauth_token + description: Anthropic subscription OAuth access token; injected as an Authorization Bearer header at the egress boundary and never exported into sandboxes + env_vars: [ANTHROPIC_OAUTH_TOKEN] + required: false + auth_style: bearer + header_name: authorization + refresh: + strategy: oauth2_refresh_token + token_url: https://console.anthropic.com/v1/oauth/token + refresh_before_seconds: 300 + max_lifetime_seconds: 28800 + material: + - name: client_id + description: Anthropic public OAuth client ID + required: true + - name: refresh_token + description: Anthropic OAuth refresh token harvested from the local Claude Code login + required: true + secret: true +discovery: + credentials: [oauth_token] +endpoints: + - host: api.anthropic.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce From f67c398d15b127e5ebba2c2a8b621d53c975d4c3 Mon Sep 17 00:00:00 2001 From: Cedric Fitzgerald Date: Tue, 14 Jul 2026 15:55:58 -0400 Subject: [PATCH 2/6] change alias claude-plan to claude-subscription --- architecture/gateway.md | 2 +- crates/openshell-core/src/inference.rs | 8 ++++---- crates/openshell-server/src/grpc/provider.rs | 2 +- docs/providers/anthropic-subscription.mdx | 10 +++++----- docs/sandboxes/manage-providers.mdx | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 0c1ec26e31..a361825660 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -341,7 +341,7 @@ back the provider record. Service-account JSON and private keys are gateway-side refresh bootstrap material only; sandbox runtime inference receives minted access tokens, not raw service-account material. -The `anthropic-oauth` provider type (alias `claude-plan`) routes the direct +The `anthropic-oauth` provider type (alias `claude-subscription`) routes the direct Anthropic API with a subscription OAuth token instead of an API key. Its inference profile uses `Authorization: Bearer` plus a mandatory `anthropic-beta: oauth-2025-04-20` default header; the sandbox router merges that diff --git a/crates/openshell-core/src/inference.rs b/crates/openshell-core/src/inference.rs index 2e556a1c16..3eff7fd352 100644 --- a/crates/openshell-core/src/inference.rs +++ b/crates/openshell-core/src/inference.rs @@ -254,7 +254,7 @@ pub fn normalize_inference_provider_type(input: &str) -> Option<&'static str> { match input.trim().to_ascii_lowercase().as_str() { "openai" => Some("openai"), "anthropic" => Some("anthropic"), - "anthropic-oauth" | "claude-plan" => Some("anthropic-oauth"), + "anthropic-oauth" | "claude-subscription" => Some("anthropic-oauth"), "nvidia" => Some("nvidia"), "deepinfra" => Some("deepinfra"), "aws-bedrock" => Some("aws-bedrock"), @@ -514,16 +514,16 @@ mod tests { #[test] fn profile_for_anthropic_oauth_types() { - for key in &["anthropic-oauth", "claude-plan", "Anthropic-OAuth"] { + for key in &["anthropic-oauth", "claude-subscription", "Anthropic-OAuth"] { let profile = profile_for(key).expect("anthropic-oauth profile should be Some"); assert_eq!(profile.provider_type, "anthropic-oauth"); } } #[test] - fn normalize_aliases_claude_plan_to_anthropic_oauth() { + fn normalize_aliases_claude_subscription_to_anthropic_oauth() { assert_eq!( - normalize_inference_provider_type("claude-plan"), + normalize_inference_provider_type("claude-subscription"), Some("anthropic-oauth") ); assert_eq!( diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index f58c86166d..fd11664670 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -5367,7 +5367,7 @@ mod tests { )); // The alias resolves to the same provider type and stays non-injectable. let aliased = Provider { - r#type: "claude-plan".to_string(), + r#type: "claude-subscription".to_string(), ..Default::default() }; assert!(is_non_injectable_provider_credential( diff --git a/docs/providers/anthropic-subscription.mdx b/docs/providers/anthropic-subscription.mdx index 121f0acb91..4fe69b6959 100644 --- a/docs/providers/anthropic-subscription.mdx +++ b/docs/providers/anthropic-subscription.mdx @@ -23,7 +23,7 @@ Create the provider with `--from-claude-login`: ```shell openshell provider create \ - --name claude-plan \ + --name claude-subscription \ --type anthropic-oauth \ --from-claude-login ``` @@ -58,7 +58,7 @@ Then point `inference.local` at the provider: ```shell openshell inference set \ - --provider claude-plan \ + --provider claude-subscription \ --model claude-sonnet-4-6 ``` @@ -76,13 +76,13 @@ openshell settings set --global --key providers_v2_enabled --value true --yes # 2. Authenticate on the host, then create the provider claude login -openshell provider create --name claude-plan --type anthropic-oauth --from-claude-login +openshell provider create --name claude-subscription --type anthropic-oauth --from-claude-login # 3. Configure inference routing -openshell inference set --provider claude-plan --model claude-sonnet-4-6 +openshell inference set --provider claude-subscription --model claude-sonnet-4-6 # 4. Create a sandbox with the provider attached -openshell sandbox create --name my-sandbox --provider claude-plan +openshell sandbox create --name my-sandbox --provider claude-subscription ``` Then inside the sandbox, launch Claude Code against the local endpoint: diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 29349e2e36..73f451b576 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -308,7 +308,7 @@ The following providers have been tested with `inference.local`. Any provider th | AWS Bedrock (via bridge) | `bedrock-bridge` | `aws-bedrock` | Operator-supplied `BEDROCK_BASE_URL` | None at router level (bridge holds creds) | | NVIDIA API Catalog | `nvidia-prod` | `nvidia` | `https://integrate.api.nvidia.com/v1` | `NVIDIA_API_KEY` | | Anthropic | `anthropic-prod` | `anthropic` | `https://api.anthropic.com` | `ANTHROPIC_API_KEY` | -| Anthropic (subscription) | `claude-plan` | `anthropic-oauth` | `https://api.anthropic.com` | OAuth — use `--from-claude-login` | +| Anthropic (subscription) | `claude-subscription` | `anthropic-oauth` | `https://api.anthropic.com` | OAuth — use `--from-claude-login` | | Google Vertex AI | `vertex-prod` | `google-vertex-ai` | Regional, global, or multi-region Vertex endpoint | `GOOGLE_VERTEX_AI_TOKEN` or `GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN` | | Baseten | `baseten` | `openai` | `https://inference.baseten.co/v1` | `OPENAI_API_KEY` | | Bitdeer AI | `bitdeer` | `openai` | `https://api-inference.bitdeer.ai/v1` | `OPENAI_API_KEY` | From 1ec35d98a39a0b1db8260d76326883d863a1070a Mon Sep 17 00:00:00 2001 From: Cedric Fitzgerald Date: Tue, 14 Jul 2026 20:32:30 -0400 Subject: [PATCH 3/6] feat(providers): auto-configure sandbox env for anthropic-oauth agents 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 --- crates/openshell-core/src/inference.rs | 10 +- .../src/provider_credentials.rs | 80 +++++++++++---- crates/openshell-providers/src/lib.rs | 1 + .../src/providers/anthropic_oauth.rs | 99 +++++++++++++++++++ .../openshell-providers/src/providers/mod.rs | 1 + .../src/google_cloud_metadata.rs | 2 +- crates/openshell-sandbox/src/lib.rs | 6 +- crates/openshell-server/src/grpc/provider.rs | 8 ++ .../openshell-supervisor-process/src/ssh.rs | 8 +- 9 files changed, 192 insertions(+), 23 deletions(-) create mode 100644 crates/openshell-providers/src/providers/anthropic_oauth.rs diff --git a/crates/openshell-core/src/inference.rs b/crates/openshell-core/src/inference.rs index 3eff7fd352..04a9f16454 100644 --- a/crates/openshell-core/src/inference.rs +++ b/crates/openshell-core/src/inference.rs @@ -120,6 +120,14 @@ pub const ANTHROPIC_OAUTH_TOKEN_KEY: &str = "ANTHROPIC_OAUTH_TOKEN"; /// Anthropic subscription OAuth token (as opposed to an API key). pub const ANTHROPIC_OAUTH_BETA: &str = "oauth-2025-04-20"; +/// Non-secret Anthropic env keys that sandbox clients must read at process +/// startup. The credential pipeline placeholderizes all provider env values; +/// these keys are resolved back to real values in the sandbox env because a +/// placeholderized base URL cannot be parsed by SDKs. `ANTHROPIC_AUTH_TOKEN` +/// and `ANTHROPIC_API_KEY` are deliberately excluded: they may hold real +/// secrets and stay egress-time placeholders. +pub const ANTHROPIC_STATIC_CONFIG_KEYS: &[&str] = &["ANTHROPIC_BASE_URL"]; + /// Inference profile for the Anthropic subscription ("plan") OAuth flow. /// /// Differs from [`ANTHROPIC_PROFILE`] in two ways the Anthropic API requires for @@ -254,7 +262,7 @@ pub fn normalize_inference_provider_type(input: &str) -> Option<&'static str> { match input.trim().to_ascii_lowercase().as_str() { "openai" => Some("openai"), "anthropic" => Some("anthropic"), - "anthropic-oauth" | "claude-subscription" => Some("anthropic-oauth"), + "anthropic-oauth" | "claude-subscription" => Some("anthropic-oauth"), "nvidia" => Some("nvidia"), "deepinfra" => Some("deepinfra"), "aws-bedrock" => Some("aws-bedrock"), diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 65310b3a43..ca734747f0 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -152,24 +152,27 @@ impl ProviderCredentialState { inner.current = Arc::new(env); } - /// Return `child_env` with GCP static config vars resolved to real values. + /// Return `child_env` with non-secret static config vars resolved to real + /// values. /// - /// The credential pipeline placeholderizes ALL env values, but GCP SDKs - /// and coding agents read certain vars (project ID, region, metadata host) - /// at process startup before any HTTP request flows through the proxy. - /// This method overrides those vars with resolved real values while - /// keeping secret credentials (like `GCP_ACCESS_TOKEN`) as placeholders. + /// The credential pipeline placeholderizes ALL env values, but SDKs and + /// coding agents read certain vars (GCP project ID, region, metadata host, + /// Anthropic base URL) at process startup before any HTTP request flows + /// through the proxy. This method overrides those vars with resolved real + /// values while keeping secret credentials (like `GCP_ACCESS_TOKEN` or + /// `ANTHROPIC_AUTH_TOKEN`) as placeholders. /// /// Three layers of env var injection: /// 1. **Synthetic vars** (`GCE_METADATA_IP`, `METADATA_SERVER_DETECTION`) /// — sandbox-internal config not from user /// input, inserted directly here with real values. - /// 2. **`google_cloud::STATIC_CONFIG_KEYS`** — user-provided non-secret config - /// (project ID, region, SA email) that was placeholderized by + /// 2. **`google_cloud::STATIC_CONFIG_KEYS`** and + /// **`inference::ANTHROPIC_STATIC_CONFIG_KEYS`** — non-secret config + /// (project ID, region, SA email, base URL) that was placeholderized by /// `ProviderPlugin::inject_env` → `SecretResolver`; un-placeholderized /// here so SDKs can read them at startup. /// 3. Everything else stays as placeholders for proxy-time resolution. - pub fn child_env_with_gcp_resolved(&self) -> HashMap { + pub fn child_env_with_static_config_resolved(&self) -> HashMap { use crate::google_cloud; let inner = self @@ -178,6 +181,18 @@ impl ProviderCredentialState { .expect("provider credential state poisoned"); let mut env = inner.current.child_env.clone(); + if let Some(ref resolver) = inner.combined_resolver { + for key in crate::inference::ANTHROPIC_STATIC_CONFIG_KEYS { + if !env.contains_key(*key) { + continue; + } + let placeholder = crate::secrets::placeholder_for_env_key(key); + if let Some(value) = resolver.resolve_placeholder(&placeholder) { + env.insert((*key).to_string(), value.to_string()); + } + } + } + let has_gcp_metadata = env.contains_key("GCE_METADATA_HOST"); let has_gcp_config = google_cloud::STATIC_CONFIG_KEYS .iter() @@ -421,14 +436,14 @@ mod tests { } #[test] - fn child_env_with_gcp_resolved_without_gcp_returns_unchanged() { + fn child_env_with_static_config_resolved_without_gcp_returns_unchanged() { let state = ProviderCredentialState::from_environment( 1, HashMap::from([("GITHUB_TOKEN".to_string(), "ghp_abc".to_string())]), HashMap::new(), HashMap::new(), ); - let env = state.child_env_with_gcp_resolved(); + let env = state.child_env_with_static_config_resolved(); assert_eq!( env.get("GITHUB_TOKEN").map(String::as_str), Some("openshell:resolve:env:v1_GITHUB_TOKEN"), @@ -439,7 +454,7 @@ mod tests { } #[test] - fn child_env_with_gcp_resolved_overrides_gcp_static_vars() { + fn child_env_with_static_config_resolved_overrides_gcp_static_vars() { let state = ProviderCredentialState::from_environment( 1, HashMap::from([ @@ -454,7 +469,7 @@ mod tests { HashMap::new(), HashMap::new(), ); - let env = state.child_env_with_gcp_resolved(); + let env = state.child_env_with_static_config_resolved(); assert_eq!( env.get("GCE_METADATA_HOST").map(String::as_str), @@ -483,7 +498,7 @@ mod tests { } #[test] - fn child_env_with_gcp_resolved_handles_missing_config_keys() { + fn child_env_with_static_config_resolved_handles_missing_config_keys() { let state = ProviderCredentialState::from_environment( 1, HashMap::from([ @@ -493,7 +508,7 @@ mod tests { HashMap::new(), HashMap::new(), ); - let env = state.child_env_with_gcp_resolved(); + let env = state.child_env_with_static_config_resolved(); assert_eq!( env.get("GCE_METADATA_HOST").map(String::as_str), @@ -509,6 +524,37 @@ mod tests { ); } + #[test] + fn child_env_with_static_config_resolved_resolves_anthropic_base_url() { + let state = ProviderCredentialState::from_environment( + 1, + HashMap::from([ + ( + "ANTHROPIC_BASE_URL".to_string(), + "https://inference.local".to_string(), + ), + ( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "inference-local".to_string(), + ), + ]), + HashMap::new(), + HashMap::new(), + ); + let env = state.child_env_with_static_config_resolved(); + + assert_eq!( + env.get("ANTHROPIC_BASE_URL").map(String::as_str), + Some("https://inference.local"), + "base URL must be a parseable real value at process startup" + ); + let token = env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str).unwrap(); + assert!( + token.starts_with("openshell:resolve:env:"), + "ANTHROPIC_AUTH_TOKEN must stay as placeholder, got: {token}" + ); + } + #[test] fn gcp_token_response_returns_sa_over_adc() { let state = ProviderCredentialState::from_environment( @@ -588,7 +634,7 @@ mod tests { } #[test] - fn child_env_with_gcp_resolved_resolves_vertex_vars_without_metadata_host() { + fn child_env_with_static_config_resolved_resolves_vertex_vars_without_metadata_host() { let state = ProviderCredentialState::from_environment( 1, HashMap::from([ @@ -602,7 +648,7 @@ mod tests { HashMap::new(), HashMap::new(), ); - let env = state.child_env_with_gcp_resolved(); + let env = state.child_env_with_static_config_resolved(); assert_eq!( env.get("GOOSE_PROVIDER").map(String::as_str), Some("gcp_vertex_ai"), diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index b15525e372..5d3e832460 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -114,6 +114,7 @@ impl ProviderRegistry { registry.register(providers::generic::GenericProvider); registry.register(providers::openai::SPEC); registry.register(providers::anthropic::SPEC); + registry.register(providers::anthropic_oauth::AnthropicOauthProvider); registry.register(providers::nvidia::SPEC); registry.register(providers::deepinfra::SPEC); registry.register(providers::github::SPEC); diff --git a/crates/openshell-providers/src/providers/anthropic_oauth.rs b/crates/openshell-providers/src/providers/anthropic_oauth.rs new file mode 100644 index 0000000000..8f014fb3d9 --- /dev/null +++ b/crates/openshell-providers/src/providers/anthropic_oauth.rs @@ -0,0 +1,99 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; + +use crate::{DiscoveredProvider, Provider, ProviderError, ProviderPlugin}; + +pub struct AnthropicOauthProvider; + +/// Placeholder injected as `ANTHROPIC_AUTH_TOKEN` so agent CLIs (Claude Code, +/// Anthropic SDKs) authenticate without prompting for an interactive login. +/// `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` because +/// Claude Code asks for confirmation before trusting an environment API key +/// but accepts an auth token silently. The value never reaches Anthropic: +/// `inference.local` replaces caller-supplied `Authorization` and injects the +/// real subscription token at the egress boundary. +pub const ANTHROPIC_AUTH_TOKEN_PLACEHOLDER: &str = "inference-local"; + +/// Base URL agents inside the sandbox must use to reach the model. The +/// subscription OAuth token is proxy-only, so direct calls to +/// `api.anthropic.com` cannot authenticate; all traffic goes through the +/// gateway's inference endpoint. +pub const ANTHROPIC_BASE_URL_VALUE: &str = "https://inference.local"; + +impl ProviderPlugin for AnthropicOauthProvider { + fn id(&self) -> &'static str { + "anthropic-oauth" + } + + fn discover_existing(&self) -> Result, ProviderError> { + // OAuth material is harvested via `--from-claude-login`, not env + // scanning, and the access token must never be treated as an + // injectable env credential. + Ok(None) + } + + fn inject_env(&self, _provider: &Provider, env: &mut HashMap) { + env.entry("ANTHROPIC_BASE_URL".to_string()) + .or_insert_with(|| ANTHROPIC_BASE_URL_VALUE.to_string()); + env.entry("ANTHROPIC_AUTH_TOKEN".to_string()) + .or_insert_with(|| ANTHROPIC_AUTH_TOKEN_PLACEHOLDER.to_string()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn injects_base_url_and_placeholder_auth_token() { + let provider = Provider { + r#type: "anthropic-oauth".to_string(), + ..Default::default() + }; + let mut env = HashMap::new(); + AnthropicOauthProvider.inject_env(&provider, &mut env); + + assert_eq!( + env.get("ANTHROPIC_BASE_URL").map(String::as_str), + Some(ANTHROPIC_BASE_URL_VALUE) + ); + assert_eq!( + env.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str), + Some(ANTHROPIC_AUTH_TOKEN_PLACEHOLDER) + ); + assert!( + !env.contains_key("ANTHROPIC_API_KEY"), + "must not inject an API key: Claude Code prompts before trusting it" + ); + } + + #[test] + fn does_not_override_existing_values() { + let provider = Provider { + r#type: "anthropic-oauth".to_string(), + ..Default::default() + }; + let mut env = HashMap::from([( + "ANTHROPIC_BASE_URL".to_string(), + "https://custom.example".to_string(), + )]); + AnthropicOauthProvider.inject_env(&provider, &mut env); + + assert_eq!( + env.get("ANTHROPIC_BASE_URL").map(String::as_str), + Some("https://custom.example") + ); + } + + #[test] + fn discovery_finds_nothing() { + assert!( + AnthropicOauthProvider + .discover_existing() + .expect("discovery should not error") + .is_none() + ); + } +} diff --git a/crates/openshell-providers/src/providers/mod.rs b/crates/openshell-providers/src/providers/mod.rs index 2770210318..71a26ffb5f 100644 --- a/crates/openshell-providers/src/providers/mod.rs +++ b/crates/openshell-providers/src/providers/mod.rs @@ -31,6 +31,7 @@ macro_rules! test_discovers_env_credential { }; } pub mod anthropic; +pub mod anthropic_oauth; pub mod claude; pub mod codex; pub mod copilot; diff --git a/crates/openshell-sandbox/src/google_cloud_metadata.rs b/crates/openshell-sandbox/src/google_cloud_metadata.rs index 9e1e179872..315d5fb2d2 100644 --- a/crates/openshell-sandbox/src/google_cloud_metadata.rs +++ b/crates/openshell-sandbox/src/google_cloud_metadata.rs @@ -11,7 +11,7 @@ //! The emulator runs as a loopback HTTP server inside the sandbox network //! namespace (see [`metadata_server`](crate::metadata_server)). GCP SDKs //! discover it via the `GCE_METADATA_HOST` environment variable, which is -//! set to the loopback address by `child_env_with_gcp_resolved()`. +//! set to the loopback address by `child_env_with_static_config_resolved()`. use miette::{IntoDiagnostic, Result}; use openshell_core::provider_credentials::ProviderCredentialState; diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 7a1085f1bf..15ac7078f9 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -279,7 +279,7 @@ pub async fn run_sandbox( provider_credential_expires_at_ms, dynamic_credentials, ); - let provider_env = provider_credentials.child_env_with_gcp_resolved(); + let provider_env = provider_credentials.child_env_with_static_config_resolved(); (provider_credentials, provider_env) }; let process_control_writer = process_control_connection @@ -2481,7 +2481,9 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { env_result.credential_expires_at_ms, env_result.dynamic_credentials, ); - let child_env = ctx.provider_credentials.child_env_with_gcp_resolved(); + let child_env = ctx + .provider_credentials + .child_env_with_static_config_resolved(); let env_count = child_env.len(); if let Some(publisher) = ctx.sidecar_control_publisher.as_ref() { publisher.publish_provider_env( diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index fd11664670..b16dcb1847 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -5411,6 +5411,14 @@ mod tests { .unwrap(); assert!(!result.contains_key(openshell_core::inference::ANTHROPIC_OAUTH_TOKEN_KEY)); + // The plugin projects the inference endpoint into the sandbox env so + // agent CLIs work without manual configuration. + assert_eq!( + result.get("ANTHROPIC_BASE_URL").map(String::as_str), + Some("https://inference.local") + ); + assert!(result.contains_key("ANTHROPIC_AUTH_TOKEN")); + assert!(!result.contains_key("ANTHROPIC_API_KEY")); } #[tokio::test] diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index f5a3ee0793..e55b7e6c5f 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -485,7 +485,9 @@ impl russh::server::Handler for SshHandler { self.netns_fd, self.proxy_url.clone(), self.ca_file_paths.clone(), - &self.provider_credentials.child_env_with_gcp_resolved(), + &self + .provider_credentials + .child_env_with_static_config_resolved(), &self.user_environment, self.enforcement_mode, )?; @@ -564,7 +566,9 @@ impl SshHandler { handle: Handle, command: Option, ) -> anyhow::Result<()> { - let provider_env = self.provider_credentials.child_env_with_gcp_resolved(); + let provider_env = self + .provider_credentials + .child_env_with_static_config_resolved(); let state = self .channels .get_mut(&channel) From af9222e44a9373ef3131bc5022d4fae1e9b3d13c Mon Sep 17 00:00:00 2001 From: Cedric Fitzgerald Date: Tue, 14 Jul 2026 20:32:56 -0400 Subject: [PATCH 4/6] feat(cli): streamline claude-subscription provider setup 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 --- crates/openshell-cli/src/main.rs | 64 ++++++++++++++++---- crates/openshell-cli/src/run.rs | 100 ++++++++++++++++++++++++++++++- 2 files changed, 153 insertions(+), 11 deletions(-) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 96c3763459..6a1fa73548 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -749,13 +749,13 @@ enum ProviderCommands { /// Create a provider config. #[command(group = clap::ArgGroup::new("cred_source").required(true).args(["from_existing", "credentials", "from_gcloud_adc", "from_claude_login", "runtime_credentials"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Create { - /// Provider name. - #[arg(long)] - name: String, + /// Provider name. Defaults to `claude-subscription` with `--from-claude-login`. + #[arg(long, required_unless_present = "from_claude_login")] + name: Option, - /// Provider type. - #[arg(long = "type")] - provider_type: String, + /// Provider type. Defaults to `anthropic-oauth` with `--from-claude-login`. + #[arg(long = "type", required_unless_present = "from_claude_login")] + provider_type: Option, /// Load provider credentials/config from existing local state. #[arg(long, conflicts_with_all = ["credentials", "from_gcloud_adc", "from_claude_login", "runtime_credentials"])] @@ -2903,6 +2903,11 @@ async fn main() -> Result<()> { runtime_credentials, config, } => { + // clap guarantees these are present unless --from-claude-login was given. + let name = name + .unwrap_or_else(|| run::ANTHROPIC_OAUTH_DEFAULT_PROVIDER_NAME.to_string()); + let provider_type = provider_type + .unwrap_or_else(|| run::ANTHROPIC_OAUTH_PROVIDER_TYPE.to_string()); run::provider_create_with_options( endpoint, &name, @@ -4067,14 +4072,53 @@ mod tests { .. }), }) => { - assert_eq!(name, "work-github"); - assert_eq!(provider_type, "github-readonly"); + assert_eq!(name.as_deref(), Some("work-github")); + assert_eq!(provider_type.as_deref(), Some("github-readonly")); assert_eq!(credentials, vec!["GITHUB_TOKEN=token"]); } other => panic!("expected provider create command, got: {other:?}"), } } + #[test] + fn provider_create_from_claude_login_defaults_name_and_type() { + let cli = Cli::try_parse_from(["openshell", "provider", "create", "--from-claude-login"]) + .expect("provider create should parse bare --from-claude-login"); + + match cli.command { + Some(Commands::Provider { + command: + Some(ProviderCommands::Create { + name, + provider_type, + from_claude_login, + .. + }), + }) => { + assert_eq!(name, None); + assert_eq!(provider_type, None); + assert!(from_claude_login); + } + other => panic!("expected provider create command, got: {other:?}"), + } + } + + #[test] + fn provider_create_requires_name_and_type_without_claude_login() { + let err = Cli::try_parse_from([ + "openshell", + "provider", + "create", + "--credential", + "SOME_KEY=value", + ]) + .expect_err("provider create should require --name and --type"); + + let message = err.to_string(); + assert!(message.contains("--name"), "unexpected error: {message}"); + assert!(message.contains("--type"), "unexpected error: {message}"); + } + #[test] fn provider_create_requires_credential_source() { let err = Cli::try_parse_from([ @@ -4118,8 +4162,8 @@ mod tests { .. }), }) => { - assert_eq!(name, "spiffe-token-demo"); - assert_eq!(provider_type, "spiffe-token-demo"); + assert_eq!(name.as_deref(), Some("spiffe-token-demo")); + assert_eq!(provider_type.as_deref(), Some("spiffe-token-demo")); assert!(!from_existing); assert!(credentials.is_empty()); assert!(!from_gcloud_adc); diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index a487f76f7c..f1bf0a5313 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -3824,6 +3824,20 @@ async fn auto_create_provider( ) -> Result<()> { eprintln!("Missing provider: {provider_type}"); + // Subscription OAuth material lives in the host keychain and is harvested + // by `provider create --from-claude-login`, not by env discovery — and it + // cannot be configured from inside a sandbox. + if openshell_core::inference::normalize_inference_provider_type(provider_type) + == Some(ANTHROPIC_OAUTH_PROVIDER_TYPE) + { + eprintln!( + "{} '{provider_type}' uses your Anthropic subscription login. Create the provider first, then retry:\n openshell provider create --from-claude-login", + "!".yellow(), + ); + eprintln!(); + return Ok(()); + } + // --no-auto-providers: skip silently. if auto_providers_override == Some(false) { eprintln!( @@ -4464,7 +4478,14 @@ fn read_gcloud_adc() -> Result<(String, String, String)> { } /// Canonical provider type string for the Anthropic subscription OAuth flow. -const ANTHROPIC_OAUTH_PROVIDER_TYPE: &str = "anthropic-oauth"; +pub const ANTHROPIC_OAUTH_PROVIDER_TYPE: &str = "anthropic-oauth"; + +/// Default provider name when `--from-claude-login` is used without `--name`. +pub const ANTHROPIC_OAUTH_DEFAULT_PROVIDER_NAME: &str = "claude-subscription"; + +/// Model used when auto-configuring the inference route for a freshly created +/// Anthropic subscription provider. +const ANTHROPIC_OAUTH_DEFAULT_MODEL: &str = "claude-sonnet-4-6"; /// Anthropic's public OAuth client ID used by Claude Code. The refresh grant /// requires only this client ID (no client secret). @@ -5121,6 +5142,7 @@ pub async fn provider_create_with_options( println!("{} Created provider {}", "✓".green().bold(), provider_name); println!("Configured Anthropic subscription credentials from the local Claude Code login"); + configure_default_inference_route_if_unset(server, &provider_name, tls).await; return Ok(()); } @@ -5128,6 +5150,82 @@ pub async fn provider_create_with_options( Ok(()) } +/// Point the user-facing inference route at a freshly created provider when no +/// route is configured yet, so `--from-claude-login` yields a working setup +/// without a separate `inference set` step. Best-effort: the provider was +/// already created successfully, so route problems only print a hint instead +/// of failing the command. Verification is skipped because subscription rate +/// limits make the validation probe flaky (a 429 would reject a valid token). +async fn configure_default_inference_route_if_unset( + server: &str, + provider_name: &str, + tls: &TlsOptions, +) { + let manual_hint = format!( + "run `openshell inference set --provider {provider_name} --model {ANTHROPIC_OAUTH_DEFAULT_MODEL} --no-verify` to configure it manually" + ); + + let mut client = match grpc_inference_client(server, tls).await { + Ok(client) => client, + Err(e) => { + println!("Could not reach the inference API to configure a route ({e}); {manual_hint}"); + return; + } + }; + + let route_unset = match client + .get_cluster_inference(GetClusterInferenceRequest { + route_name: String::new(), + }) + .await + { + Ok(response) => response.into_inner().provider_name.trim().is_empty(), + Err(status) if status.code() == Code::NotFound => true, + Err(status) => { + println!( + "Could not check the current inference route ({}); {manual_hint}", + status.message() + ); + return; + } + }; + + if !route_unset { + println!( + "Inference route already configured; left unchanged (use `openshell inference set --provider {provider_name}` to switch)" + ); + return; + } + + match client + .set_cluster_inference(SetClusterInferenceRequest { + provider_name: provider_name.to_string(), + model_id: ANTHROPIC_OAUTH_DEFAULT_MODEL.to_string(), + route_name: String::new(), + verify: false, + no_verify: true, + timeout_secs: 0, + }) + .await + { + Ok(response) => { + let configured = response.into_inner(); + println!( + "{} Configured inference route: provider {} model {}", + "✓".green().bold(), + configured.provider_name, + configured.model_id + ); + } + Err(status) => { + println!( + "Could not auto-configure the inference route ({}); {manual_hint}", + status.message() + ); + } + } +} + fn provider_profile_allows_empty_credentials(profile: &ProviderProfile) -> bool { ProviderTypeProfile::from_proto(profile).allows_empty_provider_credentials() } From 26054228f90845e2af2235549b9c05dec83a0a49 Mon Sep 17 00:00:00 2001 From: Cedric Fitzgerald Date: Tue, 14 Jul 2026 20:35:04 -0400 Subject: [PATCH 5/6] feat(server): hint re-login when subscription token refresh is rejected 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 --- .../openshell-server/src/provider_refresh.rs | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index b0b9a927c4..f7b01d17a4 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -377,6 +377,14 @@ pub async fn refresh_provider_credential( } Err(err) => { let now_ms = current_time_ms(); + let err = if anthropic_subscription_login_rejected(&provider, &err) { + Status::new( + err.code(), + format!("{}{ANTHROPIC_RELOGIN_HINT}", err.message()), + ) + } else { + err + }; state.status = "error".to_string(); state.last_error = err.message().to_string(); state.next_refresh_at_ms = @@ -397,6 +405,18 @@ pub async fn refresh_provider_credential( } } +const ANTHROPIC_RELOGIN_HINT: &str = "; the subscription login is no longer valid — run `claude login` on the host, then delete and recreate the provider with `openshell provider create --from-claude-login`"; + +/// True when an `anthropic-oauth` refresh failed because the token endpoint +/// rejected the grant (revoked login, lapsed plan) rather than a transient +/// network or gateway error, so re-authenticating on the host is the fix. +fn anthropic_subscription_login_rejected(provider: &Provider, err: &Status) -> bool { + 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") +} + async fn apply_minted_credential( store: &Store, provider: &Provider, @@ -772,9 +792,9 @@ async fn run_refresh_worker_tick(store: &Store) -> Result<(), Status> { #[cfg(test)] mod tests { use super::{ - NewRefreshStateConfig, get_refresh_state, new_refresh_state, put_refresh_state, - refresh_provider_credential, refresh_state_name, refresh_strategy_name, - run_refresh_worker_tick, seconds_until_ms, + NewRefreshStateConfig, anthropic_subscription_login_rejected, get_refresh_state, + new_refresh_state, put_refresh_state, refresh_provider_credential, refresh_state_name, + refresh_strategy_name, run_refresh_worker_tick, seconds_until_ms, }; use crate::persistence::test_store; use openshell_core::ObjectId; @@ -783,6 +803,7 @@ mod tests { Provider, ProviderCredentialRefreshStrategy, Sandbox, SandboxSpec, }; use std::collections::HashMap; + use tonic::Status; use wiremock::matchers::{body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -1180,6 +1201,42 @@ mod tests { } } + #[test] + fn relogin_hint_applies_only_to_anthropic_oauth_grant_rejection() { + let rejected = Status::failed_precondition("token endpoint returned HTTP 400 Bad Request"); + assert!(anthropic_subscription_login_rejected( + &provider("plan", "anthropic-oauth"), + &rejected + )); + assert!( + anthropic_subscription_login_rejected( + &provider("plan", "claude-subscription"), + &rejected + ), + "type alias must get the hint too" + ); + assert!( + !anthropic_subscription_login_rejected( + &provider("plan", "anthropic-oauth"), + &Status::unavailable("token endpoint request failed: timeout") + ), + "transient network errors must not suggest re-login" + ); + assert!( + !anthropic_subscription_login_rejected( + &provider("plan", "anthropic-oauth"), + &Status::failed_precondition( + "token endpoint returned HTTP 500 Internal Server Error" + ) + ), + "server-side errors must not suggest re-login" + ); + assert!(!anthropic_subscription_login_rejected( + &provider("graph", "custom"), + &rejected + )); + } + const TEST_RSA_PRIVATE_KEY: &str = r"-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCvCoZ0mVHpCHsF zeeqw2caNIe/eb4BQUccFPhZfRnF7sCfyB84zTBmuwG2umRBdjFnVsfIIZRp2HcD From 195c591c44a920c545fdd8d2dee1d27f48bb3c2d Mon Sep 17 00:00:00 2001 From: Cedric Fitzgerald Date: Tue, 14 Jul 2026 20:35:12 -0400 Subject: [PATCH 6/6] docs(providers): document subscription env preset, billing banner, and 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 --- architecture/gateway.md | 11 +++++++- docs/providers/anthropic-subscription.mdx | 34 ++++++++++++----------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index a361825660..9421578c1d 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -350,10 +350,19 @@ The CLI `--from-claude-login` harvests the local Claude Code login (macOS Keychain `Claude Code-credentials` or `~/.claude/.credentials.json`) on the host, stores the access token under the non-injectable `ANTHROPIC_OAUTH_TOKEN` credential, and calls `ConfigureProviderRefresh` with the subscription's refresh -token (Anthropic public `client_id` only, no secret). The background refresh +token (Anthropic public `client_id` only, no secret). With `--from-claude-login` +the provider name and type default (`claude-subscription` / `anthropic-oauth`), +and when no user inference route exists the CLI points the route at the new +provider (best-effort, unverified). The background refresh worker rotates the access token ahead of expiry and persists Anthropic's rotated refresh token. The access token is never exported into sandbox environments; it rides the inference route bundle and is injected only at the egress boundary. +Instead, the provider plugin projects `ANTHROPIC_BASE_URL=https://inference.local` +and a placeholder `ANTHROPIC_AUTH_TOKEN` into bound sandboxes so agent CLIs work +without manual configuration or confirmation prompts; `inference.local` replaces +the placeholder auth with the real token before forwarding. The base URL is +resolved to its real value in the sandbox env (agents must parse it at startup); +the auth token stays an egress placeholder like any other credential. ## Supervisor Relay diff --git a/docs/providers/anthropic-subscription.mdx b/docs/providers/anthropic-subscription.mdx index 4fe69b6959..46c2134ff8 100644 --- a/docs/providers/anthropic-subscription.mdx +++ b/docs/providers/anthropic-subscription.mdx @@ -22,14 +22,13 @@ Use this provider when you want sandboxed agents to consume your Claude subscrip Create the provider with `--from-claude-login`: ```shell -openshell provider create \ - --name claude-subscription \ - --type anthropic-oauth \ - --from-claude-login +openshell provider create --from-claude-login ``` `--from-claude-login` reads the local Claude Code login, stores the access token as the gateway-side `ANTHROPIC_OAUTH_TOKEN` credential, and configures an OAuth2 refresh-token flow using your subscription's refresh token. The gateway refreshes the access token ahead of expiry and updates running sandboxes without a restart. +The provider name defaults to `claude-subscription` and the type to `anthropic-oauth`; pass `--name` or `--type` to override. When no inference route is configured yet, the command also points the user-facing route at the new provider with a default model, so no separate `inference set` step is needed. + The CLI reads the macOS Keychain first, then falls back to `~/.claude/.credentials.json`. If neither contains a login, the command fails and directs you to run `claude login`. @@ -46,6 +45,8 @@ The subscription OAuth token represents your whole plan, so it is shared across To rotate or revoke, delete and recreate the provider, or rotate the credential through `openshell provider refresh`. +If the refresh token becomes invalid — you logged out of Claude Code, revoked the session, or the plan lapsed — `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`. + ## Configure Inference Routing Enable provider endpoint injection so the Anthropic network endpoint is added to sandbox policies automatically: @@ -54,7 +55,7 @@ Enable provider endpoint injection so the Anthropic network endpoint is added to openshell settings set --global --key providers_v2_enabled --value true --yes ``` -Then point `inference.local` at the provider: +Provider creation with `--from-claude-login` configures the route automatically when none exists. To change the model, or when another route was already configured, set it explicitly: ```shell openshell inference set \ @@ -74,24 +75,25 @@ The complete setup from scratch: # 1. Enable provider endpoint injection openshell settings set --global --key providers_v2_enabled --value true --yes -# 2. Authenticate on the host, then create the provider -claude login -openshell provider create --name claude-subscription --type anthropic-oauth --from-claude-login - -# 3. Configure inference routing -openshell inference set --provider claude-subscription --model claude-sonnet-4-6 +# 2. Create the provider from the local Claude Code login +# (also configures the inference route when none exists) +openshell provider create --from-claude-login -# 4. Create a sandbox with the provider attached -openshell sandbox create --name my-sandbox --provider claude-subscription +# 3. Create a sandbox with the provider attached and launch Claude Code +openshell sandbox create --provider claude-subscription -- claude ``` -Then inside the sandbox, launch Claude Code against the local endpoint: +Step 3 drops you into Claude Code running inside the sandbox on your subscription. In an existing sandbox, launch it with no configuration: ```shell -ANTHROPIC_BASE_URL="https://inference.local" ANTHROPIC_API_KEY=unused claude -p "Reply with exactly: pong" +claude -p "Reply with exactly: pong" ``` -Setting `ANTHROPIC_API_KEY` to any placeholder makes Claude Code talk to the gateway endpoint directly instead of starting its own OAuth login flow inside the sandbox. The placeholder key never reaches Anthropic — `inference.local` strips it and injects the real subscription token before forwarding. Drop the `-p "…"` to launch the interactive TUI instead. +Sandboxes bound to an `anthropic-oauth` provider start with `ANTHROPIC_BASE_URL=https://inference.local` and a placeholder `ANTHROPIC_AUTH_TOKEN` preset, so agent CLIs use the gateway endpoint instead of prompting for an interactive login. The auth token is used rather than `ANTHROPIC_API_KEY` because Claude Code accepts it without a confirmation prompt. The placeholder never reaches Anthropic — `inference.local` replaces caller auth with the real subscription token before forwarding. Set either variable explicitly to override the preset; tools that only read `ANTHROPIC_API_KEY` can be pointed at the gateway by setting that variable to any value. Drop the `-p "…"` to launch the interactive TUI instead. + + +Claude Code's startup banner may report API billing because it authenticates through an environment token. The label is cosmetic: the subscription token is injected at the egress boundary, outside the sandbox, so requests are billed to your subscription plan, not to metered API usage. + ## Next Steps