Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
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 @@ -747,36 +747,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 @@ -2893,16 +2899,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 @@ -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([
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading