Skip to content
26 changes: 25 additions & 1 deletion architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,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
Expand Down Expand Up @@ -449,6 +450,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
Expand Down
82 changes: 67 additions & 15 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,36 +749,42 @@ impl From<CliEditor> 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<String>,

/// 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<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<String>,

/// 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.
Expand Down Expand Up @@ -2830,16 +2836,23 @@ 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,
provider_type.as_str(),
from_existing,
&credentials,
from_gcloud_adc,
from_claude_login,
runtime_credentials,
&config,
&tls,
Expand Down Expand Up @@ -4016,14 +4029,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([
Expand Down Expand Up @@ -4067,8 +4119,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);
Expand Down
Loading
Loading