diff --git a/architecture/gateway.md b/architecture/gateway.md index d873b2a105..9421578c1d 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,29 @@ 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-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 +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). 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 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..6a1fa73548 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -747,36 +747,42 @@ 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)] - 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", "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,9 +2899,15 @@ async fn main() -> Result<()> { from_existing, credentials, from_gcloud_adc, + from_claude_login, 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, @@ -2903,6 +2915,7 @@ async fn main() -> Result<()> { from_existing, &credentials, from_gcloud_adc, + from_claude_login, runtime_credentials, &config, &tls, @@ -4059,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([ @@ -4110,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 0182e1b668..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!( @@ -4463,6 +4477,185 @@ 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. +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). +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 +4839,7 @@ pub async fn provider_create( credentials, from_gcloud_adc, false, + false, config, tls, ) @@ -4653,6 +4847,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 +4855,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 +4865,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 +4940,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 +4965,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 +5019,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 +5045,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,10 +5109,123 @@ 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"); + configure_default_inference_route_if_unset(server, &provider_name, tls).await; + return Ok(()); + } + println!("{} Created provider {}", "✓".green().bold(), provider_name); 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() } @@ -9377,6 +9726,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..04a9f16454 100644 --- a/crates/openshell-core/src/inference.rs +++ b/crates/openshell-core/src/inference.rs @@ -108,6 +108,48 @@ 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"; + +/// 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 +/// 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 +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"), "nvidia" => Some("nvidia"), "deepinfra" => Some("deepinfra"), "aws-bedrock" => Some("aws-bedrock"), @@ -238,6 +281,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 +520,43 @@ mod tests { assert!(headers.is_empty()); } + #[test] + fn profile_for_anthropic_oauth_types() { + 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_subscription_to_anthropic_oauth() { + assert_eq!( + normalize_inference_provider_type("claude-subscription"), + 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-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/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-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-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-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 d5a5f5c909..b16dcb1847 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,72 @@ 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-subscription".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)); + // 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] async fn resolve_provider_env_vertex_omits_agent_config_when_project_and_region_absent() { let store = test_store().await; 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 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) diff --git a/docs/providers/anthropic-subscription.mdx b/docs/providers/anthropic-subscription.mdx new file mode 100644 index 0000000000..46c2134ff8 --- /dev/null +++ b/docs/providers/anthropic-subscription.mdx @@ -0,0 +1,102 @@ +--- +# 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 --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`. + + +`--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`. + +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: + +```shell +openshell settings set --global --key providers_v2_enabled --value true --yes +``` + +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 \ + --provider claude-subscription \ + --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. Create the provider from the local Claude Code login +# (also configures the inference route when none exists) +openshell provider create --from-claude-login + +# 3. Create a sandbox with the provider attached and launch Claude Code +openshell sandbox create --provider claude-subscription -- claude +``` + +Step 3 drops you into Claude Code running inside the sandbox on your subscription. In an existing sandbox, launch it with no configuration: + +```shell +claude -p "Reply with exactly: pong" +``` + +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 + +- 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..73f451b576 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-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` | @@ -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