From d23140a6cdf3b4429847b08df9ba661d6d0e2ee4 Mon Sep 17 00:00:00 2001 From: John Myers Date: Wed, 26 Aug 2026 10:11:55 -0700 Subject: [PATCH 1/6] feat(providers): make profiles authoritative Closes #1988 Signed-off-by: John Myers --- .agents/skills/openshell-cli/SKILL.md | 24 +- .agents/skills/tui-development/SKILL.md | 21 +- architecture/google-vertex-ai-provider.md | 2 +- crates/openshell-cli/src/main.rs | 25 +- crates/openshell-cli/src/run.rs | 204 +++--- .../tests/ensure_providers_integration.rs | 24 +- .../tests/provider_commands_integration.rs | 117 ++-- .../sandbox_create_lifecycle_integration.rs | 39 +- crates/openshell-core/src/settings.rs | 25 +- crates/openshell-providers/src/lib.rs | 98 +-- crates/openshell-providers/src/profiles.rs | 60 +- .../src/providers/google_cloud.rs | 20 +- .../openshell-providers/src/providers/mod.rs | 41 -- .../src/providers/vertex.rs | 34 +- crates/openshell-server/src/grpc/policy.rs | 250 +------- crates/openshell-server/src/grpc/provider.rs | 595 +++++++----------- crates/openshell-server/src/grpc/sandbox.rs | 9 + .../src/provider_profile_sources.rs | 1 + .../openshell-server/src/provider_refresh.rs | 147 ----- .../openshell-supervisor-network/src/sigv4.rs | 21 +- crates/openshell-tui/src/app.rs | 107 ++-- crates/openshell-tui/src/lib.rs | 88 +-- crates/openshell-tui/src/ui/mod.rs | 31 +- crates/openshell-tui/src/ui/providers.rs | 10 +- docs/get-started/tutorials/index.mdx | 2 +- .../microsoft-graph-provider-refresh.mdx | 14 +- docs/providers/google-vertex-ai.mdx | 18 +- docs/sandboxes/manage-providers.mdx | 70 +-- docs/sandboxes/policy-advisor.mdx | 2 +- docs/sandboxes/providers-v2.mdx | 73 +-- e2e/python/test_sandbox_providers.py | 138 +--- e2e/rust/tests/credential_gating.rs | 92 ++- e2e/rust/tests/oidc_pkce.rs | 35 +- e2e/rust/tests/provider_refresh_keycloak.rs | 79 +-- e2e/rust/tests/provider_token_exchange.rs | 13 - e2e/rust/tests/workspace_lifecycle.rs | 12 +- .../agent-driven-policy-management/demo.sh | 32 +- examples/aws-s3-sts.md | 3 - examples/governance-interceptor/smoke.sh | 1 - examples/governance-interceptor/src/tests.rs | 2 +- examples/multi-agent-notepad/demo.sh | 6 +- examples/spiffe-token-exchange-demo/README.md | 4 - examples/spiffe-token-exchange-demo/demo.sh | 1 - .../spiffe-token-exchange-demo/podman/demo.sh | 1 - examples/spiffe-token-grant-demo/README.md | 4 - examples/spiffe-token-grant-demo/demo.sh | 1 - proto/openshell.proto | 5 +- providers/anthropic.yaml | 24 + providers/aws-s3.yaml | 2 - providers/openai.yaml | 24 + scripts/agents/gator/README.md | 2 +- scripts/agents/gator/agent.yaml | 2 - sdk/go/proto/openshellv1/openshell_grpc.pb.go | 10 +- 53 files changed, 911 insertions(+), 1754 deletions(-) create mode 100644 providers/anthropic.yaml create mode 100644 providers/openai.yaml diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index 313e239883..6cd8717036 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -115,9 +115,8 @@ The `--from-existing` flag discovers credentials from local state (e.g., `gh aut ### Create a provider with explicit credentials ```bash -openshell provider create --name my-api --type generic \ - --credential API_KEY \ - --config base_url=https://api.example.com +openshell provider create --name my-openai --type openai \ + --credential OPENAI_API_KEY ``` Bare `KEY` reads the value from the environment variable of that name and avoids placing the secret in shell history. Use `KEY=VALUE` only when the user explicitly accepts that exposure. @@ -137,14 +136,9 @@ must match. For a tunnel to `api.example.com:8443`, send port and is rejected. An absolute-form request target must use the same authority. -Profile-backed provider policy composition is controlled by the gateway-global -`providers_v2_enabled` setting. Static credential endpoint binding remains -active even when policy composition is disabled: - -```bash -openshell settings get --global -openshell settings set --global --key providers_v2_enabled --value true -``` +Profile-backed providers always contribute policy unless a gateway-global +policy is active. Static credential endpoint binding remains independently +enforced. ### Inspect and manage provider profiles @@ -171,13 +165,13 @@ openshell provider delete my-github Use refresh commands only when the provider profile and gateway support refreshable credentials: ```bash -openshell provider refresh status my-outlook -openshell provider refresh configure my-outlook \ +openshell provider refresh status my-provider +openshell provider refresh configure my-provider \ --credential-key MS_GRAPH_ACCESS_TOKEN \ --strategy oauth2-refresh-token \ --secret-material-env REFRESH_TOKEN=MS_GRAPH_REFRESH_TOKEN \ --credential-expires-at 2026-07-16T00:00:00Z -openshell provider refresh rotate my-outlook --credential-key MS_GRAPH_ACCESS_TOKEN +openshell provider refresh rotate my-provider --credential-key ACCESS_TOKEN ``` Prefer `--secret-material-env KEY[=ENVVAR]` for secret refresh material. `--material KEY=VALUE` is for non-secret material; `--secret-material-key` marks supplied material keys as secret. @@ -698,7 +692,7 @@ openshell settings set work-session --key ocsf_json_enabled --value true openshell settings delete work-session --key ocsf_json_enabled openshell settings get --global --json -openshell settings set --global --key providers_v2_enabled --value true +openshell settings set --global --key ocsf_json_enabled --value true ``` Global mutations prompt for confirmation. Use `--yes` only in reviewed automation. diff --git a/.agents/skills/tui-development/SKILL.md b/.agents/skills/tui-development/SKILL.md index 7f11db26ff..5d61af6ecd 100644 --- a/.agents/skills/tui-development/SKILL.md +++ b/.agents/skills/tui-development/SKILL.md @@ -43,8 +43,8 @@ Gateway (discovered via openshell_bootstrap::list_gateways()) - **Gateways** are discovered from on-disk config via `openshell_bootstrap::list_gateways()`. Each gateway has a name, endpoint, local/remote flag, and source label. - **Workspaces** are fetched via `ListWorkspaces`. The user cycles through workspaces with `[w]`, or views all workspaces at once. The current workspace scopes provider and sandbox lists. -- **Provider Profiles** are fetched per-workspace via `ListProviderProfiles` when `providers_v2_enabled` is true. Profiles are cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)` and matched to providers by type. They provide category, credential metadata, endpoint/binary counts, and inference capability. -- **Providers** are fetched via `ListProviders` scoped to the current workspace. Each `ProviderListEntry` pairs a provider with its optional cached profile. When `providers_v2_enabled` is true, CRUD operations are read-only in the TUI; when false, the TUI supports create/update/delete. +- **Provider Profiles** are fetched per-workspace via `ListProviderProfiles`. Profiles are cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)` and matched to providers by type. They provide category, credential metadata, endpoint/binary counts, and inference capability. +- **Providers** are fetched via `ListProviders` scoped to the current workspace. Each `ProviderListEntry` pairs a provider with its optional cached profile. The TUI supports profile-backed create, update, and delete operations. - **Global Settings** are fetched via `GetGatewayConfig` and displayed in a tabbed pane alongside providers on the dashboard. Each setting is a registered key with a typed value (bool/int/string). Platform-admin access is required; `PermissionDenied` disables the pane. - **Sandboxes** belong to the active gateway and workspace. Fetched via `ListSandboxes` with a periodic tick refresh. - **Sandbox Settings** are effective settings returned by `GetSandboxConfig`, each with a scope (sandbox, global, or unset). Globally-managed settings are blocked from sandbox-level edits. @@ -170,7 +170,7 @@ Phase 2: WatchSandbox(follow_logs: true) → live tail → send via Event::L **Sandboxes**: Fetched via `ListSandboxes` on a 2-second tick, scoped to the current workspace (or all workspaces). -**Providers**: Fetched via `ListProviders` on each tick. When `providers_v2_enabled` is true, provider profiles are also fetched per-workspace via `ListProviderProfiles` and cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)`. +**Providers**: Fetched via `ListProviders` on each tick. Provider profiles are fetched per-workspace via `ListProviderProfiles` and cached in a `ProviderProfileCache` keyed by `(workspace, profile_id)`. **Settings**: Global settings are fetched via `GetGatewayConfig` on each tick. Sandbox settings are fetched alongside the sandbox policy via `GetSandboxConfig` and refreshed on each tick when viewing a sandbox. @@ -321,7 +321,7 @@ TUI actions should parallel `openshell` CLI commands so users have familiar ment | `openshell sandbox connect` | `[s]` on sandbox policy view to launch SSH shell | | `openshell logs ` | `[l]` on sandbox detail to open log viewer | | `openshell provider list` | Provider table on Dashboard (middle pane) | -| `openshell provider create` | `[c]` on provider panel (when not providers_v2) | +| `openshell provider create` | `[c]` on provider panel | | `openshell status` | Status in title bar + gateway list | When adding new TUI features, check what the CLI offers and maintain consistency. @@ -383,10 +383,7 @@ All actions are accessible via keyboard shortcuts displayed in the nav bar. The **Dashboard (Gateways focus):** `[Tab] Switch Panel [Enter] Select [j/k] Navigate │ [:] Command [q] Quit` -**Dashboard (Providers focus, providers_v2):** -`[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail read-only │ [:] Command [q] Quit` - -**Dashboard (Providers focus, legacy):** +**Dashboard (Providers focus):** `[Tab] Switch Panel [h/l] Switch Tab [j/k] Navigate [Enter] Detail [c] Create [u] Update [d] Delete │ [:] Command [q] Quit` **Dashboard (Global Settings focus):** @@ -412,7 +409,7 @@ All actions are accessible via keyboard shortcuts displayed in the nav bar. The | File | Purpose | | --- | --- | | `crates/openshell-tui/Cargo.toml` | Crate manifest — dependencies on `openshell-core`, `openshell-bootstrap`, `ratatui`, `crossterm`, `tonic`, `tokio` | -| `crates/openshell-tui/src/lib.rs` | Entry point. Event loop, gRPC calls (`refresh_data`, `refresh_providers`, `refresh_global_settings`, `refresh_workspaces`, `refresh_sandboxes`, `spawn_log_stream`, `handle_sandbox_delete`, `fetch_providers_v2_setting`), gateway switching, mTLS channel building, provider CRUD spawners, settings CRUD spawners, draft approval spawners | +| `crates/openshell-tui/src/lib.rs` | Entry point. Event loop, gRPC calls (`refresh_data`, `refresh_providers`, `refresh_global_settings`, `refresh_workspaces`, `refresh_sandboxes`, `spawn_log_stream`, `handle_sandbox_delete`), gateway switching, mTLS channel building, provider CRUD spawners, settings CRUD spawners, draft approval spawners | | `crates/openshell-tui/src/app.rs` | `App` state struct, `Screen`/`Focus`/`InputMode`/`LogSourceFilter`/`MiddlePaneTab`/`SandboxPolicyTab` enums, `LogLine`/`GatewayEntry`/`GlobalSettingEntry`/`SandboxSettingEntry`/`ProviderListEntry`/`ProviderDetailView` structs, create sandbox/provider form state, all key handling logic | | `crates/openshell-tui/src/event.rs` | `Event` enum (`Key`, `Mouse`, `Tick`, `Redraw`, `Resize`, `LogLines`, `CreateResult`, `ProviderCreateResult`, `ProviderDetailFetched`, `ProviderUpdateResult`, `ProviderDeleteResult`, `DraftActionResult`, `GlobalSettingsFetched`, `GlobalSettingSetResult`, `GlobalSettingDeleteResult`, `SandboxSettingSetResult`, `SandboxSettingDeleteResult`, `ForwardWarnings`), `EventHandler` with mpsc channels and crossterm polling | | `crates/openshell-tui/src/theme.rs` | `colors` module (NVIDIA_GREEN, EVERGLADE, BG, FG) and `styles` module (all `Style` constants) | @@ -526,7 +523,6 @@ The connect timeout for gateway switching is 10 seconds with HTTP/2 keepalive at 4. On success: - `app.client` is replaced with a new intercepted client - `reset_sandbox_state()` clears all sandbox/log/draft/policy data - - `fetch_providers_v2_setting()` probes the new gateway's `GetGatewayConfig` to determine whether providers_v2 mode is enabled, so provider CRUD controls render correctly - `refresh_data()` runs the full capability refresh sequence: `refresh_health` → `refresh_global_settings` → `refresh_workspaces` → `refresh_providers` → `refresh_sandboxes` 5. On failure: `status_text` shows the error @@ -534,9 +530,8 @@ The connect timeout for gateway switching is 10 seconds with HTTP/2 keepalive at On launch, before the event loop starts: -1. `fetch_providers_v2_setting()` — probe gateway capability -2. `refresh_gateway_list()` — discover gateways from disk -3. `refresh_data()` — full refresh (health, global settings, workspaces, providers, sandboxes) +1. `refresh_gateway_list()` — discover gateways from disk +2. `refresh_data()` — full refresh (health, global settings, workspaces, providers, sandboxes) ### Workspace switching lifecycle diff --git a/architecture/google-vertex-ai-provider.md b/architecture/google-vertex-ai-provider.md index aac0c11c27..e161471c9f 100644 --- a/architecture/google-vertex-ai-provider.md +++ b/architecture/google-vertex-ai-provider.md @@ -375,7 +375,7 @@ The `discovery` section lists `[service_account_token, gcloud_adc_token]` as the credential sources the gateway will scan during `--from-existing`. The `endpoints` section enumerates all Vertex AI API hosts that sandbox network -policies must permit when `providers_v2_enabled=true`: +policies permit through provider profile composition: - `*-aiplatform.googleapis.com:443` (regional endpoints) - `aiplatform.googleapis.com:443` (global endpoint) diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index aaabf26625..1a78cc5214 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -368,10 +368,9 @@ const POLICY_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m const SETTINGS_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell settings get my-sandbox $ openshell settings get --global - $ openshell settings set --global --key providers_v2_enabled --value true $ openshell settings set my-sandbox --key ocsf_json_enabled --value true $ openshell settings set --global --key ocsf_json_enabled --value true - $ openshell settings delete --global --key providers_v2_enabled + $ openshell settings delete --global --key ocsf_json_enabled "; const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m @@ -811,7 +810,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).multiple(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + #[command(group = clap::ArgGroup::new("cred_source").required(false).multiple(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Create { /// Provider name. #[arg(long)] @@ -4714,8 +4713,8 @@ mod tests { } #[test] - fn provider_create_requires_credential_source() { - let err = Cli::try_parse_from([ + fn provider_create_accepts_no_credential_source() { + let cli = Cli::try_parse_from([ "openshell", "provider", "create", @@ -4724,9 +4723,21 @@ mod tests { "--type", "spiffe-token-demo", ]) - .expect_err("provider create should require a credential source"); + .expect("provider create should allow profiles without static credentials"); - assert!(err.to_string().contains("--runtime-credentials")); + assert!(matches!( + cli.command, + Some(Commands::Provider { + command: Some(ProviderCommands::Create { + from_existing: false, + credentials, + from_gcloud_adc: false, + from_oidc_token: false, + runtime_credentials: false, + .. + }) + }) if credentials.is_empty() + )); } #[test] diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 0a0b21a7f4..48cd365579 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -55,14 +55,14 @@ use openshell_core::proto::{ SetInferenceRouteRequest, SettingScope, StartSandboxRequest, StopSandboxRequest, TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, - setting_value, tcp_forward_init, + tcp_forward_init, }; use openshell_core::settings; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use openshell_providers::{ - ProviderRegistry, ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, - discover_from_profile, normalize_provider_type, parse_profile_json, parse_profile_yaml, - profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, + ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, discover_from_profile, + normalize_provider_type, parse_profile_json, parse_profile_yaml, profile_to_json, + profile_to_yaml, profiles_to_json, profiles_to_yaml, }; use owo_colors::OwoColorize; use std::borrow::Cow; @@ -491,18 +491,7 @@ pub async fn sandbox_create( } None => None, }; - let inferred_provider = inferred_provider_type(command); - let providers_v2_enabled = - if inferred_provider.is_some() && auto_providers_override != Some(false) { - gateway_providers_v2_enabled(&mut client).await? - } else { - false - }; - let inferred_types: Vec = if providers_v2_enabled { - Vec::new() - } else { - inferred_provider.into_iter().collect() - }; + let inferred_types: Vec = inferred_provider_type(command).into_iter().collect(); let configured_providers = ensure_required_providers( &mut client, providers, @@ -2616,10 +2605,20 @@ pub async fn ensure_required_providers( if seen_names.insert(name.clone()) { configured_names.push(name.clone()); } - } else if let Some(provider_type) = normalize_provider_type(name) { + } else { + let profile_id = normalize_provider_type(name).unwrap_or(name); + let profile = fetch_provider_profile(client, profile_id, workspace) + .await + .map_err(|_| { + miette::miette!( + "provider '{name}' not found and no provider profile named '{profile_id}' is available. \ + Create or import the profile first, then create the provider" + ) + })?; + let provider_type = profile.id; auto_create_provider( client, - provider_type, + &provider_type, Some(name), auto_providers_override, &mut seen_names, @@ -2632,11 +2631,6 @@ pub async fn ensure_required_providers( type_to_name .entry(provider_type.to_ascii_lowercase()) .or_insert_with(|| name.clone()); - } else { - return Err(miette::miette!( - "provider '{name}' not found and '{name}' is not a recognized provider type. \ - Create it first with `openshell provider create --type --name {name}`" - )); } } @@ -2727,17 +2721,21 @@ async fn auto_create_provider( return Ok(()); } + let profile = fetch_provider_profile(client, provider_type, workspace).await?; let discovered = discover_existing_provider_data(client, provider_type, workspace) .await .map_err(|err| miette::miette!("failed to discover provider '{provider_type}': {err}"))?; - let Some(discovered) = discovered else { - eprintln!( - "{} No existing local credentials/config found for '{}'. You can configure it from inside the sandbox.", - "!".yellow(), - provider_type - ); - eprintln!(); - return Ok(()); + let discovered = match discovered { + Some(discovered) => discovered, + None if provider_profile_allows_empty_credentials(&profile) => { + openshell_providers::DiscoveredProvider::default() + } + None => { + return Err(miette::miette!( + "no existing local credentials found for provider profile '{provider_type}'. \ + Create it first with `openshell provider create --type {provider_type} --name {provider_type} --credential `" + )); + } }; if let Some(exact_name) = preferred_name { @@ -3261,25 +3259,6 @@ fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String service_url.to_string() } -async fn gateway_providers_v2_enabled(client: &mut crate::tls::GrpcClient) -> Result { - let response = client - .get_gateway_config(GetGatewayConfigRequest {}) - .await - .into_diagnostic()? - .into_inner(); - let Some(setting) = response.settings.get(settings::PROVIDERS_V2_ENABLED_KEY) else { - return Ok(false); - }; - match setting.value.as_ref() { - Some(setting_value::Value::BoolValue(enabled)) => Ok(*enabled), - None => Ok(false), - Some(_) => Err(miette::miette!( - "gateway setting '{}' has invalid value type; expected bool", - settings::PROVIDERS_V2_ENABLED_KEY - )), - } -} - async fn fetch_provider_profile( client: &mut crate::tls::GrpcClient, provider_type: &str, @@ -3294,7 +3273,7 @@ async fn fetch_provider_profile( .map_err(|status| { if status.code() == Code::NotFound { miette::miette!( - "provider profile '{provider_type}' not found; providers v2 discovery requires a provider profile" + "provider profile '{provider_type}' not found; import a matching profile before using this provider type" ) } else { miette::miette!(status.to_string()) @@ -3312,36 +3291,28 @@ async fn discover_existing_provider_data( provider_type: &str, workspace: &str, ) -> Result> { - if gateway_providers_v2_enabled(client).await? { - let profile = fetch_provider_profile(client, provider_type, workspace).await?; - let profile = ProviderTypeProfile::from_proto(&profile); - let mut discovered = - discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { - miette::miette!("failed to discover existing provider data from profile: {err}") - })?; + let profile = fetch_provider_profile(client, provider_type, workspace).await?; + let profile = ProviderTypeProfile::from_proto(&profile); + let mut discovered = discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { + miette::miette!("failed to discover existing provider data from profile: {err}") + })?; - // Vertex AI config keys (project ID, region, base URL, publisher) are not - // declared in the profile's discovery.credentials list, so discover_from_profile - // does not scan them. Scan them directly here so --from-existing captures them. - if provider_type == VERTEX_AI_PROVIDER_TYPE { - let discovered = discovered.get_or_insert_with(Default::default); - for key in openshell_core::inference::VERTEX_AI_CONFIG_KEY_NAMES { - if let Ok(val) = std::env::var(key) { - let val = val.trim().to_string(); - if !val.is_empty() { - discovered.config.entry(key.to_string()).or_insert(val); - } + // Vertex AI config keys (project ID, region, base URL, publisher) are not + // declared in the profile's discovery.credentials list, so discover_from_profile + // does not scan them. Scan them directly here so --from-existing captures them. + if provider_type == VERTEX_AI_PROVIDER_TYPE { + let discovered = discovered.get_or_insert_with(Default::default); + for key in openshell_core::inference::VERTEX_AI_CONFIG_KEY_NAMES { + if let Ok(val) = std::env::var(key) { + let val = val.trim().to_string(); + if !val.is_empty() { + discovered.config.entry(key.to_string()).or_insert(val); } } } - - Ok(discovered) - } else { - let registry = ProviderRegistry::new(); - registry - .discover_existing(provider_type) - .map_err(|err| miette::miette!("failed to discover existing provider data: {err}")) } + + Ok(discovered) } /// Canonical provider type string for Google Vertex AI. @@ -3588,44 +3559,19 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> let mut client = grpc_client(server, tls).await?; - let provider_type = if let Some(provider_type) = normalize_provider_type(provider_type) { - provider_type.to_string() - } else { - let profile_id = provider_type.trim(); - if profile_id.is_empty() { - return Err(miette::miette!("provider type is required")); - } - let response = client - .get_provider_profile(GetProviderProfileRequest { - id: profile_id.to_string(), - workspace: profile_workspace.to_string(), - }) - .await; - match response { - Ok(response) => response - .into_inner() - .profile - .map(|profile| profile.id) - .filter(|id| !id.trim().is_empty()) - .unwrap_or_else(|| profile_id.to_string()), - Err(status) if status.code() == Code::NotFound => { - return Err(miette::miette!( - "unsupported provider type or profile: {provider_type}" - )); - } - Err(status) => return Err(status).into_diagnostic(), - } - }; + let profile_id = normalize_provider_type(provider_type).unwrap_or_else(|| provider_type.trim()); + if profile_id.is_empty() { + return Err(miette::miette!("provider type is required")); + } + let provider_profile = fetch_provider_profile(&mut client, profile_id, profile_workspace) + .await + .map_err(|err| { + miette::miette!("unsupported provider type or profile: {profile_id} ({err})") + })?; + let provider_type = provider_profile.id.clone(); let adc_credential_key = if from_gcloud_adc { - let profile = fetch_provider_profile(&mut client, &provider_type, profile_workspace) - .await - .map_err(|err| { - miette::miette!( - "--from-gcloud-adc is not supported for '{provider_type}' providers ({err})" - ) - })?; - let profile = ProviderTypeProfile::from_proto(&profile); + let profile = ProviderTypeProfile::from_proto(&provider_profile); let adc_cred = profile.adc_credential().ok_or_else(|| { miette::miette!( "--from-gcloud-adc is not supported for '{provider_type}' providers \ @@ -3648,7 +3594,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> }; let oidc_profile = if from_oidc_token { - Some(fetch_provider_profile(&mut client, &provider_type, profile_workspace).await?) + Some(provider_profile.clone()) } else { None }; @@ -3681,25 +3627,12 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> if from_existing { return Err(missing_credentials_error(&provider_type)); } - if !from_gcloud_adc && !runtime_credentials { - return Err(missing_credentials_error(&provider_type)); + if runtime_credentials && !provider_profile_allows_runtime_credentials(&provider_profile) { + return Err(miette::miette!( + "--runtime-credentials is only valid for provider profiles whose required credentials are resolved at runtime" + )); } - let allows_empty_credentials = if runtime_credentials { - provider_profile_allows_empty_credentials( - &fetch_provider_profile(&mut client, &provider_type, profile_workspace).await?, - ) - } else { - fetch_provider_profile(&mut client, &provider_type, profile_workspace) - .await - .ok() - .is_some_and(|profile| provider_profile_allows_empty_credentials(&profile)) - }; - if !allows_empty_credentials { - if runtime_credentials { - return Err(miette::miette!( - "--runtime-credentials is only valid for provider profiles whose required credentials are resolved at runtime" - )); - } + if !provider_profile_allows_empty_credentials(&provider_profile) { return Err(missing_credentials_error(&provider_type)); } } @@ -3810,6 +3743,10 @@ fn provider_profile_allows_empty_credentials(profile: &ProviderProfile) -> bool ProviderTypeProfile::from_proto(profile).allows_empty_provider_credentials() } +fn provider_profile_allows_runtime_credentials(profile: &ProviderProfile) -> bool { + ProviderTypeProfile::from_proto(profile).allows_runtime_provider_credentials() +} + pub async fn provider_get( server: &str, name: &str, @@ -7930,9 +7867,10 @@ mod tests { #[test] fn inferred_provider_type_normalizes_aliases() { - // `glab` should resolve to `gitlab` + // Retired legacy types are not inferred, even when a custom profile + // with the same ID could be imported and attached explicitly. let result = inferred_provider_type(&["glab".to_string()]); - assert_eq!(result, Some("gitlab".to_string())); + assert_eq!(result, None); // `gh` should resolve to `github` let result = inferred_provider_type(&["gh".to_string()]); diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 8192989375..4c40bc5c96 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -307,14 +307,30 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("not implemented in test")) + let profiles = openshell_providers::builtin_profiles() + .iter() + .map(openshell_providers::ProviderTypeProfile::to_proto) + .collect(); + Ok(Response::new( + openshell_core::proto::ListProviderProfilesResponse { profiles }, + )) } async fn get_provider_profile( &self, - _request: tonic::Request, + request: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("not implemented in test")) + let id = request.into_inner().id; + let profile = openshell_providers::builtin_profiles() + .iter() + .find(|profile| profile.id == id) + .ok_or_else(|| Status::not_found("provider profile not found"))? + .to_proto(); + Ok(Response::new( + openshell_core::proto::ProviderProfileResponse { + profile: Some(profile), + }, + )) } async fn import_provider_profiles( @@ -843,7 +859,7 @@ async fn explicit_provider_name_errors_for_unrecognised_name() { "error should mention the name: {msg}" ); assert!( - msg.contains("not a recognized provider type"), + msg.contains("no provider profile"), "error should explain why it failed: {msg}" ); } diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 2c71e0b39f..9a5296e0c5 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -27,7 +27,7 @@ use openshell_core::proto::{ ProviderProfileDiscovery, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, Sandbox, SandboxResponse, SandboxStreamEvent, ServiceStatus, SettingValue, SupervisorMessage, UpdateProviderRequest, - WatchSandboxRequest, setting_value, + WatchSandboxRequest, }; use openshell_core::{ObjectId, ObjectName}; use std::collections::HashMap; @@ -1155,11 +1155,19 @@ async fn run_server() -> TestServer { } } -async fn enable_providers_v2(ts: &TestServer) { - ts.state.global_settings.lock().await.insert( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - SettingValue { - value: Some(setting_value::Value::BoolValue(true)), +async fn install_test_profile(ts: &TestServer, id: &str, credential_key: &str) { + ts.state.profiles.lock().await.insert( + id.to_string(), + ProviderProfile { + id: id.to_string(), + display_name: id.to_string(), + credentials: vec![ProviderProfileCredential { + name: "access_token".to_string(), + env_vars: vec![credential_key.to_string()], + required: true, + ..Default::default() + }], + ..Default::default() }, ); } @@ -1334,11 +1342,12 @@ async fn provider_list_json_empty() { #[tokio::test] async fn provider_refresh_cli_run_functions_wire_requests() { let ts = run_server().await; + install_test_profile(&ts, "custom-graph", "MS_GRAPH_ACCESS_TOKEN").await; run::provider_create( &ts.endpoint, "my-graph", - "outlook", + "custom-graph", false, &["MS_GRAPH_ACCESS_TOKEN=token".to_string()], false, @@ -1423,11 +1432,12 @@ async fn provider_refresh_cli_run_functions_wire_requests() { #[tokio::test] async fn provider_refresh_configure_reads_secret_material_from_env_off_argv() { let ts = run_server().await; + install_test_profile(&ts, "custom-chat", "GOOGLE_CHAT_ACCESS_TOKEN").await; run::provider_create( &ts.endpoint, "gc-bridge", - "outlook", + "custom-chat", false, &["GOOGLE_CHAT_ACCESS_TOKEN=pending".to_string()], false, @@ -1578,7 +1588,7 @@ async fn provider_create_allows_empty_credentials_for_gateway_refresh_profiles() } #[tokio::test] -async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_profiles() { +async fn provider_create_allows_no_source_for_runtime_resolved_profiles() { let ts = run_server().await; ts.state.profiles.lock().await.insert( "custom-refresh".to_string(), @@ -1598,7 +1608,7 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ }, ); - let err = run::provider_create( + run::provider_create( &ts.endpoint, "custom-refresh-provider", "custom-refresh", @@ -1610,11 +1620,10 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ &ts.tls, ) .await - .expect_err("empty runtime-resolved providers should require an explicit source"); + .expect("runtime-resolved provider should not require a credential source"); - assert!(err.to_string().contains("--runtime-credentials")); assert!( - !ts.state + ts.state .providers .lock() .await @@ -1622,6 +1631,36 @@ async fn provider_create_requires_runtime_credentials_for_empty_gateway_refresh_ ); } +#[tokio::test] +async fn provider_create_allows_credentialless_policy_profile() { + let ts = run_server().await; + + run::provider_create( + &ts.endpoint, + "pypi", + "pypi", + false, + &[], + false, + &[], + "default", + &ts.tls, + ) + .await + .expect("credential-less provider create"); + + let provider = ts + .state + .providers + .lock() + .await + .get("pypi") + .cloned() + .expect("pypi provider"); + assert!(provider.credentials.is_empty()); + assert_eq!(provider.r#type, "pypi"); +} + #[tokio::test] async fn sandbox_provider_cli_run_functions_wire_requests_and_idempotent_results() { let ts = run_server().await; @@ -1844,9 +1883,8 @@ binaries: [/usr/bin/custom] } #[tokio::test] -async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled() { +async fn provider_create_from_existing_uses_profile_discovery() { let ts = run_server().await; - enable_providers_v2(&ts).await; ts.state.profiles.lock().await.insert( "custom-discovery".to_string(), ProviderProfile { @@ -1896,7 +1934,7 @@ async fn provider_create_from_existing_uses_profile_discovery_when_v2_enabled() } #[tokio::test] -async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled() { +async fn provider_create_from_existing_uses_builtin_profile_discovery() { let ts = run_server().await; let _env = EnvVarGuard::set(&[("OPENAI_API_KEY", "legacy-openai-secret")]); @@ -1930,9 +1968,8 @@ async fn provider_create_from_existing_uses_registry_discovery_when_v2_disabled( } #[tokio::test] -async fn provider_create_from_existing_vertex_discovers_credentials_and_config_when_v2_enabled() { +async fn provider_create_from_existing_vertex_discovers_credentials_and_config() { let ts = run_server().await; - enable_providers_v2(&ts).await; let _env = EnvVarGuard::set(&[ ("VERTEX_AI_TOKEN", "ya29.vertex-v2-fallback"), ("VERTEX_AI_PROJECT_ID", "vertex-v2-project"), @@ -1993,9 +2030,8 @@ async fn provider_create_from_existing_vertex_discovers_credentials_and_config_w } #[tokio::test] -async fn provider_create_from_existing_requires_profile_when_v2_enabled() { +async fn provider_create_from_existing_requires_profile() { let ts = run_server().await; - enable_providers_v2(&ts).await; // Use "generic" which is a normalised type but has no built-in provider // profile, so v2 profile-based discovery fails with the expected message. let _env = EnvVarGuard::set(&[("GENERIC_API_KEY", "some-secret")]); @@ -2016,7 +2052,7 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() { assert!( err.to_string() - .contains("providers v2 discovery requires a provider profile"), + .contains("import a matching profile before using this provider type"), "unexpected error: {err}" ); assert!(!ts.state.providers.lock().await.contains_key("v2-generic")); @@ -2025,7 +2061,6 @@ async fn provider_create_from_existing_requires_profile_when_v2_enabled() { #[tokio::test] async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothing() { let ts = run_server().await; - enable_providers_v2(&ts).await; ts.state.profiles.lock().await.insert( "empty-discovery".to_string(), ProviderProfile { @@ -2073,9 +2108,8 @@ async fn provider_create_from_existing_fails_when_profile_discovery_finds_nothin } #[tokio::test] -async fn provider_update_from_existing_uses_profile_discovery_when_v2_enabled() { +async fn provider_update_from_existing_uses_profile_discovery() { let ts = run_server().await; - enable_providers_v2(&ts).await; ts.state.profiles.lock().await.insert( "custom-update-discovery".to_string(), ProviderProfile { @@ -2335,11 +2369,11 @@ async fn provider_create_rejects_key_only_credentials_without_local_env_value() } #[tokio::test] -async fn provider_create_supports_generic_type_and_env_lookup_credentials() { +async fn provider_create_rejects_profileless_generic_type() { let ts = run_server().await; let _guard = EnvVarGuard::set(&[("NAV_GENERIC_TEST_KEY", "generic-value")]); - run::provider_create( + let err = run::provider_create( &ts.endpoint, "my-generic", "generic", @@ -2351,24 +2385,12 @@ async fn provider_create_supports_generic_type_and_env_lookup_credentials() { &ts.tls, ) .await - .expect("provider create"); + .expect_err("profileless generic provider creation should fail"); - let mut client = openshell_cli::tls::grpc_client(&ts.endpoint, &ts.tls) - .await - .expect("grpc client should connect"); - let response = client - .get_provider(GetProviderRequest { - name: "my-generic".to_string(), - workspace: String::new(), - }) - .await - .expect("get provider should succeed") - .into_inner(); - let provider = response.provider.expect("provider should exist"); - assert_eq!(provider.r#type, "generic"); - assert_eq!( - provider.credentials.get("NAV_GENERIC_TEST_KEY"), - Some(&"generic-value".to_string()) + assert!( + err.to_string() + .contains("provider profile 'generic' not found"), + "unexpected error: {err}" ); } @@ -2487,14 +2509,14 @@ async fn provider_create_rejects_combined_from_gcloud_adc_and_credentials() { #[tokio::test] async fn provider_create_rejects_empty_env_var_for_key_only_credential() { let ts = run_server().await; - let _guard = EnvVarGuard::set(&[("NAV_EMPTY_ENV_KEY", "")]); + let _guard = EnvVarGuard::set(&[("NVIDIA_API_KEY", "")]); let err = run::provider_create( &ts.endpoint, "bad-provider", - "generic", + "nvidia", false, - &["NAV_EMPTY_ENV_KEY".to_string()], + &["NVIDIA_API_KEY".to_string()], false, &[], "default", @@ -2505,7 +2527,7 @@ async fn provider_create_rejects_empty_env_var_for_key_only_credential() { assert!( err.to_string() - .contains("requires local env var 'NAV_EMPTY_ENV_KEY' to be set to a non-empty value"), + .contains("requires local env var 'NVIDIA_API_KEY' to be set to a non-empty value"), "unexpected error: {err}" ); } @@ -2900,7 +2922,6 @@ async fn provider_create_from_gcloud_adc_rolls_back_provider_when_initial_rotate #[tokio::test] async fn provider_create_from_existing_vertex_config_only_reports_missing_vertex_credentials() { let ts = run_server().await; - enable_providers_v2(&ts).await; let _env = EnvVarGuard::set(&[ ("VERTEX_AI_PROJECT_ID", "vertex-config-only-project"), ("VERTEX_AI_REGION", "us-central1"), diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index bcc07619ee..58746f8466 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -28,7 +28,6 @@ use openshell_core::proto::{ RevokeSshSessionResponse, Sandbox, SandboxCondition, SandboxLogLine, SandboxPhase, SandboxResponse, SandboxStatus, SandboxStreamEvent, ServiceStatus, SettingValue, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, sandbox_stream_event, - setting_value, }; use std::collections::HashMap; use std::fs; @@ -316,14 +315,30 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("not implemented in test")) + let profiles = openshell_providers::builtin_profiles() + .iter() + .map(openshell_providers::ProviderTypeProfile::to_proto) + .collect(); + Ok(Response::new( + openshell_core::proto::ListProviderProfilesResponse { profiles }, + )) } async fn get_provider_profile( &self, - _request: tonic::Request, + request: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("not implemented in test")) + let id = request.into_inner().id; + let profile = openshell_providers::builtin_profiles() + .iter() + .find(|profile| profile.id == id) + .ok_or_else(|| Status::not_found("provider profile not found"))? + .to_proto(); + Ok(Response::new( + openshell_core::proto::ProviderProfileResponse { + profile: Some(profile), + }, + )) } async fn import_provider_profiles( @@ -1143,15 +1158,6 @@ async fn create_requests(server: &TestServer) -> Vec { server.openshell.state.create_requests.lock().await.clone() } -async fn enable_providers_v2(server: &TestServer) { - server.openshell.state.global_settings.lock().await.insert( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - SettingValue { - value: Some(setting_value::Value::BoolValue(true)), - }, - ); -} - fn test_tls(server: &TestServer) -> TlsOptions { server.tls.with_gateway_name("openshell") } @@ -1478,9 +1484,8 @@ async fn sandbox_create_sends_gpu_count_request() { } #[tokio::test] -async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { +async fn sandbox_create_skips_inferred_provider_without_local_credentials() { let server = run_server().await; - enable_providers_v2(&server).await; let fake_ssh_dir = tempfile::tempdir().unwrap(); let xdg_dir = tempfile::tempdir().unwrap(); let _env = test_env(&fake_ssh_dir, &xdg_dir); @@ -1491,7 +1496,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { &server.endpoint, "openshell", run::SandboxCreateConfig { - name: Some("v2-no-inferred-provider"), + name: Some("no-inferred-provider"), command: &["claude".into(), "--version".into()], tty_override: Some(true), ..test_config() @@ -1511,7 +1516,7 @@ async fn sandbox_create_does_not_infer_command_providers_when_v2_enabled() { .clone(); assert!( providers.is_empty(), - "providers v2 should not infer command providers, got {providers:?}" + "missing local credentials should skip inferred providers, got {providers:?}" ); } diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 156e4c3845..30fba51e31 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -74,8 +74,7 @@ impl RegisteredSetting { /// settable via `settings set`. The server validates that only registered /// keys are accepted. /// 5. Add a unit test in this module's `tests` section to cover the new key. -pub const PROVIDERS_V2_ENABLED_KEY: &str = "providers_v2_enabled"; - +/// /// Sandbox-level opt-in for the agent-driven policy proposal surface. /// /// When true, the supervisor installs the `policy_advisor` skill, serves @@ -108,13 +107,6 @@ pub const PROPOSAL_APPROVAL_MODE_KEY: &str = "proposal_approval_mode"; pub const PROPOSAL_APPROVAL_MODE_VALUES: &[&str] = &["manual", "auto"]; pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ - // Gateway-level opt-in for provider profile policy composition. Defaults - // to false when unset. - RegisteredSetting { - key: PROVIDERS_V2_ENABLED_KEY, - kind: SettingValueKind::Bool, - allowed_string_values: None, - }, // When true the sandbox writes OCSF v1.7.0 JSONL records to // `/var/log/openshell-ocsf*.log` (daily rotation, 3 files) in addition // to the human-readable shorthand log. Defaults to false (no JSONL written). @@ -168,9 +160,8 @@ pub fn parse_bool_like(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES, PROVIDERS_V2_ENABLED_KEY, - REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like, - registered_keys_csv, setting_for_key, + PROPOSAL_APPROVAL_MODE_KEY, PROPOSAL_APPROVAL_MODE_VALUES, REGISTERED_SETTINGS, + RegisteredSetting, SettingValueKind, parse_bool_like, registered_keys_csv, setting_for_key, }; #[test] @@ -185,18 +176,16 @@ mod tests { } #[test] - fn setting_for_key_returns_providers_v2_enabled() { - let setting = setting_for_key(PROVIDERS_V2_ENABLED_KEY) - .expect("providers_v2_enabled should be registered"); - assert_eq!(setting.kind, SettingValueKind::Bool); + fn setting_for_key_rejects_removed_providers_v2_enabled() { + assert!(setting_for_key("providers_v2_enabled").is_none()); } // ---- RegisteredSetting::validate_string_value ---- #[test] fn validate_string_value_accepts_anything_when_unconstrained() { - let setting = setting_for_key(PROVIDERS_V2_ENABLED_KEY) - .expect("providers_v2_enabled should be registered"); + let setting = + setting_for_key("ocsf_json_enabled").expect("ocsf_json_enabled should be registered"); // Bool-kind entries currently leave `allowed_string_values = None`; // the helper still returns Ok for arbitrary strings. assert!(setting.validate_string_value("anything").is_ok()); diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index fabe1a5bed..4e53a99dff 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -56,21 +56,10 @@ pub struct ProviderDiscoverySpec { pub credential_env_vars: &'static [&'static str], } -pub trait ProviderPlugin: Send + Sync { - /// Canonical provider id (for example: "claude", "gitlab"). +trait ProviderPlugin: Send + Sync { + /// Canonical provider id. fn id(&self) -> &'static str; - /// Discover provider credentials and config from the local machine. - fn discover_existing(&self) -> Result, ProviderError>; - - /// Return the known credential environment variable names for this provider type. - /// - /// Used by the TUI to label BYO key entry fields and to choose which - /// env var name to store a manually-entered credential under. - fn credential_env_vars(&self) -> &'static [&'static str] { - &[] - } - /// Inject provider-specific environment variables into the sandbox env. /// /// Called during sandbox creation to project provider config (project IDs, @@ -79,25 +68,6 @@ pub trait ProviderPlugin: Send + Sync { fn inject_env(&self, _provider: &Provider, _env: &mut HashMap) {} } -/// Blanket implementation of [`ProviderPlugin`] for [`ProviderDiscoverySpec`]. -/// -/// Providers that only need standard env-var discovery can register their -/// `SPEC` constant directly, instead of defining a dedicated struct and -/// repeating the same three-method delegation. -impl ProviderPlugin for ProviderDiscoverySpec { - fn id(&self) -> &'static str { - self.id - } - - fn discover_existing(&self) -> Result, ProviderError> { - discover_with_spec(self, &RealDiscoveryContext) - } - - fn credential_env_vars(&self) -> &'static [&'static str] { - self.credential_env_vars - } -} - #[derive(Default)] pub struct ProviderRegistry { plugins: HashMap<&'static str, Box>, @@ -107,24 +77,15 @@ impl ProviderRegistry { #[must_use] pub fn new() -> Self { let mut registry = Self::default(); - registry.register(providers::claude::SPEC); - registry.register(providers::codex::SPEC); - registry.register(providers::copilot::SPEC); - registry.register(providers::opencode::OpencodeProvider); - registry.register(providers::generic::GenericProvider); - registry.register(providers::openai::SPEC); - registry.register(providers::anthropic::SPEC); - registry.register(providers::nvidia::SPEC); - registry.register(providers::deepinfra::SPEC); - registry.register(providers::github::SPEC); - registry.register(providers::gitlab::SPEC); + // Keep only the legacy config projectors required to run existing + // Google Cloud and Vertex records. Public provider discovery is + // profile-driven; this registry is an internal compatibility adapter. registry.register(providers::google_cloud::GoogleCloudProvider); - registry.register(providers::outlook::OutlookProvider); registry.register(providers::vertex::VertexProvider); registry } - pub fn register

(&mut self, plugin: P) + fn register

(&mut self, plugin: P) where P: ProviderPlugin + 'static, { @@ -132,36 +93,10 @@ impl ProviderRegistry { } #[must_use] - pub fn get(&self, id: &str) -> Option<&dyn ProviderPlugin> { + fn get(&self, id: &str) -> Option<&dyn ProviderPlugin> { self.plugins.get(id).map(Box::as_ref) } - pub fn discover_existing(&self, id: &str) -> Result, ProviderError> { - let Some(plugin) = self.get(id) else { - return Err(ProviderError::UnsupportedProvider(id.to_string())); - }; - plugin.discover_existing() - } - - /// Return the known credential env var names for a provider type. - #[must_use] - pub fn credential_env_vars(&self, id: &str) -> &'static [&'static str] { - self.get(id) - .map_or(&[], ProviderPlugin::credential_env_vars) - } - - #[must_use] - pub fn profile(&self, id: &str) -> Option<&'static ProviderTypeProfile> { - builtin_profiles() - .iter() - .find(|profile| profile.id.eq_ignore_ascii_case(id)) - } - - #[must_use] - pub fn profiles(&self) -> Vec<&'static ProviderTypeProfile> { - builtin_profiles().iter().collect() - } - /// Inject provider-specific env vars via the registered plugin. /// /// Normalizes the provider type and delegates to the plugin's `inject_env`. @@ -175,13 +110,6 @@ impl ProviderRegistry { plugin.inject_env(provider, env); } } - - #[must_use] - pub fn known_types(&self) -> Vec<&'static str> { - let mut types = self.plugins.keys().copied().collect::>(); - types.sort_unstable(); - types - } } #[must_use] @@ -196,12 +124,8 @@ pub fn normalize_provider_type(input: &str) -> Option<&'static str> { "claude" | "claude-code" | "claude_code" => Some("claude-code"), "codex" => Some("codex"), "copilot" => Some("copilot"), - "opencode" => Some("opencode"), "gcp" | "google-cloud" => Some("google-cloud"), - "generic" => Some("generic"), - "gitlab" | "glab" => Some("gitlab"), "github" | "gh" => Some("github"), - "outlook" => Some("outlook"), _ => None, } } @@ -222,12 +146,12 @@ mod tests { #[test] fn normalizes_known_provider_aliases() { - assert_eq!(normalize_provider_type("gitlab"), Some("gitlab")); - assert_eq!(normalize_provider_type("glab"), Some("gitlab")); assert_eq!(normalize_provider_type("gh"), Some("github")); assert_eq!(normalize_provider_type("CLAUDE"), Some("claude-code")); assert_eq!(normalize_provider_type("claude-code"), Some("claude-code")); - assert_eq!(normalize_provider_type("generic"), Some("generic")); + for retired in ["generic", "gitlab", "glab", "opencode", "outlook"] { + assert_eq!(normalize_provider_type(retired), None); + } assert_eq!(normalize_provider_type("openai"), Some("openai")); assert_eq!(normalize_provider_type("anthropic"), Some("anthropic")); assert_eq!(normalize_provider_type("nvidia"), Some("nvidia")); @@ -252,7 +176,7 @@ mod tests { ); assert_eq!( detect_provider_from_command(&["/usr/bin/glab".to_string()]), - Some("gitlab") + None ); assert_eq!( detect_provider_from_command(&["/usr/bin/bash".to_string()]), diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 6e87826c4d..6ee7763799 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -29,6 +29,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/aws.yaml"), include_str!("../../../providers/aws-bedrock.yaml"), include_str!("../../../providers/aws-s3.yaml"), + include_str!("../../../providers/anthropic.yaml"), include_str!("../../../providers/claude-code.yaml"), include_str!("../../../providers/codex.yaml"), include_str!("../../../providers/copilot.yaml"), @@ -38,6 +39,7 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/google-cloud.yaml"), include_str!("../../../providers/google-vertex-ai.yaml"), include_str!("../../../providers/nvidia.yaml"), + include_str!("../../../providers/openai.yaml"), include_str!("../../../providers/pypi.yaml"), ]; @@ -492,13 +494,25 @@ impl ProviderTypeProfile { /// Whether this profile can be created without initial static credentials. /// - /// Empty provider creation is allowed when at least one credential can be - /// resolved at runtime, and every required credential can be resolved at - /// runtime. Runtime-resolvable credentials are either gateway-mintable - /// refresh credentials, sandbox-side dynamic token grants, or additional - /// outputs co-minted by another credential's gateway-mintable refresh. + /// Empty provider creation is allowed when every required credential can + /// be resolved at runtime. This includes profiles with no credentials and + /// profiles whose credentials are all optional. #[must_use] pub fn allows_empty_provider_credentials(&self) -> bool { + let co_minted = self.co_minted_credential_names(); + self.credentials.iter().all(|credential| { + let is_runtime_resolvable = + credential.is_runtime_resolvable() || co_minted.contains(credential.name.as_str()); + !credential.required || is_runtime_resolvable + }) + } + + /// Whether `--runtime-credentials` is meaningful for this profile. + /// + /// At least one credential must be runtime-resolvable and every required + /// credential must be resolvable without an initial static value. + #[must_use] + pub fn allows_runtime_provider_credentials(&self) -> bool { let co_minted = self.co_minted_credential_names(); let mut has_runtime_resolvable_credential = false; for credential in &self.credentials { @@ -512,6 +526,20 @@ impl ProviderTypeProfile { has_runtime_resolvable_credential } + /// Required credentials that must have an initial static value. + #[must_use] + pub fn required_static_credentials(&self) -> Vec<&CredentialProfile> { + let co_minted = self.co_minted_credential_names(); + self.credentials + .iter() + .filter(|credential| { + credential.required + && !credential.is_runtime_resolvable() + && !co_minted.contains(credential.name.as_str()) + }) + .collect() + } + /// Names of credentials produced as `additional_outputs` of a /// gateway-mintable refresh on some other credential. fn co_minted_credential_names(&self) -> HashSet<&str> { @@ -3081,8 +3109,7 @@ mod tests { } #[test] - fn empty_provider_credentials_require_a_runtime_resolvable_path_and_no_required_static_credentials() - { + fn empty_provider_credentials_require_no_required_static_credentials() { let optional_refresh_profile = parse_profile_yaml( r" id: optional-refresh @@ -3096,6 +3123,7 @@ credentials: ) .expect("profile"); assert!(optional_refresh_profile.allows_empty_provider_credentials()); + assert!(optional_refresh_profile.allows_runtime_provider_credentials()); let token_grant_profile = parse_profile_yaml( r" @@ -3110,6 +3138,7 @@ credentials: ) .expect("profile"); assert!(token_grant_profile.allows_empty_provider_credentials()); + assert!(token_grant_profile.allows_runtime_provider_credentials()); let mixed_required_profile = parse_profile_yaml( r" @@ -3126,6 +3155,7 @@ credentials: ) .expect("profile"); assert!(!mixed_required_profile.allows_empty_provider_credentials()); + assert!(!mixed_required_profile.allows_runtime_provider_credentials()); let static_only_profile = parse_profile_yaml( r" @@ -3137,7 +3167,21 @@ credentials: ", ) .expect("profile"); - assert!(!static_only_profile.allows_empty_provider_credentials()); + assert!(static_only_profile.allows_empty_provider_credentials()); + assert!(!static_only_profile.allows_runtime_provider_credentials()); + + let policy_only_profile = parse_profile_yaml( + r" +id: policy-only +display_name: Policy Only +endpoints: + - host: example.com + port: 443 +", + ) + .expect("profile"); + assert!(policy_only_profile.allows_empty_provider_credentials()); + assert!(!policy_only_profile.allows_runtime_provider_credentials()); } #[test] diff --git a/crates/openshell-providers/src/providers/google_cloud.rs b/crates/openshell-providers/src/providers/google_cloud.rs index 470d1d206f..3bea53d825 100644 --- a/crates/openshell-providers/src/providers/google_cloud.rs +++ b/crates/openshell-providers/src/providers/google_cloud.rs @@ -5,29 +5,13 @@ use std::collections::HashMap; use openshell_core::google_cloud; -use crate::{ - DiscoveredProvider, Provider, ProviderDiscoverySpec, ProviderError, ProviderPlugin, - RealDiscoveryContext, discover_with_spec, -}; +use crate::{Provider, ProviderPlugin}; pub struct GoogleCloudProvider; -const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { - id: "google-cloud", - credential_env_vars: google_cloud::TOKEN_ENV_KEYS, -}; - impl ProviderPlugin for GoogleCloudProvider { fn id(&self) -> &'static str { - SPEC.id - } - - fn discover_existing(&self) -> Result, ProviderError> { - discover_with_spec(&SPEC, &RealDiscoveryContext) - } - - fn credential_env_vars(&self) -> &'static [&'static str] { - SPEC.credential_env_vars + "google-cloud" } fn inject_env(&self, provider: &Provider, env: &mut HashMap) { diff --git a/crates/openshell-providers/src/providers/mod.rs b/crates/openshell-providers/src/providers/mod.rs index 2770210318..b6450e170e 100644 --- a/crates/openshell-providers/src/providers/mod.rs +++ b/crates/openshell-providers/src/providers/mod.rs @@ -1,46 +1,5 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -/// Generate a standard discovery smoke-test for a provider whose only test is -/// checking that an env-var credential is picked up by `discover_with_spec`. -/// -/// # Usage -/// ```ignore -/// test_discovers_env_credential!(discovers_openai_env_credentials, "OPENAI_API_KEY", "sk-test"); -/// ``` -macro_rules! test_discovers_env_credential { - ($test_name:ident, $env_var:expr, $env_value:expr) => { - #[cfg(test)] - mod tests { - use super::SPEC; - use crate::discover_with_spec; - use crate::test_helpers::MockDiscoveryContext; - - #[test] - fn $test_name() { - let ctx = MockDiscoveryContext::new().with_env($env_var, $env_value); - let discovered = discover_with_spec(&SPEC, &ctx) - .expect("discovery") - .expect("provider"); - assert_eq!( - discovered.credentials.get($env_var), - Some(&$env_value.to_string()) - ); - } - } - }; -} -pub mod anthropic; -pub mod claude; -pub mod codex; -pub mod copilot; -pub mod deepinfra; -pub mod generic; -pub mod github; -pub mod gitlab; pub mod google_cloud; -pub mod nvidia; -pub mod openai; -pub mod opencode; -pub mod outlook; pub mod vertex; diff --git a/crates/openshell-providers/src/providers/vertex.rs b/crates/openshell-providers/src/providers/vertex.rs index ad52bc51c0..ba3aacd007 100644 --- a/crates/openshell-providers/src/providers/vertex.rs +++ b/crates/openshell-providers/src/providers/vertex.rs @@ -6,43 +6,13 @@ use std::collections::HashMap; use openshell_core::google_cloud; use openshell_core::inference; -use crate::{ - DiscoveredProvider, Provider, ProviderDiscoverySpec, ProviderError, ProviderPlugin, - RealDiscoveryContext, discover_with_spec, -}; +use crate::{Provider, ProviderPlugin}; pub struct VertexProvider; -const SPEC: ProviderDiscoverySpec = ProviderDiscoverySpec { - id: "google-vertex-ai", - credential_env_vars: inference::VERTEX_AI_CREDENTIAL_KEY_NAMES, -}; - impl ProviderPlugin for VertexProvider { fn id(&self) -> &'static str { - SPEC.id - } - - fn discover_existing(&self) -> Result, ProviderError> { - let mut discovered = discover_with_spec(&SPEC, &RealDiscoveryContext)?.unwrap_or_default(); - - for key in inference::VERTEX_AI_CONFIG_KEY_NAMES { - if let Ok(val) = std::env::var(key) - && !val.trim().is_empty() - { - discovered.config.entry(key.to_string()).or_insert(val); - } - } - - if discovered.is_empty() { - Ok(None) - } else { - Ok(Some(discovered)) - } - } - - fn credential_env_vars(&self) -> &'static [&'static str] { - SPEC.credential_env_vars + "google-vertex-ai" } fn inject_env(&self, provider: &Provider, env: &mut HashMap) { diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 114b43aba0..acefd9d9f4 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1582,8 +1582,8 @@ async fn auto_approve_chunk( } // TODO: share effective-policy lookup with `load_sandbox_policy` / -// `GetSandboxConfig`. They re-implement very similar global-settings + -// providers_v2 + compose logic; consolidating them is out of scope for the +// `GetSandboxConfig`. They re-implement very similar global-settings and +// profile-composition logic; consolidating them is out of scope for the // agent-authored proposal validation slice. async fn current_effective_policy_for_sandbox( state: &ServerState, @@ -1632,8 +1632,6 @@ async fn effective_policy_for_source( }, ); - let providers_v2_enabled = - bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)?; clear_provider_credentialed_markers(&mut policy); let mut provider_context = provider_policy_context_with_catalog( state.store.as_ref(), @@ -1642,10 +1640,7 @@ async fn effective_policy_for_source( provider_names, ) .await?; - if providers_v2_enabled - && !matches!(policy_source, PolicySource::Global) - && !provider_context.layers.is_empty() - { + if !matches!(policy_source, PolicySource::Global) && !provider_context.layers.is_empty() { policy = compose_effective_policy(&policy, &provider_context.layers); } let policy_credential_bindings = policy_static_credential_endpoint_bindings(Some(&policy))?; @@ -1966,9 +1961,7 @@ async fn provider_policy_layers_for_sandbox( provider_names: &[String], ) -> Result, Status> { let global_settings = load_global_settings(state.store.as_ref()).await?; - if decode_policy_from_global_settings(&global_settings)?.is_some() - || !bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)? - { + if decode_policy_from_global_settings(&global_settings)?.is_some() { return Ok(Vec::new()); } let catalog = state @@ -2046,8 +2039,7 @@ pub(super) async fn provider_policy_composition_enabled(store: &Store) -> Result } fn provider_policy_composition_enabled_in(settings: &StoredSettings) -> Result { - Ok(decode_policy_from_global_settings(settings)?.is_none() - && bool_setting_enabled(settings, settings::PROVIDERS_V2_ENABLED_KEY)?) + Ok(decode_policy_from_global_settings(settings)?.is_none()) } async fn validate_provider_composition_for_existing_sandboxes( @@ -2408,8 +2400,6 @@ pub(super) async fn handle_get_sandbox_config( let global_settings = load_global_settings(state.store.as_ref()).await?; let sandbox_settings = load_sandbox_settings(state.store.as_ref(), &workspace, sandbox.object_name()).await?; - let providers_v2_enabled = - bool_setting_enabled(&global_settings, settings::PROVIDERS_V2_ENABLED_KEY)?; let mut provider_policy_context = provider_policy_context_with_catalog( state.store.as_ref(), &provider_profile_catalog, @@ -2442,8 +2432,7 @@ pub(super) async fn handle_get_sandbox_config( clear_provider_credentialed_markers(source_policy); } - if providers_v2_enabled - && !matches!(policy_source, PolicySource::Global) + if !matches!(policy_source, PolicySource::Global) && let Some(source_policy) = policy.as_ref() && !provider_policy_context.layers.is_empty() { @@ -3005,16 +2994,6 @@ fn report_uninspected_credentialed_endpoints(policy: &ProtoSandboxPolicy, sandbo } } -pub(super) fn bool_setting_enabled(settings: &StoredSettings, key: &str) -> Result { - match settings.settings.get(key) { - None => Ok(false), - Some(StoredSettingValue::Bool(value)) => Ok(*value), - Some(_) => Err(Status::internal(format!( - "setting '{key}' has invalid value type; expected bool" - ))), - } -} - pub(super) async fn handle_get_gateway_config( state: &Arc, _request: Request, @@ -6706,30 +6685,6 @@ pub(super) async fn load_global_settings(store: &Store) -> Result Result { - let global_settings = load_global_settings(store).await?; - bool_setting_enabled(&global_settings, key) -} - -/// Test helper: set a boolean global setting, loading current settings first so -/// the CAS write succeeds whether the record already exists or not. Available to -/// sibling test modules without exposing the private `StoredSettings` type. -#[cfg(test)] -pub async fn set_global_bool_setting_for_test( - store: &Store, - key: &str, - value: bool, -) -> Result<(), Status> { - let mut settings = load_global_settings(store).await?; - settings - .settings - .insert(key.to_string(), StoredSettingValue::Bool(value)); - save_global_settings(store, &settings).await -} - pub(super) async fn save_global_settings( store: &Store, settings: &StoredSettings, @@ -8051,21 +8006,6 @@ mod tests { sandbox } - async fn enable_providers_v2(state: &Arc) { - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - } - async fn get_sandbox_policy(state: &Arc, sandbox_id: &str) -> ProtoSandboxPolicy { handle_get_sandbox_config( state, @@ -8163,7 +8103,6 @@ mod tests { Arc::clone(&fetch_count), ); let state = Arc::new(state); - enable_providers_v2(&state).await; let mut provider_a = test_provider("provider-a", "moving-a"); provider_a.credentials = HashMap::from([("TOKEN_A".to_string(), "a".to_string())]); @@ -8635,65 +8574,9 @@ mod tests { ); } - #[test] - fn providers_v2_enabled_defaults_false_when_unset() { - assert!( - !bool_setting_enabled( - &StoredSettings::default(), - settings::PROVIDERS_V2_ENABLED_KEY - ) - .unwrap() - ); - } - - #[test] - fn providers_v2_enabled_reads_global_bool_setting() { - let mut settings = StoredSettings::default(); - settings.settings.insert( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - ); - - assert!(bool_setting_enabled(&settings, settings::PROVIDERS_V2_ENABLED_KEY).unwrap()); - } - #[tokio::test] - async fn sandbox_config_omits_provider_layers_when_v2_disabled() { + async fn sandbox_config_always_composes_provider_layers() { let state = test_server_state().await; - state - .store - .put_message(&test_provider("work-github", "github")) - .await - .unwrap(); - state - .store - .put_message(&test_sandbox( - "sb-v2-disabled", - "v2-disabled", - test_policy_with_rule("sandbox_only", "sandbox.example.com"), - vec!["work-github".to_string()], - )) - .await - .unwrap(); - - let effective_policy = get_sandbox_policy(&state, "sb-v2-disabled").await; - - assert!( - effective_policy - .network_policies - .contains_key("sandbox_only") - ); - assert!( - !effective_policy - .network_policies - .contains_key("_provider_work_github") - ); - } - - #[tokio::test] - async fn sandbox_config_composes_provider_layers_when_v2_enabled() { - let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-github", "github")) @@ -9154,12 +9037,17 @@ mod tests { sandbox.spec.as_mut().unwrap().policy = None; state.store.put_message(&sandbox).await.unwrap(); + let mut policy = test_sigv4_policy("bucket.s3.amazonaws.com", None); + let endpoint = &mut policy.network_policies.get_mut("aws").unwrap().endpoints[0]; + endpoint.access = "read-write".to_string(); + endpoint.enforcement = "enforce".to_string(); + handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { name: "signing-profile-endpoint".to_string(), workspace: "default".to_string(), - policy: Some(test_sigv4_policy("bucket.s3.amazonaws.com", None)), + policy: Some(policy), ..Default::default() })), ) @@ -9251,7 +9139,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&StoredProviderProfile { @@ -9324,7 +9211,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; let profile = StoredProviderProfile { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { @@ -9398,9 +9284,8 @@ mod tests { } #[tokio::test] - async fn sandbox_config_skips_profileless_provider_types_when_v2_enabled() { + async fn sandbox_config_skips_profileless_provider_types() { let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("legacy-generic", "generic")) @@ -9435,7 +9320,6 @@ mod tests { #[tokio::test] async fn sandbox_config_composition_is_jit_and_does_not_persist_provider_layers() { let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-github", "github")) @@ -9522,7 +9406,6 @@ mod tests { } let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&stored_profile("api.before.example")) @@ -9625,7 +9508,6 @@ mod tests { #[tokio::test] async fn sandbox_config_composes_user_and_provider_rules() { let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-github", "github")) @@ -9674,7 +9556,7 @@ mod tests { } #[tokio::test] - async fn provider_environment_resolution_is_unchanged_by_providers_v2_setting() { + async fn provider_environment_resolution_is_stable_across_policy_composition() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; let state = test_server_state().await; @@ -9706,7 +9588,6 @@ mod tests { .into_inner() .environment; - enable_providers_v2(&state).await; let v2_env = handle_get_sandbox_provider_environment( &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { @@ -9769,7 +9650,7 @@ mod tests { .put_message(&test_provider("work-github", "github")) .await .unwrap(); - let mut profileless_openai = test_provider("gateway-openai", "openai"); + let mut profileless_openai = test_provider("gateway-openai", "legacy-openai"); profileless_openai.credentials = HashMap::from([("OPENAI_API_KEY".to_string(), "openai-secret".to_string())]); state.store.put_message(&profileless_openai).await.unwrap(); @@ -10733,7 +10614,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-github", "github")) @@ -10856,7 +10736,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; handle_import_provider_profiles( &state, authed_request(ImportProviderProfilesRequest { @@ -11015,7 +10894,7 @@ mod tests { } #[tokio::test] - async fn global_policy_suppresses_provider_profile_layers_when_v2_enabled() { + async fn global_policy_suppresses_provider_profile_layers() { use openshell_core::proto::{ GetSandboxConfigRequest, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -11083,17 +10962,10 @@ mod tests { }; let global_settings = StoredSettings { revision: 1, - settings: [ - ( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - ), - ( - POLICY_SETTING_KEY.to_string(), - StoredSettingValue::Bytes(hex::encode(global_policy.encode_to_vec())), - ), - ] - .into_iter() + settings: std::iter::once(( + POLICY_SETTING_KEY.to_string(), + StoredSettingValue::Bytes(hex::encode(global_policy.encode_to_vec())), + )) .collect(), ..Default::default() }; @@ -12618,13 +12490,13 @@ mod tests { .find(|c| c.id == mechanistic_chunk_id) .expect("mechanistic chunk present"); assert_eq!(mech.status, "pending"); - // Mechanistic L4 with credential in scope flags as new credentialed - // reach for the binary on the host. + // The attached GitHub profile already grants credentialed reach for + // this host, so the mechanistic proposal does not expand reach. assert!( - mech.validation_result + !mech + .validation_result .contains("credential_reach_expansion"), - "mechanistic L4 with credential in scope should emit \ - credential_reach_expansion; got: {}", + "profile-composed reach should prevent a duplicate expansion finding; got: {}", mech.validation_result ); @@ -14304,7 +14176,7 @@ mod tests { } #[tokio::test] - async fn agent_authored_validation_uses_providers_v2_effective_policy() { + async fn agent_authored_validation_uses_profile_composed_effective_policy() { use openshell_core::proto::{ FilesystemPolicy, L7Allow, L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, ProviderProfile, ProviderProfileCategory, SandboxPhase, SandboxPolicy, SandboxSpec, @@ -14312,7 +14184,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; state .store .put_message(&test_provider("work-custom", "custom-api")) @@ -14481,7 +14352,6 @@ mod tests { }; let state = test_server_state().await; - enable_providers_v2(&state).await; // Github provider attached: a credential ends up in scope for // api.github.com (PUT proposal flags MEDIUM). raw.githubusercontent.com @@ -16455,32 +16325,6 @@ mod tests { .unwrap(); } - #[tokio::test] - async fn enabling_provider_composition_rejects_existing_ambiguous_binding() { - let state = test_server_state().await; - install_ambiguous_provider_binding(&state, "enable").await; - - let error = handle_update_config( - &state, - with_user(Request::new(UpdateConfigRequest { - global: true, - setting_key: settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - setting_value: Some(SettingValue { - value: Some(setting_value::Value::BoolValue(true)), - }), - ..Default::default() - })), - ) - .await - .expect_err("provider composition must be validated before activation"); - - assert_eq!(error.code(), Code::FailedPrecondition); - assert!(error.message().contains("sandbox-enable")); - assert!(error.message().contains("tls")); - let settings = load_global_settings(state.store.as_ref()).await.unwrap(); - assert!(!bool_setting_enabled(&settings, settings::PROVIDERS_V2_ENABLED_KEY).unwrap()); - } - #[tokio::test] async fn deleting_global_policy_rejects_reactivated_ambiguous_provider_binding() { let state = test_server_state().await; @@ -16496,20 +16340,6 @@ mod tests { ) .await .expect("global policy should suppress provider composition"); - handle_update_config( - &state, - with_user(Request::new(UpdateConfigRequest { - global: true, - setting_key: settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - setting_value: Some(SettingValue { - value: Some(setting_value::Value::BoolValue(true)), - }), - ..Default::default() - })), - ) - .await - .expect("providers may be enabled while a global policy is active"); - let error = handle_update_config( &state, with_user(Request::new(UpdateConfigRequest { @@ -16532,17 +16362,10 @@ mod tests { fn merge_effective_settings_global_overrides_sandbox_key() { let global = StoredSettings { revision: 2, - settings: [ - ( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(false), - ), - ( - settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(false), - ), - ] - .into_iter() + settings: std::iter::once(( + settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), + StoredSettingValue::Bool(false), + )) .collect(), ..Default::default() }; @@ -16550,7 +16373,7 @@ mod tests { revision: 1, settings: [ ( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), + settings::AGENT_POLICY_PROPOSALS_ENABLED_KEY.to_string(), StoredSettingValue::Bool(true), ), ( @@ -16564,15 +16387,6 @@ mod tests { }; let merged = merge_effective_settings(&global, &sandbox).unwrap(); - let providers_v2 = merged - .get(settings::PROVIDERS_V2_ENABLED_KEY) - .expect("providers_v2_enabled present"); - assert_eq!(providers_v2.scope, SettingScope::Global as i32); - assert_eq!( - providers_v2.value.as_ref().and_then(|v| v.value.as_ref()), - Some(&setting_value::Value::BoolValue(false)) - ); - let ocsf_json = merged .get("ocsf_json_enabled") .expect("ocsf_json_enabled present"); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index c8c8149a82..6b16d28416 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -167,13 +167,18 @@ async fn create_provider_record_validating( "provider.credential_handles is internal gateway state and cannot be supplied", )); } + if !provider.profile_workspace.is_empty() && provider.profile_workspace != workspace { return Err(Status::invalid_argument( "profile_workspace must be empty (global) or match the provider workspace", )); } if provider.credentials.is_empty() - && !provider_type_allows_empty_credentials(catalog, &provider.r#type) + && !provider_type_allows_empty_credentials( + catalog, + &provider.r#type, + &provider.profile_workspace, + ) { return Err(Status::invalid_argument( "provider.credentials must not be empty", @@ -2373,14 +2378,21 @@ pub(super) async fn handle_create_provider( if let Some(metadata) = provider.metadata.as_mut() { metadata.workspace.clone_from(&workspace); } - let provider_type = provider.r#type.clone(); - if state.credentials.stores_provider_credentials() && !provider.credentials.is_empty() { - state.compute.ensure_workspace(&workspace).await?; + if !provider.credential_handles.is_empty() { + return Err(Status::invalid_argument( + "provider.credential_handles is internal gateway state and cannot be supplied", + )); } + let provider_type = provider.r#type.clone(); let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) .await?; + let profile = resolve_provider_create_profile(&catalog, &mut provider)?; + validate_provider_create_credentials(&profile, &provider)?; + if state.credentials.stores_provider_credentials() && !provider.credentials.is_empty() { + state.compute.ensure_workspace(&workspace).await?; + } let result = create_provider_record_validating( state.store.as_ref(), &workspace, @@ -2841,13 +2853,6 @@ pub(super) async fn handle_delete_provider_profile( Ok(Response::new(DeleteProviderProfileResponse { deleted })) } -pub(super) fn get_provider_type_profile_with_catalog( - catalog: &EffectiveProviderProfileCatalog, - id: &str, -) -> Option { - catalog.get_type_profile(id) -} - pub(super) fn get_provider_type_profile_for_scope( catalog: &EffectiveProviderProfileCatalog, id: &str, @@ -3004,13 +3009,110 @@ fn validate_refresh_material( fn provider_type_allows_empty_credentials( catalog: &EffectiveProviderProfileCatalog, provider_type: &str, + profile_workspace: &str, ) -> bool { - let Some(profile) = get_provider_type_profile_with_catalog(catalog, provider_type) else { + let Some(profile) = + get_provider_type_profile_for_scope(catalog, provider_type, profile_workspace) + else { return false; }; profile.allows_empty_provider_credentials() } +fn resolve_provider_create_profile( + catalog: &EffectiveProviderProfileCatalog, + provider: &mut Provider, +) -> Result { + let requested_type = provider.r#type.trim(); + if requested_type.is_empty() { + return Err(Status::invalid_argument("provider.type is required")); + } + let profile_id = normalize_provider_type(requested_type).unwrap_or(requested_type); + let profile = get_provider_type_profile_for_scope( + catalog, + profile_id, + &provider.profile_workspace, + ) + .ok_or_else(|| { + Status::invalid_argument(format!( + "provider profile '{profile_id}' was not found in the requested scope; import a matching profile before creating this provider" + )) + })?; + provider.r#type.clone_from(&profile.id); + Ok(profile) +} + +fn validate_provider_create_credentials( + profile: &ProviderTypeProfile, + provider: &Provider, +) -> Result<(), Status> { + validate_provider_credential_keys(profile, provider)?; + validate_required_static_credentials(profile, provider, &HashMap::new()) +} + +fn validate_provider_credential_keys( + profile: &ProviderTypeProfile, + provider: &Provider, +) -> Result<(), Status> { + let declared_keys = profile + .credentials + .iter() + .flat_map(|credential| credential.env_vars.iter()) + .collect::>(); + let mut unknown_keys = provider + .credentials + .keys() + .filter(|key| !declared_keys.contains(key)) + .cloned() + .collect::>(); + unknown_keys.sort(); + if !unknown_keys.is_empty() { + return Err(Status::invalid_argument(format!( + "provider credentials are not declared by profile '{}': {}", + profile.id, + unknown_keys.join(", ") + ))); + } + + Ok(()) +} + +fn validate_required_static_credentials( + profile: &ProviderTypeProfile, + provider: &Provider, + pending_credentials: &HashMap, +) -> Result<(), Status> { + let mut missing = Vec::new(); + for credential in profile.required_static_credentials() { + let supplied = credential.env_vars.iter().any(|key| { + provider + .credentials + .get(key) + .is_some_and(|value| !value.trim().is_empty()) + || provider.credential_handles.contains_key(key) + || pending_credentials + .get(key) + .is_some_and(|value| !value.trim().is_empty()) + }); + if !supplied { + missing.push( + credential + .env_vars + .first() + .map_or_else(|| credential.name.clone(), Clone::clone), + ); + } + } + if !missing.is_empty() { + return Err(Status::invalid_argument(format!( + "provider profile '{}' requires static credentials: {}", + profile.id, + missing.join(", ") + ))); + } + Ok(()) +} + fn normalize_profile_id_request(id: &str) -> Result { if id.trim().is_empty() { return Err(Status::invalid_argument("id is required")); @@ -4126,18 +4228,6 @@ pub(super) async fn handle_configure_provider_refresh( crate::provider_refresh::refresh_strategy_name(strategy as i32) ))); } - if strategy == ProviderCredentialRefreshStrategy::AwsStsAssumeRole { - let global_settings = - crate::grpc::policy::load_global_settings(state.store.as_ref()).await?; - if !crate::grpc::policy::bool_setting_enabled( - &global_settings, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - )? { - return Err(Status::failed_precondition( - "aws_sts_assume_role requires providers_v2_enabled=true", - )); - } - } if request.material.len() > MAX_PROVIDER_CONFIG_ENTRIES { return Err(Status::invalid_argument(format!( "material exceeds maximum entries ({} > {MAX_PROVIDER_CONFIG_ENTRIES})", @@ -4755,11 +4845,10 @@ mod tests { ImportProviderProfilesRequest, L7Allow, L7Rule, LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, - ProviderCredentialRefreshRecoveryAction, ProviderCredentialTokenGrant, - ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, ProviderProfileCategory, - ProviderProfileCredential, ProviderProfileImportItem, RotateProviderCredentialRequest, - Sandbox, SandboxPolicy, SandboxSpec, StoredProviderProfile, UpdateProviderProfilesRequest, - UpdateProviderRequest, + ProviderCredentialTokenGrant, ProviderCredentialTokenGrantAudienceOverride, + ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, + ProviderProfileImportItem, RotateProviderCredentialRequest, Sandbox, SandboxPolicy, + SandboxSpec, StoredProviderProfile, UpdateProviderProfilesRequest, UpdateProviderRequest, }; use openshell_core::{ObjectId, ObjectName}; use tonic::{Code, Request}; @@ -4797,11 +4886,11 @@ mod tests { ); assert_eq!( telemetry_provider_profile("glab"), - TelemetryProviderProfile::Gitlab + TelemetryProviderProfile::Custom ); assert_eq!( telemetry_provider_profile("outlook"), - TelemetryProviderProfile::Outlook + TelemetryProviderProfile::Custom ); assert_eq!( telemetry_provider_profile("generic"), @@ -5657,6 +5746,7 @@ mod tests { assert_eq!( ids, vec![ + "anthropic", "aws", "aws-bedrock", "aws-s3", @@ -5669,6 +5759,7 @@ mod tests { "google-cloud", "google-vertex-ai", "nvidia", + "openai", "pypi" ] ); @@ -5773,20 +5864,6 @@ mod tests { #[tokio::test] async fn profile_update_rejects_fanout_endpoint_ambiguity_without_persisting() { let state = test_server_state().await; - crate::grpc::policy::save_global_settings( - state.store.as_ref(), - &crate::grpc::StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - crate::grpc::StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }, - ) - .await - .unwrap(); let mut initial_profile = custom_profile("fanout-ambiguity"); initial_profile.endpoints.push(NetworkEndpoint { @@ -7105,17 +7182,7 @@ mod tests { #[tokio::test] async fn delete_aws_sts_refresh_clears_all_pinned_output_expiries() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -8124,6 +8191,96 @@ mod tests { assert!(err.message().contains("internal gateway state")); } + #[tokio::test] + async fn handle_create_provider_rejects_profileless_type() { + let state = test_server_state().await; + let err = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_values("legacy-gitlab", "gitlab")), + workspace: "default".to_string(), + }), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::InvalidArgument); + assert!( + err.message() + .contains("provider profile 'gitlab' was not found") + ); + } + + #[tokio::test] + async fn handle_create_provider_allows_credentialless_policy_profile() { + let state = test_server_state().await; + let response = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "pypi".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: "pypi".to_string(), + profile_workspace: "default".to_string(), + ..Default::default() + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .provider + .expect("provider"); + + assert_eq!(response.r#type, "pypi"); + assert!(response.credentials.is_empty()); + } + + #[tokio::test] + async fn handle_create_provider_allows_retired_type_with_imported_profile() { + let state = test_server_state().await; + handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(custom_profile("gitlab")), + source: "custom-gitlab.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let response = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + name: "private-gitlab".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + r#type: "gitlab".to_string(), + profile_workspace: "default".to_string(), + ..Default::default() + }), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .provider + .expect("provider"); + + assert_eq!(response.r#type, "gitlab"); + } + #[tokio::test] async fn handle_create_provider_stores_inline_credentials_with_enabled_driver() { let mut state = test_server_state().await; @@ -10894,7 +11051,8 @@ mod tests { let state = test_server_state().await; // Create a provider - let mut provider = provider_with_values("test-provider", "generic"); + let mut provider = + provider_with_credential_value("test-provider", "openai", "OPENAI_API_KEY", "sk-test"); provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, @@ -10965,7 +11123,8 @@ mod tests { let state = test_server_state().await; // Create a provider - let mut provider = provider_with_values("test-provider", "generic"); + let mut provider = + provider_with_credential_value("test-provider", "openai", "OPENAI_API_KEY", "sk-test"); provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, @@ -11107,7 +11266,8 @@ mod tests { let state = Arc::new(test_server_state().await); // Create a provider - let mut provider = provider_with_values("test-provider", "generic"); + let mut provider = + provider_with_credential_value("test-provider", "openai", "OPENAI_API_KEY", "sk-test"); provider.metadata.as_mut().unwrap().id = String::new(); handle_create_provider( &state, @@ -11199,79 +11359,8 @@ mod tests { } #[tokio::test] - async fn configure_aws_sts_requires_v2_enabled() { + async fn configure_aws_sts_succeeds_without_feature_gate() { let state = test_server_state().await; - create_provider_record( - state.store.as_ref(), - "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "my-aws".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: "aws".to_string(), - credentials: std::iter::once(( - "AWS_ACCESS_KEY_ID".to_string(), - "placeholder".to_string(), - )) - .collect(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - credential_handles: HashMap::new(), - }, - ) - .await - .unwrap(); - - let err = handle_configure_provider_refresh( - &state, - authed_request(ConfigureProviderRefreshRequest { - provider: "my-aws".to_string(), - credential_key: "AWS_ACCESS_KEY_ID".to_string(), - strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, - material: HashMap::from([( - "role_arn".to_string(), - "arn:aws:iam::123456789012:role/Test".to_string(), - )]), - secret_material_keys: Vec::new(), - expires_at_ms: None, - workspace: "default".to_string(), - }), - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), Code::FailedPrecondition); - assert!(err.message().contains("providers_v2_enabled")); - } - - #[tokio::test] - async fn configure_aws_sts_succeeds_with_v2_enabled() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - - let state = test_server_state().await; - - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); create_provider_record( state.store.as_ref(), @@ -11332,24 +11421,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_endpoint_override_material() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11424,24 +11496,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_partial_source_credentials() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11496,17 +11551,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_session_token_without_pair() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11562,24 +11607,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_persists_resolved_additional_output_keys() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11651,17 +11679,7 @@ mod tests { #[tokio::test] async fn update_provider_rejects_gateway_refresh_primary_and_additional_output_keys() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - let original_credentials = HashMap::from([ ( "AWS_ACCESS_KEY_ID".to_string(), @@ -11751,24 +11769,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_requires_profile_declaring_the_refresh() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - // A generic provider has no profile, so it declares no STS refresh // binding. STS must not be configurable against it. create_provider_record( @@ -11824,24 +11825,7 @@ mod tests { #[tokio::test] async fn configure_aws_sts_rejects_non_canonical_credential_key() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - create_provider_record( state.store.as_ref(), "default", @@ -11891,116 +11875,6 @@ mod tests { assert!(err.message().contains("AWS_ACCESS_KEY_ID")); } - #[tokio::test] - async fn rotate_aws_sts_blocked_after_providers_v2_disabled() { - use crate::grpc::policy::set_global_bool_setting_for_test; - - let state = test_server_state().await; - - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - - create_provider_record( - state.store.as_ref(), - "default", - Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: "aws-gate".to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: "default".to_string(), - deletion_timestamp_ms: 0, - }), - r#type: "aws".to_string(), - credentials: HashMap::new(), - config: HashMap::new(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: "default".to_string(), - credential_handles: HashMap::new(), - }, - ) - .await - .unwrap(); - - handle_configure_provider_refresh( - &state, - authed_request(ConfigureProviderRefreshRequest { - provider: "aws-gate".to_string(), - credential_key: "AWS_ACCESS_KEY_ID".to_string(), - strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole as i32, - material: HashMap::from([( - "role_arn".to_string(), - "arn:aws:iam::123456789012:role/Test".to_string(), - )]), - secret_material_keys: Vec::new(), - expires_at_ms: None, - workspace: "default".to_string(), - }), - ) - .await - .unwrap(); - - // Disable the gate after the refresh is already configured. - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - false, - ) - .await - .unwrap(); - - let err = handle_rotate_provider_credential( - &state, - authed_request(RotateProviderCredentialRequest { - provider: "aws-gate".to_string(), - credential_key: "AWS_ACCESS_KEY_ID".to_string(), - workspace: "default".to_string(), - }), - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), Code::FailedPrecondition); - assert!(err.message().contains("providers_v2_enabled")); - - // The gate rejection is recorded on the refresh state, and no credential - // was minted into the provider. - let provider = state - .store - .get_message_by_name::("default", "aws-gate") - .await - .unwrap() - .unwrap(); - assert!(!provider.credentials.contains_key("AWS_ACCESS_KEY_ID")); - let refresh_state = crate::provider_refresh::get_refresh_state( - state.store.as_ref(), - "default", - provider.object_id(), - "AWS_ACCESS_KEY_ID", - ) - .await - .unwrap() - .expect("refresh state should exist"); - assert_eq!(refresh_state.status, "configuration_required"); - assert_eq!( - refresh_state.recovery_action, - ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32 - ); - assert_eq!(refresh_state.failure_code, "refresh_configuration_invalid"); - assert_eq!( - refresh_state.next_refresh_at_ms - refresh_state.last_error_at_ms, - 60 * 60 * 1000 - ); - } - #[tokio::test] async fn active_provider_environment_keys_include_additional_output_keys() { let state = test_server_state().await; @@ -12078,25 +11952,8 @@ mod tests { #[tokio::test] async fn configure_aws_sts_validates_additional_credential_key_collision() { - use crate::grpc::StoredSettingValue; - use crate::grpc::StoredSettings; - use crate::grpc::policy::save_global_settings; - let state = test_server_state().await; - let global_settings = StoredSettings { - revision: 1, - settings: std::iter::once(( - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - StoredSettingValue::Bool(true), - )) - .collect(), - ..Default::default() - }; - save_global_settings(state.store.as_ref(), &global_settings) - .await - .unwrap(); - let mut existing_provider = Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: String::new(), @@ -12198,17 +12055,7 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_configure_aws_sts_reserves_key_exactly_once() { - use crate::grpc::policy::set_global_bool_setting_for_test; - let state = test_server_state().await; - set_global_bool_setting_for_test( - state.store.as_ref(), - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); - for name in ["aws-a", "aws-b"] { create_provider_record( state.store.as_ref(), @@ -12454,8 +12301,8 @@ mod tests { // Create same-named provider in each workspace via handlers. let make_provider = || Provider { metadata: None, - r#type: "custom".to_string(), - credentials: HashMap::from([("TOKEN".to_string(), "secret".to_string())]), + r#type: "pypi".to_string(), + credentials: HashMap::new(), config: HashMap::new(), credential_expires_at_ms: HashMap::new(), profile_workspace: String::new(), diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 89f8c942ea..aa41e343e7 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -3026,6 +3026,15 @@ mod tests { #[tokio::test] async fn detach_sandbox_provider_is_idempotent_and_removes_all_matches() { let state = test_server_state().await; + state + .store + .put_message(&test_provider_with_credential_key( + "other", + "github", + "GITHUB_TOKEN", + )) + .await + .unwrap(); state .store .put_message(&test_sandbox( diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index cbe5ff892c..1871d99154 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -427,6 +427,7 @@ impl EffectiveProviderProfileCatalog { .map(|entry| entry.effective.response.clone()) } + #[cfg(test)] pub(crate) fn get_type_profile(&self, id: &str) -> Option { let id = normalize_profile_id(id)?; self.profiles diff --git a/crates/openshell-server/src/provider_refresh.rs b/crates/openshell-server/src/provider_refresh.rs index 176f86ae22..bba28c3843 100644 --- a/crates/openshell-server/src/provider_refresh.rs +++ b/crates/openshell-server/src/provider_refresh.rs @@ -835,23 +835,6 @@ pub async fn refresh_provider_credential( "provider credential refresh started" ); - // Enforce the providers_v2 gate on every mint, not just at configure time. - // Otherwise disabling providers_v2_enabled leaves already-configured refresh - // states that the worker and manual rotation keep minting from. - if let Err(err) = ensure_refresh_providers_v2_gate(store, &state).await { - let failure = RefreshFailure::from_status(&err); - persist_refresh_failure_state(store, &mut state, expected_version, &failure).await?; - warn!( - provider = %state.provider_name, - credential_key = %state.credential_key, - strategy = %refresh_strategy_name(state.strategy), - status = %state.status, - error = %err, - "provider credential refresh gate rejected" - ); - return Err(err); - } - let mint_result = match resolve_refresh_material(Some(credentials), &state).await { Ok(transient_state) => mint_credential(&transient_state).await, Err(err) => Err(err.into()), @@ -1276,31 +1259,6 @@ async fn cleanup_staged_refresh_handles( } } -/// Reject minting for strategies that require `providers_v2_enabled` when the -/// setting is off. Runs on every refresh (worker sweep and manual rotation), so -/// disabling the setting halts further mints from already-configured states. -async fn ensure_refresh_providers_v2_gate( - store: &Store, - state: &StoredProviderCredentialRefreshState, -) -> Result<(), Status> { - let strategy = ProviderCredentialRefreshStrategy::try_from(state.strategy) - .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); - if strategy != ProviderCredentialRefreshStrategy::AwsStsAssumeRole { - return Ok(()); - } - if !crate::grpc::policy::global_bool_setting_enabled( - store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - ) - .await? - { - return Err(Status::failed_precondition( - "aws_sts_assume_role requires providers_v2_enabled=true", - )); - } - Ok(()) -} - async fn mint_credential( state: &StoredProviderCredentialRefreshState, ) -> Result { @@ -2518,62 +2476,6 @@ mod tests { ); } - #[tokio::test] - async fn disabled_provider_gate_requires_configuration_and_retries_hourly() { - let store = test_store().await; - let provider = provider("disabled-provider-gate", "aws-s3"); - store.put_message(&provider).await.unwrap(); - let state = new_refresh_state( - &provider, - "default", - "AWS_ACCESS_KEY_ID", - NewRefreshStateConfig { - strategy: ProviderCredentialRefreshStrategy::AwsStsAssumeRole, - material: HashMap::from([( - "role_arn".to_string(), - "arn:aws:iam::123456789012:role/test".to_string(), - )]), - secret_material_keys: Vec::new(), - expires_at_ms: 0, - token_url: String::new(), - scopes: Vec::new(), - refresh_before_seconds: 30, - max_lifetime_seconds: 60, - additional_output_keys: HashMap::new(), - }, - ) - .unwrap(); - put_refresh_state(&store, &state).await.unwrap(); - - let err = refresh_provider_credential( - &store, - "default", - &test_credentials(), - None, - "disabled-provider-gate", - "AWS_ACCESS_KEY_ID", - ) - .await - .unwrap_err(); - - assert_eq!(err.code(), tonic::Code::FailedPrecondition); - let stored = - get_refresh_state(&store, "default", provider.object_id(), "AWS_ACCESS_KEY_ID") - .await - .unwrap() - .unwrap(); - assert_eq!(stored.status, "configuration_required"); - assert_eq!( - stored.recovery_action, - ProviderCredentialRefreshRecoveryAction::FixConfiguration as i32 - ); - assert_eq!(stored.failure_code, "refresh_configuration_invalid"); - assert_eq!( - stored.next_refresh_at_ms - stored.last_error_at_ms, - 60 * 60 * 1000 - ); - } - #[tokio::test] async fn oauth_invalid_grant_persists_terminal_reauthorization_status() { let mock_server = MockServer::start().await; @@ -3590,13 +3492,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-test", "aws"); store.put_message(&prov).await.unwrap(); @@ -3695,13 +3590,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-custom", "aws"); store.put_message(&prov).await.unwrap(); @@ -3779,13 +3667,6 @@ mod tests { #[tokio::test] async fn aws_sts_mint_rejects_partial_source_credentials() { let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-partial", "aws"); store.put_message(&prov).await.unwrap(); @@ -4110,13 +3991,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-session", "aws"); store.put_message(&prov).await.unwrap(); @@ -4194,13 +4068,6 @@ mod tests { #[tokio::test] async fn aws_sts_mint_rejects_session_token_without_source_pair() { let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-sts-lonesession", "aws"); store.put_message(&prov).await.unwrap(); @@ -4279,13 +4146,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-race", "aws"); store.put_message(&prov).await.unwrap(); let provider_id = prov.object_id().to_string(); @@ -4407,13 +4267,6 @@ mod tests { .await; let store = test_store().await; - crate::grpc::policy::set_global_bool_setting_for_test( - &store, - openshell_core::settings::PROVIDERS_V2_ENABLED_KEY, - true, - ) - .await - .unwrap(); let prov = provider("aws-superseded", "aws"); store.put_message(&prov).await.unwrap(); let provider_id = prov.object_id().to_string(); diff --git a/crates/openshell-supervisor-network/src/sigv4.rs b/crates/openshell-supervisor-network/src/sigv4.rs index 40cc189dc2..79e4c32e18 100644 --- a/crates/openshell-supervisor-network/src/sigv4.rs +++ b/crates/openshell-supervisor-network/src/sigv4.rs @@ -28,6 +28,13 @@ fn looks_like_region(s: &str) -> bool { /// hostnames. The region is the label immediately before `amazonaws.com` /// (or `amazonaws.com.cn`). pub fn extract_aws_region(host: &str) -> Option { + // The global S3 endpoint and its virtual-hosted bucket form always sign + // in us-east-1. This keeps policy metadata consistent across overlapping + // S3 hostname patterns while preserving correct global endpoint signing. + if host == "s3.amazonaws.com" || host.ends_with(".s3.amazonaws.com") { + return Some("us-east-1".to_string()); + } + let parts: Vec<&str> = host.split('.').collect(); // China partition: *.amazonaws.com.cn if parts.len() >= 5 @@ -386,13 +393,19 @@ mod tests { } #[test] - fn global_endpoint_returns_none() { - assert!(extract_aws_region("s3.amazonaws.com").is_none()); + fn global_s3_endpoint_uses_us_east_1() { + assert_eq!( + extract_aws_region("s3.amazonaws.com").as_deref(), + Some("us-east-1") + ); } #[test] - fn virtual_hosted_global_endpoint_returns_none() { - assert!(extract_aws_region("my-bucket.s3.amazonaws.com").is_none()); + fn virtual_hosted_global_s3_endpoint_uses_us_east_1() { + assert_eq!( + extract_aws_region("my-bucket.s3.amazonaws.com").as_deref(), + Some("us-east-1") + ); } #[test] diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index a5e14bcbab..1909caa18a 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -11,6 +11,7 @@ use openshell_core::auth::EdgeAuthInterceptor; use openshell_core::proto::open_shell_client::OpenShellClient; use openshell_core::proto::setting_value; use openshell_core::settings::{self, SettingValueKind}; +use openshell_providers::{ProviderTypeProfile, RealDiscoveryContext, discover_from_profile}; use tonic::service::interceptor::InterceptedService; use tonic::transport::Channel; @@ -350,9 +351,9 @@ pub enum ProviderKeyField { Name, /// Focused credential row for known types (index via `cred_cursor`). Credential, - /// Custom env var name (generic / no-known-env-vars types only). + /// Custom env var name (legacy no-known-env-vars types only). EnvVarName, - /// Custom env var value (generic / no-known-env-vars types only). + /// Custom env var value (legacy no-known-env-vars types only). GenericValue, /// Browsing/deleting existing config entries (Up/Down/Ctrl+D). ConfigList, @@ -385,13 +386,13 @@ pub struct CreateProviderForm { pub config_key_input: String, /// Config value being entered. pub config_value_input: String, - /// For generic / types with no known env vars: custom env var name. + /// For legacy types with no known env vars: custom env var name. pub generic_env_name: String, - /// For generic / types with no known env vars: custom value. + /// For legacy types with no known env vars: custom value. pub generic_value: String, /// Which field is focused in the key entry form. pub key_field: ProviderKeyField, - /// True when the provider type has no known env vars (generic, outlook). + /// True when the selected profile has no credential environment variables. pub is_generic: bool, /// Status message (errors, validation). pub status: Option, @@ -590,7 +591,7 @@ pub struct App { pub pending_workspace_refresh: bool, // Provider list - pub providers_v2_enabled: bool, + pub provider_profiles: Vec, pub provider_entries: Vec, pub provider_names: Vec, pub provider_types: Vec, @@ -970,7 +971,7 @@ impl App { all_workspaces: false, workspace_names: Vec::new(), pending_workspace_refresh: false, - providers_v2_enabled: false, + provider_profiles: Vec::new(), provider_entries: Vec::new(), provider_names: Vec::new(), provider_types: Vec::new(), @@ -1410,7 +1411,7 @@ impl App { self.overflow_focus_up(); } } - KeyCode::Char('c') if !self.providers_v2_enabled => { + KeyCode::Char('c') => { if self.all_workspaces { self.status_text = "Switch to a specific workspace to create providers.".to_string(); @@ -1423,10 +1424,10 @@ impl App { self.pending_provider_get = true; } // Open update form for the selected provider. - KeyCode::Char('u') if self.provider_count > 0 && !self.providers_v2_enabled => { + KeyCode::Char('u') if self.provider_count > 0 => { self.open_update_provider_form(); } - KeyCode::Char('d') if self.provider_count > 0 && !self.providers_v2_enabled => { + KeyCode::Char('d') if self.provider_count > 0 => { self.confirm_provider_delete = true; } KeyCode::Char('h' | 'l') | KeyCode::Left | KeyCode::Right => { @@ -2383,8 +2384,13 @@ impl App { // ------------------------------------------------------------------ fn open_create_provider_form(&mut self) { - let known = openshell_providers::ProviderRegistry::new().known_types(); - let types: Vec = known.into_iter().map(String::from).collect(); + let mut types = self + .provider_profiles + .iter() + .map(|profile| profile.id.clone()) + .collect::>(); + types.sort(); + types.dedup(); self.create_provider_form = Some(CreateProviderForm { types, @@ -2410,8 +2416,17 @@ impl App { } KeyCode::Enter => { let selected = form.types[form.type_cursor].clone(); - let registry = openshell_providers::ProviderRegistry::new(); - let env_vars = registry.credential_env_vars(&selected); + let Some(profile) = self + .provider_profiles + .iter() + .find(|profile| profile.id == selected) + .cloned() + else { + form.status = Some(format!("Provider profile '{selected}' is unavailable")); + return; + }; + let profile = ProviderTypeProfile::from_proto(&profile); + let env_vars = profile.credential_env_vars(); form.is_generic = env_vars.is_empty(); // Populate credential rows from all known env vars. @@ -2425,11 +2440,11 @@ impl App { form.name = unique_provider_name(&selected, &self.provider_names); if form.is_generic { - // No known env vars — skip straight to manual entry. - form.phase = CreateProviderPhase::EnterKey; - form.key_field = ProviderKeyField::Name; - form.status = None; - form.warning = None; + // Credential-less profiles can be created directly. + form.discovered_credentials = Some(HashMap::new()); + form.phase = CreateProviderPhase::Creating; + form.anim_start = Some(Instant::now()); + self.pending_provider_create = true; } else { form.phase = CreateProviderPhase::ChooseMethod; form.method_cursor = 0; @@ -2450,9 +2465,17 @@ impl App { KeyCode::Enter => { let ptype = form.types[form.type_cursor].clone(); if form.method_cursor == 0 { - // Autodetect — synchronous since we only check env vars now. - let registry = openshell_providers::ProviderRegistry::new(); - if let Ok(Some(discovered)) = registry.discover_existing(&ptype) { + let discovered = self + .provider_profiles + .iter() + .find(|profile| profile.id == ptype) + .map(ProviderTypeProfile::from_proto) + .and_then(|profile| { + discover_from_profile(&profile, &RealDiscoveryContext) + .ok() + .flatten() + }); + if let Some(discovered) = discovered { form.discovered_credentials = Some(discovered.credentials); if form.name.is_empty() { form.name = unique_provider_name(&ptype, &self.provider_names); @@ -2893,13 +2916,19 @@ impl App { }) .unwrap_or_default(); - // If we don't know the credential key, derive from registry. + // If we don't know the credential key, derive it from the profile. let key = if cred_key.is_empty() { - let registry = openshell_providers::ProviderRegistry::new(); - registry - .credential_env_vars(&ptype) - .first() - .map_or(String::new(), ToString::to_string) + self.provider_entries + .get(self.provider_selected) + .and_then(|entry| entry.profile.as_ref()) + .map(ProviderTypeProfile::from_proto) + .and_then(|profile| { + profile + .credential_env_vars() + .first() + .map(ToString::to_string) + }) + .unwrap_or_default() } else { cred_key }; @@ -3336,7 +3365,7 @@ impl App { ); let raw_profile_yaml = profile.and_then(|profile| { - let dto = openshell_providers::ProviderTypeProfile::from_proto(profile); + let dto = ProviderTypeProfile::from_proto(profile); openshell_providers::profile_to_yaml(&dto).ok() }); @@ -3435,7 +3464,7 @@ impl App { self.global_policy_active = false; self.global_policy_version = 0; // Reset provider state too. - self.providers_v2_enabled = false; + self.provider_profiles.clear(); self.provider_entries.clear(); self.provider_names.clear(); self.provider_types.clear(); @@ -3501,24 +3530,6 @@ mod tests { ) } - #[tokio::test] - async fn global_settings_do_not_override_provider_api_capability() { - let mut app = test_app(); - app.providers_v2_enabled = true; - let mut values = HashMap::new(); - values.insert( - settings::PROVIDERS_V2_ENABLED_KEY.to_string(), - openshell_core::proto::SettingValue { - value: Some(setting_value::Value::BoolValue(false)), - }, - ); - - app.apply_global_settings(values, 7); - - assert!(app.providers_v2_enabled); - assert_eq!(app.global_settings_revision, 7); - } - #[tokio::test] async fn denied_platform_state_is_cleared_and_reprobed_after_gateway_switch() { let mut app = test_app(); diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index e90cee8b38..ff71c95ddf 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -78,7 +78,6 @@ pub async fn run( let mut events = EventHandler::new(Duration::from_secs(2)); - fetch_providers_v2_setting(&mut app).await; refresh_gateway_list(&mut app); refresh_data(&mut app).await; @@ -500,10 +499,6 @@ async fn handle_gateway_switch(app: &mut App) { app.gateway_name = name; app.endpoint = endpoint; app.reset_sandbox_state(); - // Re-fetch the providers_v2 capability for the new gateway - // before refreshing data, so provider CRUD controls reflect - // the correct mode. - fetch_providers_v2_setting(app).await; refresh_data(app).await; } Err(e) => { @@ -2017,30 +2012,6 @@ fn format_draft_approve_all_result( // Data refresh // --------------------------------------------------------------------------- -async fn fetch_providers_v2_setting(app: &mut App) { - let req = openshell_core::proto::GetGatewayConfigRequest {}; - match tokio::time::timeout(Duration::from_secs(5), app.client.get_gateway_config(req)).await { - Ok(Ok(resp)) => { - let response = resp.into_inner(); - let enabled = response - .settings - .get(openshell_core::settings::PROVIDERS_V2_ENABLED_KEY) - .and_then(|s| match &s.value { - Some(openshell_core::proto::setting_value::Value::BoolValue(v)) => Some(*v), - _ => None, - }) - .unwrap_or(false); - app.providers_v2_enabled = enabled; - } - Ok(Err(e)) => { - app.status_text = format!("failed to fetch gateway config: {}", e.message()); - } - Err(_) => { - app.status_text = "gateway config fetch timed out".to_string(); - } - } -} - async fn refresh_data(app: &mut App) { refresh_health(app).await; refresh_global_settings(app).await; @@ -2140,36 +2111,39 @@ async fn refresh_providers(app: &mut App) { }; let providers = response.providers; - let profiles: ProviderProfileCache = if app.providers_v2_enabled { - let workspaces: std::collections::HashSet = providers - .iter() - .map(|provider| provider_profile_query_workspace(provider).to_string()) - // Legacy provider records can decode without an object workspace. Do not - // turn that missing context into a platform-scoped profile request. - .filter(|workspace| !workspace.is_empty()) - .collect(); - let mut all_profiles = HashMap::new(); - for ws in &workspaces { - let req = openshell_core::proto::ListProviderProfilesRequest { - limit: 100, - offset: 0, - workspace: ws.clone(), - }; - if let Ok(Ok(resp)) = tokio::time::timeout( - Duration::from_secs(5), - app.client.list_provider_profiles(req), - ) - .await - { - for profile in resp.into_inner().profiles { - cache_provider_profile(&mut all_profiles, ws, profile); - } + let mut workspaces: std::collections::HashSet = providers + .iter() + .map(|provider| provider_profile_query_workspace(provider).to_string()) + // Legacy provider records can decode without an object workspace. Do not + // turn that missing context into a platform-scoped profile request. + .filter(|workspace| !workspace.is_empty()) + .collect(); + if !app.all_workspaces { + workspaces.insert(app.current_workspace.clone()); + } + let mut profiles = HashMap::new(); + app.provider_profiles.clear(); + for ws in &workspaces { + let req = openshell_core::proto::ListProviderProfilesRequest { + limit: 100, + offset: 0, + workspace: ws.clone(), + }; + if let Ok(Ok(resp)) = tokio::time::timeout( + Duration::from_secs(5), + app.client.list_provider_profiles(req), + ) + .await + { + let listed = resp.into_inner().profiles; + if !app.all_workspaces && ws == &app.current_workspace { + app.provider_profiles.clone_from(&listed); + } + for profile in listed { + cache_provider_profile(&mut profiles, ws, profile); } } - all_profiles - } else { - HashMap::new() - }; + } app.provider_count = providers.len(); app.provider_entries = providers diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 3078ff793f..7ad1589186 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -207,27 +207,6 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled("[q]", t.muted), Span::styled(" Quit", t.muted), ], - Focus::Providers if app.providers_v2_enabled => vec![ - Span::styled(" ", t.text), - Span::styled("[Tab]", t.key_hint), - Span::styled(" Switch Panel", t.text), - Span::styled(" ", t.text), - Span::styled("[h/l]", t.key_hint), - Span::styled(" Switch Tab", t.text), - Span::styled(" ", t.text), - Span::styled("[j/k]", t.key_hint), - Span::styled(" Navigate", t.text), - Span::styled(" ", t.text), - Span::styled("[Enter]", t.key_hint), - Span::styled(" Detail", t.text), - Span::styled(" ", t.text), - Span::styled("read-only", t.muted), - Span::styled(" | ", t.border), - Span::styled("[:]", t.muted), - Span::styled(" Command ", t.muted), - Span::styled("[q]", t.muted), - Span::styled(" Quit", t.muted), - ], Focus::Providers => vec![ Span::styled(" ", t.text), Span::styled("[Tab]", t.key_hint), @@ -242,14 +221,8 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled("[Enter]", t.key_hint), Span::styled(" Detail", t.text), Span::styled(" ", t.text), - Span::styled("[c]", t.key_hint), - Span::styled(" Create", t.text), - Span::styled(" ", t.text), - Span::styled("[u]", t.key_hint), - Span::styled(" Update", t.text), - Span::styled(" ", t.text), - Span::styled("[d]", t.key_hint), - Span::styled(" Delete", t.text), + Span::styled("[c/u/d]", t.key_hint), + Span::styled(" Create/Update/Delete", t.text), Span::styled(" | ", t.border), Span::styled("[:]", t.muted), Span::styled(" Command ", t.muted), diff --git a/crates/openshell-tui/src/ui/providers.rs b/crates/openshell-tui/src/ui/providers.rs index e4f4d2d0ba..3bf5c7f94c 100644 --- a/crates/openshell-tui/src/ui/providers.rs +++ b/crates/openshell-tui/src/ui/providers.rs @@ -9,12 +9,12 @@ use ratatui::widgets::{Block, Borders, Cell, Padding, Row, Table}; use crate::app::App; pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { - let t = &app.theme; - if app.providers_v2_enabled { - draw_v2(frame, app, area, focused); - return; - } + draw_v2(frame, app, area, focused); +} +#[allow(dead_code)] +fn draw_legacy(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + let t = &app.theme; let show_ws = app.all_workspaces; let mut header_cells = Vec::new(); diff --git a/docs/get-started/tutorials/index.mdx b/docs/get-started/tutorials/index.mdx index bc30bcfc44..bba5996277 100644 --- a/docs/get-started/tutorials/index.mdx +++ b/docs/get-started/tutorials/index.mdx @@ -24,7 +24,7 @@ Launch Claude Code in a sandbox, diagnose a policy denial, and iterate on a cust -Configure a Providers v2 Microsoft Graph provider with gateway-managed OAuth2 refresh-token rotation. +Configure a Microsoft Graph provider profile with gateway-managed OAuth2 refresh-token rotation. diff --git a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx index d3c4a75847..76adf55fde 100644 --- a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx +++ b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx @@ -1,14 +1,14 @@ --- # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -title: "Refresh Microsoft Graph Credentials with Providers v2" +title: "Refresh Microsoft Graph Credentials with a Provider Profile" sidebar-title: "Microsoft Graph Provider Refresh" slug: "get-started/tutorials/microsoft-graph-provider-refresh" -description: "Configure a Providers v2 Microsoft Graph profile with gateway-managed OAuth2 refresh-token rotation." +description: "Configure a Microsoft Graph provider profile with gateway-managed OAuth2 refresh-token rotation." keywords: "Generative AI, Cybersecurity, Tutorial, Providers, Microsoft Graph, OAuth2, Credential Refresh, Sandbox" --- -Use Providers v2 to keep Microsoft Graph access tokens short lived while sandboxes receive a stable `MS_GRAPH_ACCESS_TOKEN` placeholder. OpenShell stores the non-injectable refresh material at the gateway, refreshes the Microsoft Graph access token before it expires, updates the provider record, and injects the current credential into newly launched sandbox processes. +Use a provider profile to keep Microsoft Graph access tokens short lived while sandboxes receive a stable `MS_GRAPH_ACCESS_TOKEN` placeholder. OpenShell stores the non-injectable refresh material at the gateway, refreshes the Microsoft Graph access token before it expires, updates the provider record, and injects the current credential into newly launched sandbox processes. After completing this tutorial, you have: @@ -43,14 +43,6 @@ Do not commit access tokens, refresh tokens, or local `.env` files. The commands -## Enable Providers v2 - -Enable provider profile policy composition on the active gateway: - -```shell -openshell settings set --global --key providers_v2_enabled --value true --yes -``` - ## Create a Microsoft Graph Provider Profile Create `microsoft-graph-mail.yaml` with this profile: diff --git a/docs/providers/google-vertex-ai.mdx b/docs/providers/google-vertex-ai.mdx index 21b9d4d371..5c577efd5a 100644 --- a/docs/providers/google-vertex-ai.mdx +++ b/docs/providers/google-vertex-ai.mdx @@ -105,13 +105,8 @@ OpenShell exposes Anthropic Vertex routes for inference only. It does not advert ## Configure Inference Routing -Before configuring inference routing, enable provider endpoint injection so the Vertex AI network endpoints are automatically included in sandbox policies: - -```shell -openshell settings set --global --key providers_v2_enabled --value true --yes -``` - -Then point `inference.local` at the provider: +Point `inference.local` at the provider. The Vertex AI profile contributes its +network endpoints to sandbox policies automatically: ```shell openshell inference set \ @@ -137,10 +132,7 @@ Agents inside sandboxes should reach Vertex AI through `inference.local`, not by 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 +# 1. Create the provider openshell provider create \ --name vertex-local \ --type google-vertex-ai \ @@ -148,10 +140,10 @@ openshell provider create \ --config VERTEX_AI_PROJECT_ID=my-gcp-project \ --config VERTEX_AI_REGION=us-central1 -# 3. Configure inference routing +# 2. Configure inference routing openshell inference set --provider vertex-local --model claude-sonnet-4-6 --no-verify -# 4. Create a sandbox with the provider attached +# 3. Create a sandbox with the provider attached openshell sandbox create --name my-sandbox --provider vertex-local ``` diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 66fc418041..5aff9794bb 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -13,7 +13,9 @@ AI agents typically need credentials to access external services: an API key for Create and manage providers that supply credentials to sandboxes. -Providers v2 is available for profile-backed provider policy, provider-owned network rules, and gateway-managed credential refresh. This page remains the credential-focused provider command reference. For the new workflow, see [Providers v2](/sandboxes/providers-v2). +Provider profiles define provider credentials, policy, and refresh behavior. See +[Provider profiles](/sandboxes/providers-v2) for the profile format and custom +profile workflow. Provider profiles include metadata for known endpoints and binaries. View @@ -89,22 +91,22 @@ the stored gateway access token is expired and a refresh token is available, the CLI refreshes it before storing the provider credential. It does not store the OIDC refresh token in the provider. -Provider profile metadata is available for known provider types. Provider profile -network policy is gateway opt-in: +`--from-existing` uses profile-backed discovery. The requested `--type` must +have a built-in or imported provider profile with a `discovery` section. If no +matching profile exists, the CLI returns an error instead of falling back to +legacy discovery. + +Create a provider whose profile requires no static credentials without a +credential source: ```shell -openshell settings set --global --key providers_v2_enabled --value true +openshell provider create --name public-pypi --type pypi ``` -Without `providers_v2_enabled=true`, attached provider profiles do not contribute -network policy to the sandbox. Static credential endpoint binding remains active -in either mode. - -When `providers_v2_enabled=true`, `--from-existing` uses profile-backed -discovery instead of the legacy provider registry. The requested `--type` must -have a built-in or imported provider profile with a `discovery` section. If no -matching profile exists, the CLI returns an error instead of falling back to -legacy discovery. +Provider creation rejects profileless types. For a custom GitLab deployment, +import a profile with the deployment's endpoints and then create an instance +using that profile ID. Existing profileless provider records remain usable, but +OpenShell does not create new ones. Update a custom provider profile after exporting it, editing its endpoints, binaries, or credential metadata, and preserving the exported `resource_version`: @@ -117,9 +119,8 @@ openshell provider profile update my-api -f my-api-profile.yaml Import remains create-only and fails if the profile ID already exists. Use `provider profile update ` for existing custom profiles. Built-in profiles are read-only. The target ID must match the profile ID in the file. Update accepts -one file at a time and rejects stale resource versions. When -`providers_v2_enabled=true`, updated profile policy applies to all provider -instances of that type on the next sandbox config sync. +one file at a time and rejects stale resource versions. Updated profile policy +applies to all provider instances of that type on the next sandbox config sync. Static credentials require a built-in or imported provider profile. Profiles @@ -218,17 +219,15 @@ openshell provider refresh rotate my-graph --credential-key MS_GRAPH_ACCESS_TOKE ### AWS STS -AWS STS refresh requires `providers_v2_enabled=true`. The gateway calls -`sts:AssumeRole` and writes three short-lived credentials (`AWS_ACCESS_KEY_ID`, -`AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) to the provider record atomically. +The gateway calls `sts:AssumeRole` and writes three short-lived credentials +(`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) to the +provider record atomically. The `aws` and `aws-s3` profiles declare `AWS_SECRET_ACCESS_KEY` and `AWS_SESSION_TOKEN` as `additional_outputs` of the `AWS_ACCESS_KEY_ID` refresh, so all three credentials are gateway-minted. Create the provider with `--runtime-credentials` — no placeholder credential is needed. ```shell -openshell settings set --global --key providers_v2_enabled --value true --yes - openshell provider create --name my-aws --type aws-s3 --runtime-credentials openshell provider refresh configure my-aws \ @@ -291,25 +290,23 @@ credentials from every attached provider as placeholders at runtime. Each static credential resolves only for endpoints in that provider's profile, or for explicitly bound sandbox policy endpoints when the profile is endpointless. Profile-managed providers also contribute provider-generated network policy -entries when `providers_v2_enabled` is enabled at the gateway. When the setting -is disabled, endpoint binding still applies, but provider-generated policy does -not. +entries. Endpoint binding independently limits where each static credential can +be resolved. -Legacy provider attachment is fixed at sandbox creation time. Providers v2 adds +Legacy provider attachment is fixed at sandbox creation time. Provider profiles add `openshell sandbox provider attach` and `openshell sandbox provider detach` for -running sandboxes. See [Providers v2](/sandboxes/providers-v2#attach-and-detach-providers) +running sandboxes. See [Provider profiles](/sandboxes/providers-v2#attach-and-detach-providers) for runtime attach and detach behavior. ### Auto-Discovery Shortcut -When `providers_v2_enabled=false` and the trailing command in -`openshell sandbox create` is a recognized tool name (`claude`, `codex`, or -`opencode`), the CLI auto-creates the required provider from your local -credentials if one does not already exist. You do not need to create the -provider separately: +When the trailing command in `openshell sandbox create` maps to an available +profile, the CLI auto-creates the required provider from local profile +discovery if one does not already exist. You do not need to create the provider +separately: ```shell openshell sandbox create -- claude @@ -318,9 +315,8 @@ openshell sandbox create -- claude This detects `claude` as a known tool, finds your `ANTHROPIC_API_KEY`, creates a provider, attaches it to the sandbox, and launches Claude Code. -Providers v2 disables command-derived provider inference. When -`providers_v2_enabled=true`, create or import the provider profile, create the -provider instance, and pass `--provider ` explicitly. +If the inferred type has no built-in or imported profile, creation fails. Import +a custom profile first or pass an existing provider with `--provider `. ## How Credential Injection Works @@ -343,15 +339,13 @@ request. A plain sandbox policy endpoint does not grant credential use unless the profile already covers it or the endpoint explicitly binds an endpointless provider. -Endpoint binding applies whether `providers_v2_enabled` is enabled or disabled. -The setting controls provider policy composition only. Every static credential -declared by a provider receives the complete endpoint set from that provider's +Every static credential declared by a provider receives the complete endpoint set from that provider's profile, or the explicitly bound sandbox policy endpoints for an endpointless profile. Credential resolution requires the proxy to handle the request as HTTP. Raw `tls: skip` and non-HTTP tunnels remain opaque and do not support credential -rewrite. Refer to [Providers v2](/sandboxes/providers-v2#understand-static-credential-endpoint-binding) +rewrite. Refer to [Provider profiles](/sandboxes/providers-v2#understand-static-credential-endpoint-binding) for endpoint matching and migration guidance. ### Supported injection locations diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index b4d607cb33..0e31a162e3 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -195,7 +195,7 @@ Findings are categorical. There is no severity tier. The reviewer reads the cate Before approval, the gateway rebuilds the candidate token from the live base policy, immutable provider rules, and non-secret credential metadata. When that token is unchanged, it reuses the persisted prover result instead of rerunning the prover. When it changes, the gateway evaluates and persists the refreshed candidate, leaves the chunk pending, and requires the reviewer to inspect and approve the new token. Edits and deduplicated resubmissions follow the same path. Merge, policy-shape, provider-composition, credential, or prover failures are shown as application errors and cannot be approved. Security notes flag concerns such as internal or private destinations and `allowed_ips`, wildcard hosts, hostless `allowed_ips`, ephemeral ports, and well-known database or service ports. Any prover finding or security note keeps the chunk pending in auto mode. -The full reasoning model lives in [`crates/openshell-prover/README.md`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-prover/README.md). Provider profiles composed in via [Providers v2](/sandboxes/providers-v2) are part of the effective policy the prover reasons over. +The full reasoning model lives in [`crates/openshell-prover/README.md`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-prover/README.md). Provider profiles composed in via [Provider profiles](/sandboxes/providers-v2) are part of the effective policy the prover reasons over. ## Review Proposals diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 7a0f96b5ac..b4d7aa0596 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -1,24 +1,24 @@ --- # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -title: "Providers v2" -sidebar-title: "Providers v2" +title: "Provider profiles" +sidebar-title: "Provider profiles" description: "Use provider profiles to attach credentials, network policy, and refresh metadata to OpenShell sandboxes." keywords: "Generative AI, Cybersecurity, Providers, Provider Profiles, Credentials, Policy, Sandbox" position: 5 --- -Providers v2 turns providers from credential records into profile-backed access bundles. A provider profile describes the credentials, endpoints, binaries, policy rules, and refresh behavior for a provider type. A provider instance stores the concrete credential and config values for one gateway. +Provider profiles turn providers from credential records into profile-backed access bundles. A provider profile describes the credentials, endpoints, binaries, policy rules, and refresh behavior for a provider type. A provider instance stores the concrete credential and config values for one gateway. -Use Providers v2 when you want provider-owned policy rules to travel with provider credentials. For example, a GitHub provider can describe both `GITHUB_TOKEN` and the GitHub API endpoints that a sandbox needs, so users do not have to copy the same network policy into every sandbox. +Use provider profiles when you want provider-owned policy rules to travel with provider credentials. For example, a GitHub provider can describe both `GITHUB_TOKEN` and the GitHub API endpoints that a sandbox needs, so users do not have to copy the same network policy into every sandbox. -## Why Providers v2 Exists +## Why Provider Profiles Exist Provider credentials and network policy were previously configured through separate workflows. A user could create a GitHub provider that stored `GITHUB_TOKEN`, but the sandbox still needed a separate policy that allowed `api.github.com`, selected the right binaries, and configured REST enforcement. -Providers v2 keeps those pieces together: +Provider profiles keep those pieces together: -| Need | Providers v2 behavior | +| Need | Profile-backed behavior | |---|---| | Repeatable provider setup | Built-in and custom provider profiles define reusable provider types. | | Provider-aware policy | Attached providers contribute `_provider_*` network policy entries to the effective sandbox policy. | @@ -27,29 +27,9 @@ Providers v2 keeps those pieces together: | Credential rotation | Provider refresh metadata lets the gateway refresh short-lived access tokens and update provider records. | | Credential transport | Credential delivery uses environment placeholders and proxy rewrite. Static placeholders resolve only at profile endpoints or explicitly bound sandbox policy endpoints for endpointless profiles. | -## Enable Providers v2 - -Provider profile policy composition is controlled by the gateway-level `providers_v2_enabled` setting. Enable it on the active gateway: - -```shell -openshell settings set --global --key providers_v2_enabled --value true -``` - -When the setting is disabled or unset, attached provider profiles do not add network policy entries to the effective policy. Static provider credential placeholders still use the profile endpoints as a resolution boundary. - -To disable provider profile policy composition, delete the setting: - -```shell -openshell settings delete --global --key providers_v2_enabled -``` - - -The feature flag controls provider-derived policy layers. It does not disable endpoint binding for static credential placeholders. Provider profiles can also declare dynamic token grants that the sandbox proxy resolves on demand for matching HTTP endpoints. - - ## Available Features -Providers v2 currently includes these user-facing features: +Provider profiles include these user-facing features: - Built-in provider profiles loaded by the gateway by default. - Gateway configuration can compose built-in, user-managed, and interceptor-vended profile sources. Selecting only an interceptor makes its catalog authoritative by omission. @@ -209,7 +189,7 @@ Later access-token rotations do not require workload restarts. ## Roadmap -The following Providers v2 design items are not part of the current behavior: +The following provider profile design items are not part of the current behavior: | Roadmap item | Current behavior | |---|---| @@ -241,10 +221,11 @@ of truth: list, export, provider creation, policy composition, and sandbox provider environment resolution use the interceptor-vended profiles instead of built-in or user-imported profiles. -Built-in Providers v2 profiles currently include: +Built-in provider profiles currently include: | Profile ID | Category | Credential environment variables | |---|---|---| +| `anthropic` | `inference` | `ANTHROPIC_API_KEY` | | `claude-code` | `agent` | `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY` | | `codex` | `agent` | `CODEX_AUTH_ACCESS_TOKEN`, `CODEX_AUTH_REFRESH_TOKEN`, `CODEX_AUTH_ACCOUNT_ID`, `CODEX_AUTH_ID_TOKEN` | | `copilot` | `agent` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | @@ -253,6 +234,7 @@ Built-in Providers v2 profiles currently include: | `github` | `source_control` | `GITHUB_TOKEN`, `GH_TOKEN` | | `google-vertex-ai` | `inference` | `GOOGLE_SERVICE_ACCOUNT_KEY`, `GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN`, `VERTEX_AI_SERVICE_ACCOUNT_TOKEN`, `GOOGLE_VERTEX_AI_TOKEN`, `VERTEX_AI_TOKEN` | | `nvidia` | `inference` | `NVIDIA_API_KEY` | +| `openai` | `inference` | `OPENAI_API_KEY` | | `pypi` | `data` | None | Export a built-in profile as YAML: @@ -440,8 +422,8 @@ binaries: `credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at their binding endpoints. Every static credential environment key receives the full profile endpoint set when the profile defines endpoints. An endpointless profile requires explicit sandbox policy bindings for each attached provider instance. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. -`discovery` controls what `--from-existing` scans when -`providers_v2_enabled=true`. Each entry in `discovery.credentials` must name a +`discovery` controls what `--from-existing` scans. Each entry in +`discovery.credentials` must name a credential declared under `credentials`. OpenShell scans the referenced credential's `env_vars` in order and stores the first non-empty local environment value under the actual environment variable key. @@ -465,7 +447,7 @@ Profile YAML can declare these refresh strategies: | `oauth2_refresh_token` | The gateway exchanges a refresh token for a short-lived access token. | | `oauth2_client_credentials` | The gateway mints a short-lived access token with OAuth2 client credentials. | | `google_service_account_jwt` | The gateway signs a Google service account JWT and exchanges it for an access token. | -| `aws_sts_assume_role` | The gateway calls `sts:AssumeRole` and mints `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in one operation. Requires `providers_v2_enabled=true`. | +| `aws_sts_assume_role` | The gateway calls `sts:AssumeRole` and mints `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in one operation. | `openshell provider refresh configure` accepts only gateway-mintable strategies: `oauth2-refresh-token`, `oauth2-client-credentials`, `google-service-account-jwt`, and `aws-sts-assume-role`. Use `openshell provider update` for `static` and `external` refresh patterns. @@ -582,15 +564,11 @@ openshell provider create \ --from-existing ``` -When `providers_v2_enabled=true`, `--from-existing` uses the provider profile's -`discovery` section. If no profile exists for the requested type, the command -fails instead of falling back to the legacy provider registry. When -`providers_v2_enabled=false`, `--from-existing` uses the legacy provider -registry and ignores profile `discovery` metadata. - -For example, with Providers v2 enabled, `--type openai --from-existing` -requires an imported `openai` profile with a `discovery` section. Setting -`OPENAI_API_KEY` alone is not enough for v2 profile discovery. +`--from-existing` uses the provider profile's `discovery` section. If no +profile exists for the requested type, the command fails instead of falling +back to the retired provider registry. The built-in `openai` profile discovers +`OPENAI_API_KEY`; the built-in `anthropic` profile discovers +`ANTHROPIC_API_KEY`. Create a provider from an imported custom profile: @@ -699,7 +677,7 @@ cannot replace or delete its primary credential or any co-minted output. Use management. You can still update unrelated credentials, configuration, and credential expiry metadata. -For a complete Microsoft Graph OAuth2 refresh-token walkthrough, see [Refresh Microsoft Graph Credentials with Providers v2](/get-started/tutorials/microsoft-graph-provider-refresh). +For a complete Microsoft Graph OAuth2 refresh-token walkthrough, see [Refresh Microsoft Graph Credentials with a Provider Profile](/get-started/tutorials/microsoft-graph-provider-refresh). The profile YAML strategy values use underscores, while the CLI `--strategy` values use kebab-case: @@ -846,13 +824,14 @@ openshell sandbox create \ -- claude ``` -When `providers_v2_enabled=true`, each attached provider with a matching profile contributes a provider policy layer to the sandbox effective policy. The base policy is the user-authored sandbox policy that you can edit and apply. The effective policy is the composed policy that the sandbox enforces: base policy plus provider policy layers. When the setting is disabled, the sandbox still receives endpoint-bound provider credentials but not provider-derived policy entries. +Each attached provider with a matching profile contributes a provider policy layer to the sandbox effective policy. The base policy is the user-authored sandbox policy that you can edit and apply. The effective policy is the composed policy that the sandbox enforces: base policy plus provider policy layers. A gateway-global policy suppresses provider-derived policy layers. Updating a custom provider profile affects every provider instance whose `type` matches that profile ID. Provider instances are not rewritten, and sandbox-authored policies are not modified. Running sandboxes observe the updated provider-derived policy on their next config sync. If a gateway-global policy is active, provider-derived policy layers remain suppressed. -Providers v2 does not infer or auto-attach providers from the sandbox command. -Attach providers explicitly with `--provider` during sandbox creation, or use -`openshell sandbox provider attach` after creation. +The CLI can infer a provider profile from a recognized sandbox command and +auto-create the provider from profile discovery. Attach a different existing +provider explicitly with `--provider`, or use `openshell sandbox provider +attach` after creation. List providers attached to a sandbox: diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 40fd05a122..a924168a1b 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -99,68 +99,6 @@ def _delete_provider(stub: object, name: str) -> None: raise -@pytest.fixture -def providers_v2_enabled( - sandbox_client: SandboxClient, - _gateway_config_guard: None, -) -> Iterator[None]: - """Enable the gateway-global ``providers_v2_enabled`` opt-in for one test. - - Composing a provider's network policy onto a sandbox is gated behind this - setting, which defaults off; the built-in github profile's git-transport - rules only reach the sandbox with it enabled. - - The setting is gateway-global. Exclusivity against other xdist workers is - provided by the ``exclusive_gateway_config`` marker plus the autouse - ``_gateway_config_guard`` guard (see conftest): no concurrent worker is - mid-test while this fixture mutates and restores the setting, so none can - observe the transient value. Depending on the guard here also orders the - exclusive lock acquisition before the mutation. - - ``GetGatewayConfig`` returns known keys even when unset, with an empty - ``SettingValue`` (no populated oneof), so the setting is treated as present - only when its value oneof is set; otherwise restore is a delete. ``global`` - is a Python keyword, so it is passed through a dict expansion. - """ - stub = sandbox_client._stub - key = "providers_v2_enabled" - config = stub.GetGatewayConfig(sandbox_pb2.GetGatewayConfigRequest()) - prior_value = sandbox_pb2.SettingValue() - had_prior = ( - key in config.settings - and config.settings[key].WhichOneof("value") is not None - ) - if had_prior: - prior_value.CopyFrom(config.settings[key]) - - stub.UpdateConfig( - openshell_pb2.UpdateConfigRequest( - setting_key=key, - setting_value=sandbox_pb2.SettingValue(bool_value=True), - **{"global": True}, - ) - ) - try: - yield - finally: - if had_prior: - stub.UpdateConfig( - openshell_pb2.UpdateConfigRequest( - setting_key=key, - setting_value=prior_value, - **{"global": True}, - ) - ) - else: - stub.UpdateConfig( - openshell_pb2.UpdateConfigRequest( - setting_key=key, - delete_setting=True, - **{"global": True}, - ) - ) - - # =========================================================================== # Tests: placeholder visibility # =========================================================================== @@ -195,38 +133,24 @@ def read_env_var() -> str: assert value != "sk-e2e-test-key-12345" -def test_profileless_provider_credentials_fail_closed( - sandbox: Callable[..., Sandbox], +def test_profileless_provider_creation_is_rejected( sandbox_client: SandboxClient, ) -> None: - """Profileless credentials are withheld because they have no endpoint binding.""" - with provider( - sandbox_client._stub, - name="e2e-test-generic-provider-env", - provider_type="generic", - credentials={ - "CUSTOM_SERVICE_TOKEN": "token-generic-123", - "CUSTOM_SERVICE_URL": "https://internal.example.test/api", - }, - ) as provider_name: - spec = datamodel_pb2.SandboxSpec( - policy=_default_policy(), - providers=[provider_name], + """New providers must reference a built-in or imported profile.""" + with pytest.raises(grpc.RpcError) as exc_info: + sandbox_client._stub.CreateProvider( + openshell_pb2.CreateProviderRequest( + provider=datamodel_pb2.Provider( + metadata=datamodel_pb2.ObjectMeta( + name="e2e-test-profileless-provider" + ), + type="generic", + credentials={"CUSTOM_SERVICE_TOKEN": "token-generic-123"}, + ) + ) ) - - def read_generic_env_vars() -> str: - import os - - token = os.environ.get("CUSTOM_SERVICE_TOKEN", "NOT_SET") - url = os.environ.get("CUSTOM_SERVICE_URL", "NOT_SET") - return f"{token}|{url}" - - with sandbox(spec=spec, delete_on_exit=True) as sb: - result = sb.exec_python(read_generic_env_vars) - assert result.exit_code == 0, result.stderr - token, url = result.stdout.strip().split("|") - assert token == "NOT_SET" - assert url == "NOT_SET" + assert exc_info.value.code() == grpc.StatusCode.INVALID_ARGUMENT + assert "provider profile 'generic' was not found" in exc_info.value.details() def test_endpointless_profile_credentials_fail_closed_without_policy_binding( @@ -326,9 +250,7 @@ def read_nvidia_key() -> str: with sandbox(spec=spec, delete_on_exit=True) as sb: result = sb.exec_python(read_nvidia_key) assert result.exit_code == 0, result.stderr - assert _is_placeholder_for_env_key( - result.stdout.strip(), "NVIDIA_API_KEY" - ) + assert _is_placeholder_for_env_key(result.stdout.strip(), "NVIDIA_API_KEY") def test_attach_detach_updates_credentials_for_later_exec_launches( @@ -470,8 +392,12 @@ def test_update_provider_preserves_unset_credentials_and_config( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"KEY_A": "val-a", "KEY_B": "val-b"}, + type="codex", + credentials={ + "CODEX_AUTH_ACCESS_TOKEN": "val-a", + "CODEX_AUTH_REFRESH_TOKEN": "val-b", + "CODEX_AUTH_ACCOUNT_ID": "account-id", + }, config={"BASE_URL": "https://example.com"}, ) ) @@ -482,7 +408,7 @@ def test_update_provider_preserves_unset_credentials_and_config( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), type="", - credentials={"KEY_A": "rotated-a"}, + credentials={"CODEX_AUTH_ACCESS_TOKEN": "rotated-a"}, ) ) ) @@ -515,8 +441,8 @@ def test_update_provider_empty_maps_preserves_all( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"TOKEN": "secret"}, + type="openai", + credentials={"OPENAI_API_KEY": "secret"}, config={"URL": "https://api.example.com"}, ) ) @@ -557,8 +483,8 @@ def test_update_provider_merges_config_preserves_credentials( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"API_KEY": "original-key"}, + type="openai", + credentials={"OPENAI_API_KEY": "original-key"}, config={"ENDPOINT": "https://old.example.com"}, ) ) @@ -600,8 +526,8 @@ def test_update_provider_rejects_type_change( openshell_pb2.CreateProviderRequest( provider=datamodel_pb2.Provider( metadata=datamodel_pb2.ObjectMeta(name=name), - type="generic", - credentials={"KEY": "val"}, + type="openai", + credentials={"OPENAI_API_KEY": "val"}, ) ) ) @@ -626,8 +552,6 @@ def test_update_provider_rejects_type_change( # =========================================================================== -@pytest.mark.exclusive_gateway_config -@pytest.mark.usefixtures("providers_v2_enabled") def test_github_provider_allows_https_git_clone( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, @@ -640,9 +564,7 @@ def test_github_provider_allows_https_git_clone( the sandbox, exercising provider attachment, effective-policy composition, TLS interception, and real git behavior end to end. git delegates HTTPS to a ``git-remote-https`` helper whose ancestor is ``/usr/bin/git``, so the - profile's git binary covers it via ancestor matching. The - ``providers_v2_enabled`` fixture turns on the gateway-global gate that - composes the provider's network policy. + profile's git binary covers it via ancestor matching. """ with provider( sandbox_client._stub, diff --git a/e2e/rust/tests/credential_gating.rs b/e2e/rust/tests/credential_gating.rs index f4e1c5cee7..25d5516543 100644 --- a/e2e/rust/tests/credential_gating.rs +++ b/e2e/rust/tests/credential_gating.rs @@ -221,7 +221,6 @@ enum EndpointMode { TlsSkip, L4OptIn, RestBody { rewrite: bool }, - WebSocket, } #[derive(Clone, Copy)] @@ -245,9 +244,6 @@ fn write_policy( EndpointMode::RestBody { rewrite } => format!( " protocol: rest\n access: full\n request_body_credential_rewrite: {rewrite}\n" ), - EndpointMode::WebSocket => { - " protocol: websocket\n access: read-write\n".to_string() - } }; let credential_binding = match credential_source { CredentialSource::ProviderProfile => String::new(), @@ -290,6 +286,27 @@ network_policies: Ok(file) } +fn write_base_policy() -> Result { + let mut file = NamedTempFile::new().map_err(|error| format!("create policy: {error}"))?; + file.write_all( + br#"version: 1 +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox +"#, + ) + .map_err(|error| format!("write policy: {error}"))?; + file.flush() + .map_err(|error| format!("flush policy: {error}"))?; + Ok(file) +} + #[derive(Debug, Default, Clone, Copy)] struct BodyObservation { saw_placeholder: bool, @@ -840,40 +857,41 @@ async fn run_body_sandbox( Ok(output) } -async fn assert_rest_body_backstop(server: &HttpProbeServer) -> Result<(), String> { - let denied = run_body_sandbox( - server.port, - EndpointMode::RestBody { rewrite: false }, - CredentialSource::ProviderProfile, - ) - .await?; - assert!(denied.contains("BODY_DENIED")); - - let rewritten = run_body_sandbox( - server.port, - EndpointMode::RestBody { rewrite: true }, - CredentialSource::ProviderProfile, - ) +async fn run_profile_body_sandbox(port: u16) -> Result { + let policy = write_base_policy()?; + let policy_path = policy + .path() + .to_str() + .ok_or_else(|| "body policy path is not UTF-8".to_string())?; + let script = body_client_script(port); + let mut sandbox = SandboxGuard::create(&[ + "--policy", + policy_path, + "--provider", + PROVIDER_NAME, + "--", + "python3", + "-c", + &script, + ]) .await?; - assert!(rewritten.contains("BODY_REWRITTEN")); - assert!(!rewritten.contains(TEST_SECRET)); - assert!(!rewritten.contains(PLACEHOLDER_PREFIX)); + let output = sandbox.create_output.clone(); + sandbox.cleanup().await; + Ok(output) +} - let observations = server.wait_for_observations(2).await; - assert_eq!(observations.len(), 2, "observations: {observations:?}"); +async fn assert_rest_body_backstop(server: &HttpProbeServer) -> Result<(), String> { + let denied = run_profile_body_sandbox(server.port).await?; + assert!(denied.contains("BODY_DENIED")); + let observations = server.wait_for_observations(1).await; + assert_eq!(observations.len(), 1, "observations: {observations:?}"); assert!(!observations[0].saw_placeholder); assert!(!observations[0].saw_secret); - assert!(!observations[1].saw_placeholder); - assert!(observations[1].saw_secret); Ok(()) } async fn assert_websocket_binary_denied(server: &BinaryWebSocketProbeServer) -> Result<(), String> { - let policy = write_policy( - server.port, - EndpointMode::WebSocket, - CredentialSource::ProviderProfile, - )?; + let policy = write_base_policy()?; let policy_path = policy .path() .to_str() @@ -914,7 +932,6 @@ async fn credentialed_endpoint_gates_work_end_to_end() { .expect("install credentialed provider"); let result = async { - assert_gateway_admission(server.port, CredentialSource::ProviderProfile).await?; assert_rest_body_backstop(&server).await?; assert_websocket_binary_denied(&websocket_server).await } @@ -935,10 +952,21 @@ async fn credentialed_endpoint_gates_work_end_to_end() { ) .await?; assert!(denied.contains("BODY_DENIED")); + let rewritten = run_body_sandbox( + server.port, + EndpointMode::RestBody { rewrite: true }, + CredentialSource::PolicyBinding, + ) + .await?; + assert!(rewritten.contains("BODY_REWRITTEN")); + assert!(!rewritten.contains(TEST_SECRET)); + assert!(!rewritten.contains(PLACEHOLDER_PREFIX)); let observations = server.wait_for_observations(3).await; assert_eq!(observations.len(), 3, "observations: {observations:?}"); + assert!(!observations[1].saw_placeholder); + assert!(!observations[1].saw_secret); assert!(!observations[2].saw_placeholder); - assert!(!observations[2].saw_secret); + assert!(observations[2].saw_secret); assert_endpointless_provider_env_live_update(server.port).await?; Ok::<(), String>(()) } diff --git a/e2e/rust/tests/oidc_pkce.rs b/e2e/rust/tests/oidc_pkce.rs index e6f6067a34..a2b5c64e40 100644 --- a/e2e/rust/tests/oidc_pkce.rs +++ b/e2e/rust/tests/oidc_pkce.rs @@ -138,9 +138,7 @@ async fn user_can_create_sandbox() { } /// Workspace users must be able to create sandboxes with inferred-provider -/// commands (e.g. `claude`). The CLI calls `GetGatewayConfig` to check -/// `providers_v2_enabled` before sandbox creation; that RPC must not be -/// gated to Platform Admin or the workspace-user flow breaks. +/// commands (e.g. `claude`) without requiring Platform Admin access. #[tokio::test] async fn user_can_create_sandbox_with_inferred_provider_command() { const WORKSPACE: &str = "oidc-inferred-cmd"; @@ -150,10 +148,9 @@ async fn user_can_create_sandbox_with_inferred_provider_command() { let _lifecycle = SANDBOX_LIFECYCLE_LOCK.lock().await; // Use `claude` as the command so the CLI infers provider type - // `claude-code` and calls `GetGatewayConfig` to check - // `providers_v2_enabled`. The sandbox won't actually start (no - // provider credentials), but we only care that the - // `GetGatewayConfig` call itself succeeds for a workspace user. + // `claude-code`. The sandbox won't actually start (no provider + // credentials), but provider inference must remain available to a + // workspace user. let output = run_workspace_cli( &user, WORKSPACE, @@ -279,9 +276,9 @@ async fn admin_can_manage_provider() { "--name", PROVIDER, "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], "create a provider", ) @@ -317,9 +314,9 @@ async fn user_cannot_create_provider() { "--name", "oidc-pkce-user-provider", "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], ) .await; @@ -343,9 +340,9 @@ async fn user_cannot_delete_provider() { "--name", PROVIDER, "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], "create the provider deletion target", ) @@ -538,9 +535,9 @@ async fn workspace_admin_can_create_provider() { "--name", PROVIDER, "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], "create a provider as workspace admin", ) @@ -572,9 +569,9 @@ async fn workspace_admin_can_delete_provider() { "--name", PROVIDER, "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], "create the provider deletion target", ) @@ -789,9 +786,9 @@ async fn workspace_admin_cannot_manage_another_workspace_providers() { "--name", "oidc-wsa-xprovider", "--type", - "generic", + "openai", "--credential", - "TOKEN=e2e-test-value", + "OPENAI_API_KEY=e2e-test-value", ], ) .await; diff --git a/e2e/rust/tests/provider_refresh_keycloak.rs b/e2e/rust/tests/provider_refresh_keycloak.rs index 57bcb45fb7..b0ba3236a8 100644 --- a/e2e/rust/tests/provider_refresh_keycloak.rs +++ b/e2e/rust/tests/provider_refresh_keycloak.rs @@ -200,59 +200,6 @@ async fn delete_provider_resources() { let _ = run_cli(&["provider", "profile", "delete", PROFILE_ID], &[]).await; } -async fn read_providers_v2_setting() -> Result, String> { - let output = run_cli(&["settings", "get", "--global", "--json"], &[]).await?; - if !output.status.success() { - return Err(format!( - "read global settings failed (exit {:?}):\n{}", - output.status.code(), - combined_output(&output) - )); - } - let response: Value = serde_json::from_slice(&output.stdout) - .map_err(|error| format!("decode global settings: {error}"))?; - let value = response - .get("settings") - .and_then(|settings| settings.get("providers_v2_enabled")) - .and_then(Value::as_str) - .filter(|value| *value != "") - .map(ToString::to_string); - Ok(value) -} - -async fn restore_providers_v2_setting(previous: Option<&str>) -> Result<(), String> { - if let Some(value) = previous { - run_cli_success( - &[ - "settings", - "set", - "--global", - "--key", - "providers_v2_enabled", - "--value", - value, - "--yes", - ], - &[], - ) - .await?; - } else { - run_cli_success( - &[ - "settings", - "delete", - "--global", - "--key", - "providers_v2_enabled", - "--yes", - ], - &[], - ) - .await?; - } - Ok(()) -} - #[tokio::test] async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), String> { let issuer = std::env::var("OPENSHELL_E2E_OIDC_ISSUER") @@ -261,7 +208,6 @@ async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), Str .map_err(|_| "OPENSHELL_E2E_OIDC_USERNAME is required".to_string())?; let password = std::env::var("OPENSHELL_E2E_OIDC_PASSWORD") .map_err(|_| "OPENSHELL_E2E_OIDC_PASSWORD is required".to_string())?; - let previous_providers_v2_setting = read_providers_v2_setting().await?; let (access_token, refresh_token) = acquire_keycloak_grant(&issuer, &username, &password).await?; let profile = write_profile(&issuer)?; @@ -269,20 +215,6 @@ async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), Str delete_provider_resources().await; let result = async { - run_cli_success( - &[ - "settings", - "set", - "--global", - "--key", - "providers_v2_enabled", - "--value", - "true", - "--yes", - ], - &[], - ) - .await?; run_cli_success( &["provider", "profile", "import", "--file", &profile_path], &[], @@ -404,14 +336,5 @@ async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), Str .await; delete_provider_resources().await; - let cleanup_result = - restore_providers_v2_setting(previous_providers_v2_setting.as_deref()).await; - match (result, cleanup_result) { - (Ok(()), Ok(())) => Ok(()), - (Err(test_error), Ok(())) => Err(test_error), - (Ok(()), Err(cleanup_error)) => Err(cleanup_error), - (Err(test_error), Err(cleanup_error)) => Err(format!( - "{test_error}\ncleanup also failed: {cleanup_error}" - )), - } + result } diff --git a/e2e/rust/tests/provider_token_exchange.rs b/e2e/rust/tests/provider_token_exchange.rs index 8747a4b279..c4e06f029f 100644 --- a/e2e/rust/tests/provider_token_exchange.rs +++ b/e2e/rust/tests/provider_token_exchange.rs @@ -835,19 +835,6 @@ async fn podman_provider_token_exchange_injects_bearer_header() { let _gateway_token = start_gateway_token_endpoint(token_port).await; let _target = start_protected_target(target_port).await; - run_cli(&[ - "settings", - "set", - "--global", - "--key", - "providers_v2_enabled", - "--value", - "true", - "--yes", - ]) - .await - .expect("enable providers v2"); - run_cli_ignore_error(&["provider", "delete", &provider_name, "--yes"]).await; run_cli_ignore_error(&["provider", "profile", "delete", &profile_type, "--yes"]).await; diff --git a/e2e/rust/tests/workspace_lifecycle.rs b/e2e/rust/tests/workspace_lifecycle.rs index fa1d2cb2c9..2fe946389e 100644 --- a/e2e/rust/tests/workspace_lifecycle.rs +++ b/e2e/rust/tests/workspace_lifecycle.rs @@ -85,9 +85,9 @@ async fn workspace_full_crud_lifecycle() { "--name", PROVIDER, "--type", - "generic", + "openai", "--credential", - "TOKEN=test-value", + "OPENAI_API_KEY=test-value", "--workspace", WORKSPACE, ]) @@ -206,9 +206,9 @@ async fn workspace_terminating_rejects_creates() { "--name", PROVIDER_TERM, "--type", - "generic", + "openai", "--credential", - "TOKEN=test-value", + "OPENAI_API_KEY=test-value", "--workspace", WORKSPACE_TERM, ]) @@ -239,9 +239,9 @@ async fn workspace_terminating_rejects_creates() { "--name", "should-fail", "--type", - "generic", + "openai", "--credential", - "TOKEN=test-value", + "OPENAI_API_KEY=test-value", "--workspace", WORKSPACE_TERM, ]) diff --git a/examples/agent-driven-policy-management/demo.sh b/examples/agent-driven-policy-management/demo.sh index 36bef4ded6..b123c66ad2 100755 --- a/examples/agent-driven-policy-management/demo.sh +++ b/examples/agent-driven-policy-management/demo.sh @@ -173,20 +173,6 @@ cleanup() { fi fi - # Restore the providers_v2_enabled setting to what it was before this - # run. The demo opts in to v2 composition so provider profiles - # contribute to the effective policy; restore so the host's broader - # workflow isn't affected. - if [[ -n "${PRIOR_PROVIDERS_V2_FLAG:-}" ]]; then - if [[ "$PRIOR_PROVIDERS_V2_FLAG" == "(unset)" ]]; then - "$OPENSHELL_BIN" settings delete --global --key providers_v2_enabled --yes \ - >/dev/null 2>&1 || true - else - "$OPENSHELL_BIN" settings set --global --key providers_v2_enabled \ - --value "$PRIOR_PROVIDERS_V2_FLAG" --yes >/dev/null 2>&1 || true - fi - fi - if [[ $status -eq 0 ]]; then rm -rf "$TMP_DIR" else @@ -347,7 +333,7 @@ create_providers() { "$OPENSHELL_BIN" provider create \ --name "$DEMO_CODEX_PROVIDER_NAME" \ - --type generic \ + --type codex \ --credential CODEX_AUTH_ACCESS_TOKEN \ --credential CODEX_AUTH_REFRESH_TOKEN \ --credential CODEX_AUTH_ACCOUNT_ID >/dev/null @@ -355,7 +341,7 @@ create_providers() { "$OPENSHELL_BIN" provider create \ --name "$DEMO_GITHUB_PROVIDER_NAME" \ --type github \ - --credential DEMO_GITHUB_TOKEN >/dev/null + --credential "GITHUB_TOKEN=$DEMO_GITHUB_TOKEN" >/dev/null info "providers created (codex, github) — credentials injected as env vars only" } @@ -631,19 +617,6 @@ enable_agent_proposals() { || fail "could not enable agent_policy_proposals_enabled globally" } -enable_providers_v2() { - # Providers-v2 composition is behind a global flag. The demo opts in - # so provider profiles (codex, github) contribute to the effective - # policy via composition. Cleanup restores the prior value. - local prior - prior="$("$OPENSHELL_BIN" settings get --global --json 2>/dev/null \ - | jq -r '.settings.providers_v2_enabled // empty | tostring | select(. == "true" or . == "false")')" - PRIOR_PROVIDERS_V2_FLAG="${prior:-(unset)}" - "$OPENSHELL_BIN" settings set --global \ - --key providers_v2_enabled --value true --yes >/dev/null \ - || fail "could not enable providers_v2_enabled globally" -} - main() { validate_env @@ -653,7 +626,6 @@ main() { render_payload create_providers enable_agent_proposals - enable_providers_v2 show_run_summary diff --git a/examples/aws-s3-sts.md b/examples/aws-s3-sts.md index e622e71117..ed0c204d6b 100644 --- a/examples/aws-s3-sts.md +++ b/examples/aws-s3-sts.md @@ -129,9 +129,6 @@ In a separate terminal: ```shell export OPENSHELL_BASE_URL=http://localhost:18080 -# Enable provider v2 (required for STS) -openshell settings set --global --key providers_v2_enabled --value true --yes - # Create the provider with the aws-s3 profile. All three credentials are # gateway-minted via STS, so no static credential is needed. openshell provider create --name s3-test --type aws-s3 --runtime-credentials diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index 88610cf1ee..feaa42df05 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -434,7 +434,6 @@ configure_gateway() { --gateway-endpoint "$GATEWAY_ENDPOINT" ) - run_setup_step "enabling provider profile policy composition" "${CLI[@]}" settings set --global --key providers_v2_enabled --value true --yes wait_for_profile "github" wait_for_profile "slack" } diff --git a/examples/governance-interceptor/src/tests.rs b/examples/governance-interceptor/src/tests.rs index 4575fe2ac8..1df9c25727 100644 --- a/examples/governance-interceptor/src/tests.rs +++ b/examples/governance-interceptor/src/tests.rs @@ -827,7 +827,7 @@ fn policy_update_and_merge_are_denied() { .evaluate_inner(&evaluation( "UpdateConfig", GatewayInterceptorPhase::Validate, - json!({"global": true, "settingKey": "providers_v2_enabled"}), + json!({"global": true, "settingKey": "agent_policy_proposals_enabled"}), )) .unwrap(); assert!(settings_update.allowed); diff --git a/examples/multi-agent-notepad/demo.sh b/examples/multi-agent-notepad/demo.sh index e56d303c17..0d68b3cdbf 100755 --- a/examples/multi-agent-notepad/demo.sh +++ b/examples/multi-agent-notepad/demo.sh @@ -143,15 +143,15 @@ create_providers() { "$OPENSHELL_BIN" provider create \ --name "$DEMO_CODEX_PROVIDER_NAME" \ - --type generic \ + --type codex \ --credential CODEX_AUTH_ACCESS_TOKEN \ --credential CODEX_AUTH_REFRESH_TOKEN \ --credential CODEX_AUTH_ACCOUNT_ID >/dev/null "$OPENSHELL_BIN" provider create \ --name "$DEMO_GITHUB_PROVIDER_NAME" \ - --type generic \ - --credential DEMO_GITHUB_TOKEN >/dev/null + --type github \ + --credential "GITHUB_TOKEN=$DEMO_GITHUB_TOKEN" >/dev/null } run_sandbox() { diff --git a/examples/spiffe-token-exchange-demo/README.md b/examples/spiffe-token-exchange-demo/README.md index bd0ca44a89..89ddc09e69 100644 --- a/examples/spiffe-token-exchange-demo/README.md +++ b/examples/spiffe-token-exchange-demo/README.md @@ -39,7 +39,6 @@ When a sandbox curls `alpha-exchange` or `beta-exchange`: - Gateway and supervisor access to SPIRE OIDC/JWKS discovery. - OpenShell configured with the Kubernetes ServiceAccount supervisor bootstrap path. -- `providers_v2_enabled=true` on the target gateway. - Local `curl`, `python3`, `openssl`, `nc`, `kubectl`, and `openshell`. - A registered and logged-in CLI gateway. The script uses `GATEWAY_NAME`, then `OPENSHELL_GATEWAY`, then the active OpenShell gateway selection. @@ -129,9 +128,6 @@ Then run: ```bash export GATEWAY=https://127.0.0.1:8097 -openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" settings set \ - --global --key providers_v2_enabled --value true --yes - openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" provider profile import \ -f "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/provider-profile.yaml" diff --git a/examples/spiffe-token-exchange-demo/demo.sh b/examples/spiffe-token-exchange-demo/demo.sh index e50f989454..a123f554ab 100755 --- a/examples/spiffe-token-exchange-demo/demo.sh +++ b/examples/spiffe-token-exchange-demo/demo.sh @@ -204,7 +204,6 @@ SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-t "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" settings set --global --key providers_v2_enabled --value true --yes run "${OS[@]}" provider profile lint -f "$PROFILE_FILE" run "${OS[@]}" provider profile import -f "$PROFILE_FILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" diff --git a/examples/spiffe-token-exchange-demo/podman/demo.sh b/examples/spiffe-token-exchange-demo/podman/demo.sh index 0c943c15f6..6b5804a523 100755 --- a/examples/spiffe-token-exchange-demo/podman/demo.sh +++ b/examples/spiffe-token-exchange-demo/podman/demo.sh @@ -641,7 +641,6 @@ SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-t "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" settings set --global --key providers_v2_enabled --value true --yes run "${OS[@]}" provider profile lint -f "$RENDERED_PROFILE" run "${OS[@]}" provider profile import -f "$RENDERED_PROFILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" diff --git a/examples/spiffe-token-grant-demo/README.md b/examples/spiffe-token-grant-demo/README.md index cb622ccbae..df4597c666 100644 --- a/examples/spiffe-token-grant-demo/README.md +++ b/examples/spiffe-token-grant-demo/README.md @@ -24,7 +24,6 @@ request. - SPIRE enabled for provider token grants. - OpenShell configured with the Kubernetes ServiceAccount supervisor bootstrap path. Gateway end-user OIDC is not required for this demo. -- `providers_v2_enabled=true` on the target gateway. For the Helm dev environment, deploy with the SPIRE releases and `ci/values-spire.yaml` enabled in `deploy/helm/openshell/skaffold.yaml`. @@ -60,9 +59,6 @@ Then run: export XDG_CONFIG_HOME="$(mktemp -d)" export GATEWAY=http://127.0.0.1:8097 -openshell --gateway-endpoint "$GATEWAY" settings set \ - --global --key providers_v2_enabled --value true --yes - openshell --gateway-endpoint "$GATEWAY" provider profile import \ -f examples/spiffe-token-grant-demo/provider-profile.yaml diff --git a/examples/spiffe-token-grant-demo/demo.sh b/examples/spiffe-token-grant-demo/demo.sh index f148d869fd..8013755a7d 100755 --- a/examples/spiffe-token-grant-demo/demo.sh +++ b/examples/spiffe-token-grant-demo/demo.sh @@ -111,7 +111,6 @@ wait_for_port_forward "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" settings set --global --key providers_v2_enabled --value true --yes run "${OS[@]}" provider profile lint -f "$PROFILE_FILE" run "${OS[@]}" provider profile import -f "$PROFILE_FILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --runtime-credentials diff --git a/proto/openshell.proto b/proto/openshell.proto index 246fe0626f..2c99d62676 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -364,9 +364,8 @@ service OpenShell { }; } - // Get gateway-global settings (read-only feature flags; any authenticated - // user may read these so the CLI and TUI can discover capabilities like - // providers_v2_enabled without requiring Platform Admin). + // Get gateway-global settings (read-only runtime configuration; any + // authenticated user may read these without requiring Platform Admin). // // Scope-only (no role): scopes are granted by the IdP at token issuance, // orthogonal to workspace membership. Deployments that enable scope diff --git a/providers/anthropic.yaml b/providers/anthropic.yaml new file mode 100644 index 0000000000..752bff6e4f --- /dev/null +++ b/providers/anthropic.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: anthropic +display_name: Anthropic +description: Anthropic inference API +category: inference +inference_capable: true +credentials: + - name: api_key + description: Anthropic API key + env_vars: [ANTHROPIC_API_KEY] + required: true + auth_style: header + header_name: x-api-key +discovery: + credentials: [api_key] +endpoints: + - host: api.anthropic.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/curl, /usr/local/bin/curl] diff --git a/providers/aws-s3.yaml b/providers/aws-s3.yaml index 699f067ff1..f51ffedb9a 100644 --- a/providers/aws-s3.yaml +++ b/providers/aws-s3.yaml @@ -71,7 +71,6 @@ endpoints: enforcement: enforce credential_signing: sigv4 signing_service: s3 - signing_region: us-east-1 - host: "s3.amazonaws.com" port: 443 protocol: rest @@ -80,7 +79,6 @@ endpoints: enforcement: enforce credential_signing: sigv4 signing_service: s3 - signing_region: us-east-1 - host: "*.s3.*.amazonaws.com" port: 443 protocol: rest diff --git a/providers/openai.yaml b/providers/openai.yaml new file mode 100644 index 0000000000..fe09cb0ed4 --- /dev/null +++ b/providers/openai.yaml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: openai +display_name: OpenAI +description: OpenAI inference API +category: inference +inference_capable: true +credentials: + - name: api_key + description: OpenAI API key + env_vars: [OPENAI_API_KEY] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: api.openai.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/curl, /usr/local/bin/curl] diff --git a/scripts/agents/gator/README.md b/scripts/agents/gator/README.md index 3bdb4f490a..c64bb068f9 100644 --- a/scripts/agents/gator/README.md +++ b/scripts/agents/gator/README.md @@ -34,7 +34,7 @@ The launcher: - Selects the requested harness and bakes the common runtime into the immutable sandbox payload. - For `--harness codex`, imports `providers/codex-gator.yaml`, creates or updates the `codex-gator` provider from `$HOME/.codex/auth.json`, and stores the refresh token as gateway-only refresh material. - For `--harness codex`, configures gateway-managed refresh for `CODEX_AUTH_ACCESS_TOKEN` and rotates it before launching the sandbox. -- Enables `providers_v2_enabled`, `agent_policy_proposals_enabled`, and `proposal_approval_mode=auto` at gateway scope. +- Enables `agent_policy_proposals_enabled` and `proposal_approval_mode=auto` at gateway scope. - Uses the gator image policy copied to `/etc/openshell/policy.yaml`. - Installs the gator-specific `gh` wrapper from `gator/bin/gh` as `/usr/local/bin/gh` to fail closed when same-head-SHA history cannot be checked, prevent duplicate dispositions, and require versioned review payloads. - Installs `gator/bin/review-feedback-ledger` as `/usr/local/bin/review-feedback-ledger` so reviews receive tree- and patch-aware scope, prior summaries and findings, resolution state, convergence telemetry, and the three-round Warning budget. diff --git a/scripts/agents/gator/agent.yaml b/scripts/agents/gator/agent.yaml index f4eb082ac1..363cea207f 100644 --- a/scripts/agents/gator/agent.yaml +++ b/scripts/agents/gator/agent.yaml @@ -28,8 +28,6 @@ profile_paths: - providers settings: - - key: providers_v2_enabled - value: true - key: agent_policy_proposals_enabled value: true - key: proposal_approval_mode diff --git a/sdk/go/proto/openshellv1/openshell_grpc.pb.go b/sdk/go/proto/openshellv1/openshell_grpc.pb.go index 663c09aed5..df487d9c6e 100644 --- a/sdk/go/proto/openshellv1/openshell_grpc.pb.go +++ b/sdk/go/proto/openshellv1/openshell_grpc.pb.go @@ -182,9 +182,8 @@ type OpenShellClient interface { DeleteProviderProfile(ctx context.Context, in *DeleteProviderProfileRequest, opts ...grpc.CallOption) (*DeleteProviderProfileResponse, error) // Get sandbox settings by id (called by sandbox entrypoint and poll loop). GetSandboxConfig(ctx context.Context, in *sandboxv1.GetSandboxConfigRequest, opts ...grpc.CallOption) (*sandboxv1.GetSandboxConfigResponse, error) - // Get gateway-global settings (read-only feature flags; any authenticated - // user may read these so the CLI and TUI can discover capabilities like - // providers_v2_enabled without requiring Platform Admin). + // Get gateway-global settings (read-only runtime configuration; any + // authenticated user may read these without requiring Platform Admin). // // Scope-only (no role): scopes are granted by the IdP at token issuance, // orthogonal to workspace membership. Deployments that enable scope @@ -1094,9 +1093,8 @@ type OpenShellServer interface { DeleteProviderProfile(context.Context, *DeleteProviderProfileRequest) (*DeleteProviderProfileResponse, error) // Get sandbox settings by id (called by sandbox entrypoint and poll loop). GetSandboxConfig(context.Context, *sandboxv1.GetSandboxConfigRequest) (*sandboxv1.GetSandboxConfigResponse, error) - // Get gateway-global settings (read-only feature flags; any authenticated - // user may read these so the CLI and TUI can discover capabilities like - // providers_v2_enabled without requiring Platform Admin). + // Get gateway-global settings (read-only runtime configuration; any + // authenticated user may read these without requiring Platform Admin). // // Scope-only (no role): scopes are granted by the IdP at token issuance, // orthogonal to workspace membership. Deployments that enable scope From 97772d2900535c7658eb8b2d0cadddc144ed2e8a Mon Sep 17 00:00:00 2001 From: John Myers Date: Wed, 26 Aug 2026 11:47:55 -0700 Subject: [PATCH 2/6] docs(providers): move profiles into provider navigation Signed-off-by: John Myers --- .../providers-v2.mdx => providers/profiles.mdx} | 6 +++--- docs/reference/policy-schema.mdx | 2 +- docs/sandboxes/manage-providers.mdx | 10 +++++----- docs/sandboxes/manage-sandboxes.mdx | 2 +- docs/sandboxes/policies.mdx | 4 ++-- docs/sandboxes/policy-advisor.mdx | 2 +- fern/docs.yml | 2 ++ 7 files changed, 15 insertions(+), 13 deletions(-) rename docs/{sandboxes/providers-v2.mdx => providers/profiles.mdx} (99%) diff --git a/docs/sandboxes/providers-v2.mdx b/docs/providers/profiles.mdx similarity index 99% rename from docs/sandboxes/providers-v2.mdx rename to docs/providers/profiles.mdx index b4d7aa0596..68dca7baf4 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/providers/profiles.mdx @@ -1,11 +1,11 @@ --- # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -title: "Provider profiles" -sidebar-title: "Provider profiles" +title: "Profiles" +sidebar-title: "Profiles" description: "Use provider profiles to attach credentials, network policy, and refresh metadata to OpenShell sandboxes." keywords: "Generative AI, Cybersecurity, Providers, Provider Profiles, Credentials, Policy, Sandbox" -position: 5 +position: 1 --- Provider profiles turn providers from credential records into profile-backed access bundles. A provider profile describes the credentials, endpoints, binaries, policy rules, and refresh behavior for a provider type. A provider instance stores the concrete credential and config values for one gateway. diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 64942a384b..a0c5d29737 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -216,7 +216,7 @@ global policies that use this field. Network policy admission does not expand the credential boundary unless the endpoint explicitly supplies this binding. OpenShell rejects a request mismatch with HTTP 403 and `credential_endpoint_mismatch`. Refer to [Static Credential Endpoint -Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding). +Binding](/providers/profiles#understand-static-credential-endpoint-binding). This example allows the sandbox to reach Google Cloud Storage and binds the static credentials from the attached `work-gcp` provider to that endpoint: diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 5aff9794bb..0f80610939 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -14,7 +14,7 @@ Create and manage providers that supply credentials to sandboxes. Provider profiles define provider credentials, policy, and refresh behavior. See -[Provider profiles](/sandboxes/providers-v2) for the profile format and custom +[Profiles](/providers/profiles) for the profile format and custom profile workflow. @@ -296,7 +296,7 @@ be resolved. Legacy provider attachment is fixed at sandbox creation time. Provider profiles add `openshell sandbox provider attach` and `openshell sandbox provider detach` for -running sandboxes. See [Provider profiles](/sandboxes/providers-v2#attach-and-detach-providers) +running sandboxes. See [Profiles](/providers/profiles#attach-and-detach-providers) for runtime attach and detach behavior. @@ -345,7 +345,7 @@ profile. Credential resolution requires the proxy to handle the request as HTTP. Raw `tls: skip` and non-HTTP tunnels remain opaque and do not support credential -rewrite. Refer to [Provider profiles](/sandboxes/providers-v2#understand-static-credential-endpoint-binding) +rewrite. Refer to [Profiles](/providers/profiles#understand-static-credential-endpoint-binding) for endpoint matching and migration guidance. ### Supported injection locations @@ -391,8 +391,8 @@ there returns `credential_endpoint_mismatch`. For a custom service, define the credential in a custom provider profile. Put stable endpoints in the profile, or leave the profile endpointless and bind -each concrete provider instance from sandbox policy. Refer to [Provider -Profiles](/sandboxes/providers-v2#provider-profiles) for the profile workflow +each concrete provider instance from sandbox policy. Refer to +[Profiles](/providers/profiles#provider-profiles) for the profile workflow and schema. ## Supported Provider Types diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index e5a27879f3..b4c91ae7ce 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -225,7 +225,7 @@ openshell sandbox create --env API_KEY=sk-test --env DEBUG=1 -- my-agent Variables set with `--env` are available to all processes in the sandbox, including interactive shells and exec commands. -When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [provider](/sandboxes/providers-v2) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. +When an `--env` key looks like a credential — a known provider variable, or a name whose underscore-separated segments include a credential word such as `TOKEN`, `SECRET`, `PASSWORD`, `CREDENTIAL`, `API_KEY`, `ACCESS_KEY`, or `SECRET_KEY` (for example `DB_TOKEN` or `MY_ACCESS_KEY`) — `sandbox create` prints a non-blocking warning. Matching is on whole segments, so unrelated names like `TOKENIZERS_PARALLELISM` or `PASSWORDLESS_LOGIN` do not warn. The agent inside the sandbox can read plain environment values directly, so to hide a secret from the agent, attach it through a [profile-backed provider](/providers/profiles) with `--provider` instead. Suppress the warning with `--no-credential-warnings`. Detection uses the key name only; values are never inspected or printed. You can also set per-command environment variables with `sandbox exec`: diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index a85bbd9b9d..3f57c70b2c 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -367,7 +367,7 @@ Static provider placeholders resolve only when the request host, port, and path also match an endpoint in the provider profile. A sandbox policy allow does not expand that binding. A mismatch returns HTTP 403 with `credential_endpoint_mismatch`. Refer to [Static Credential Endpoint -Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding). +Binding](/providers/profiles#understand-static-credential-endpoint-binding). For example: @@ -607,7 +607,7 @@ Do not fix `credential_endpoint_mismatch` by widening sandbox policy. Export the provider profile with `openshell provider profile export -o yaml`. Update the custom provider profile only when the destination is an intended credential recipient. Refer to [Static Credential Endpoint -Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding) +Binding](/providers/profiles#understand-static-credential-endpoint-binding) for the complete authorization model. For small changes, prefer `openshell policy update` over rewriting the full YAML: diff --git a/docs/sandboxes/policy-advisor.mdx b/docs/sandboxes/policy-advisor.mdx index 0e31a162e3..f0e289a041 100644 --- a/docs/sandboxes/policy-advisor.mdx +++ b/docs/sandboxes/policy-advisor.mdx @@ -195,7 +195,7 @@ Findings are categorical. There is no severity tier. The reviewer reads the cate Before approval, the gateway rebuilds the candidate token from the live base policy, immutable provider rules, and non-secret credential metadata. When that token is unchanged, it reuses the persisted prover result instead of rerunning the prover. When it changes, the gateway evaluates and persists the refreshed candidate, leaves the chunk pending, and requires the reviewer to inspect and approve the new token. Edits and deduplicated resubmissions follow the same path. Merge, policy-shape, provider-composition, credential, or prover failures are shown as application errors and cannot be approved. Security notes flag concerns such as internal or private destinations and `allowed_ips`, wildcard hosts, hostless `allowed_ips`, ephemeral ports, and well-known database or service ports. Any prover finding or security note keeps the chunk pending in auto mode. -The full reasoning model lives in [`crates/openshell-prover/README.md`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-prover/README.md). Provider profiles composed in via [Provider profiles](/sandboxes/providers-v2) are part of the effective policy the prover reasons over. +The full reasoning model lives in [`crates/openshell-prover/README.md`](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-prover/README.md). Provider profiles composed in via [Profiles](/providers/profiles) are part of the effective policy the prover reasons over. ## Review Proposals diff --git a/fern/docs.yml b/fern/docs.yml index c0aeb62cc1..65639bd5d0 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -61,6 +61,8 @@ versions: slug: latest redirects: + - source: "/openshell/latest/sandboxes/providers-v2" + destination: "/openshell/latest/providers/profiles" # Paths are relative to the site root; subpath prefix matches instances + custom-domain. # Legacy HTML URLs used .../path/to/page/index.html; Fern canonical URLs omit index.html. # List explicit /index.html routes before :path*/index.html so empty path segments do not From 7ecac7524f36ab2788fc25a8ead88a6f0662eaa6 Mon Sep 17 00:00:00 2001 From: John Myers Date: Wed, 26 Aug 2026 11:49:12 -0700 Subject: [PATCH 3/6] docs(providers): clarify provider attachment lifecycle Signed-off-by: John Myers --- docs/sandboxes/manage-providers.mdx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 0f80610939..8edada54bc 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -293,13 +293,13 @@ Profile-managed providers also contribute provider-generated network policy entries. Endpoint binding independently limits where each static credential can be resolved. - -Legacy provider attachment is fixed at sandbox creation time. Provider profiles add -`openshell sandbox provider attach` and `openshell sandbox provider detach` for -running sandboxes. See [Profiles](/providers/profiles#attach-and-detach-providers) -for runtime attach and detach behavior. + +Use `--provider` to attach providers when creating a sandbox. To change the +providers attached to a running sandbox, use `openshell sandbox provider attach` +and `openshell sandbox provider detach`. See +[Profiles](/providers/profiles#attach-and-detach-providers) for details. - + ### Auto-Discovery Shortcut From 86a7222e1614b7817a56bfa62da1c1bbfec8e3e8 Mon Sep 17 00:00:00 2001 From: John Myers Date: Wed, 26 Aug 2026 15:12:30 -0700 Subject: [PATCH 4/6] fix(tui): scroll provider profile picker Signed-off-by: John Myers --- .../openshell-tui/src/ui/create_provider.rs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index cb39bff341..c64fe0bef5 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -9,6 +9,7 @@ use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph}; use crate::app::{App, CreateProviderPhase, ProviderKeyField, UpdateProviderField}; use indexmap::IndexMap; +use std::ops::Range; use super::centered_rect; @@ -76,10 +77,17 @@ fn draw_select_type( chunks[0], ); + let visible_range = visible_type_range( + form.types.len(), + form.type_cursor, + usize::from(chunks[2].height), + ); let lines: Vec> = form .types .iter() .enumerate() + .skip(visible_range.start) + .take(visible_range.len()) .map(|(i, ty)| { let is_cursor = i == form.type_cursor; let marker = if is_cursor { ">" } else { " " }; @@ -95,6 +103,14 @@ fn draw_select_type( let hint = Line::from(vec![ Span::styled("[j/k]", t.key_hint), Span::styled(" Navigate ", t.muted), + Span::styled( + format!( + "[{}/{}] ", + form.type_cursor.saturating_add(1).min(form.types.len()), + form.types.len() + ), + t.muted, + ), Span::styled("[Enter]", t.key_hint), Span::styled(" Select ", t.muted), Span::styled("[Esc]", t.key_hint), @@ -103,6 +119,19 @@ fn draw_select_type( frame.render_widget(Paragraph::new(hint), chunks[4]); } +fn visible_type_range(total: usize, cursor: usize, visible_rows: usize) -> Range { + if total == 0 || visible_rows == 0 { + return 0..0; + } + + let visible_rows = visible_rows.min(total); + let cursor = cursor.min(total - 1); + let start = cursor + .saturating_sub(visible_rows - 1) + .min(total - visible_rows); + start..start + visible_rows +} + // --------------------------------------------------------------------------- // Phase 2: Choose method (autodetect vs manual) // --------------------------------------------------------------------------- @@ -1094,3 +1123,23 @@ fn render_status( ); } } + +#[cfg(test)] +mod tests { + use super::visible_type_range; + + #[test] + fn type_list_window_follows_cursor_past_visible_rows() { + assert_eq!(visible_type_range(25, 0, 10), 0..10); + assert_eq!(visible_type_range(25, 9, 10), 0..10); + assert_eq!(visible_type_range(25, 10, 10), 1..11); + assert_eq!(visible_type_range(25, 24, 10), 15..25); + } + + #[test] + fn type_list_window_handles_short_and_empty_lists() { + assert_eq!(visible_type_range(3, 2, 10), 0..3); + assert_eq!(visible_type_range(0, 0, 10), 0..0); + assert_eq!(visible_type_range(3, 2, 0), 0..0); + } +} From e438dcf8e4978a3b0c7fb33ee0a28a17f3a5dfe5 Mon Sep 17 00:00:00 2001 From: John Myers Date: Wed, 26 Aug 2026 16:43:34 -0700 Subject: [PATCH 5/6] fix(providers): honor profile credential semantics Signed-off-by: John Myers --- crates/openshell-providers/src/profiles.rs | 40 ++++ crates/openshell-server/src/grpc/provider.rs | 141 ++++++++++++- crates/openshell-tui/src/app.rs | 190 ++++++++++++++++-- crates/openshell-tui/src/lib.rs | 1 + .../openshell-tui/src/ui/create_provider.rs | 40 ++-- 5 files changed, 372 insertions(+), 40 deletions(-) diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 6ee7763799..a1f9f130a1 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -730,6 +730,21 @@ fn is_u64_zero(value: &u64) -> bool { } impl CredentialProfile { + /// Keys accepted when storing this credential on a provider. + /// + /// Workload-injectable credentials use their declared environment aliases. + /// Broker-only credentials have no environment aliases and use their + /// logical profile name instead. + #[must_use] + pub fn accepted_stored_keys(&self) -> Vec<&str> { + if self.env_vars.is_empty() { + let name = self.name.trim(); + return (!name.is_empty()).then_some(name).into_iter().collect(); + } + + self.env_vars.iter().map(String::as_str).collect() + } + #[must_use] pub fn is_runtime_resolvable(&self) -> bool { self.token_grant.is_some() @@ -3070,6 +3085,31 @@ mod tests { ); } + #[test] + fn accepted_stored_keys_use_logical_name_for_broker_only_credentials() { + let profile = parse_profile_yaml( + r" +id: token-exchange +display_name: Token Exchange +credentials: + - name: subject_token + required: true + - name: access_token + env_vars: [ACCESS_TOKEN, ACCESS_TOKEN_FALLBACK] +", + ) + .expect("profile"); + + assert_eq!( + profile.credentials[0].accepted_stored_keys(), + vec!["subject_token"] + ); + assert_eq!( + profile.credentials[1].accepted_stored_keys(), + vec!["ACCESS_TOKEN", "ACCESS_TOKEN_FALLBACK"] + ); + } + #[test] fn vertex_profile_declares_discovery_and_fallback_token_env_vars() { let profile = builtin_profile("google-vertex-ai"); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 6b16d28416..ad3473ebce 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -3057,12 +3057,12 @@ fn validate_provider_credential_keys( let declared_keys = profile .credentials .iter() - .flat_map(|credential| credential.env_vars.iter()) + .flat_map(|credential| credential.accepted_stored_keys()) .collect::>(); let mut unknown_keys = provider .credentials .keys() - .filter(|key| !declared_keys.contains(key)) + .filter(|key| !declared_keys.contains(key.as_str())) .cloned() .collect::>(); unknown_keys.sort(); @@ -3084,22 +3084,22 @@ fn validate_required_static_credentials( ) -> Result<(), Status> { let mut missing = Vec::new(); for credential in profile.required_static_credentials() { - let supplied = credential.env_vars.iter().any(|key| { + let accepted_keys = credential.accepted_stored_keys(); + let supplied = accepted_keys.iter().any(|key| { provider .credentials - .get(key) + .get(*key) .is_some_and(|value| !value.trim().is_empty()) - || provider.credential_handles.contains_key(key) + || provider.credential_handles.contains_key(*key) || pending_credentials - .get(key) + .get(*key) .is_some_and(|value| !value.trim().is_empty()) }); if !supplied { missing.push( - credential - .env_vars + accepted_keys .first() - .map_or_else(|| credential.name.clone(), Clone::clone), + .map_or_else(|| credential.name.clone(), |key| (*key).to_string()), ); } } @@ -4846,6 +4846,7 @@ mod tests { ListProviderProfilesRequest, ListProvidersRequest, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderCredentialRefresh, ProviderCredentialRefreshMaterial, ProviderCredentialTokenGrant, ProviderCredentialTokenGrantAudienceOverride, + ProviderCredentialTokenGrantSubjectToken, ProviderCredentialTokenGrantType, ProviderProfile, ProviderProfileCategory, ProviderProfileCredential, ProviderProfileImportItem, RotateProviderCredentialRequest, Sandbox, SandboxPolicy, SandboxSpec, StoredProviderProfile, UpdateProviderProfilesRequest, UpdateProviderRequest, @@ -4870,6 +4871,40 @@ mod tests { assert!(!is_valid_env_key("X;rm -rf /")); } + #[test] + fn create_validation_accepts_broker_only_credential_by_logical_name() { + let profile = ProviderTypeProfile::from_proto(&ProviderProfile { + id: "token-exchange".to_string(), + credentials: vec![ + ProviderProfileCredential { + name: "subject_token".to_string(), + required: true, + ..Default::default() + }, + ProviderProfileCredential { + name: "access_token".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "subject_token".to_string(), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }, + ], + ..Default::default() + }); + let provider = Provider { + credentials: HashMap::from([("subject_token".to_string(), "test-token".to_string())]), + ..Default::default() + }; + + validate_provider_create_credentials(&profile, &provider).unwrap(); + } + #[test] fn telemetry_provider_profile_maps_unknown_to_custom() { assert_eq!( @@ -8345,6 +8380,94 @@ mod tests { assert_eq!(result.get("OPENAI_API_KEY"), Some(&"sk-test".to_string())); } + #[tokio::test] + async fn handle_create_provider_accepts_broker_only_subject_token() { + let mut state = test_server_state().await; + let config = state + .config + .clone() + .with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let state_mut = Arc::get_mut(&mut state).unwrap(); + state_mut.config = config; + state_mut.credentials = credentials; + + let mut profile = custom_profile("spiffe-token-exchange-demo"); + profile.credentials = vec![ + ProviderProfileCredential { + name: "subject_token".to_string(), + description: "Broker-only subject token".to_string(), + required: true, + ..Default::default() + }, + ProviderProfileCredential { + name: "access_token".to_string(), + required: false, + auth_style: "bearer".to_string(), + header_name: "Authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + grant_type: ProviderCredentialTokenGrantType::TokenExchange as i32, + token_endpoint: "https://issuer.example.com/token".to_string(), + jwt_svid_audience: "https://issuer.example.com".to_string(), + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + .to_string(), + subject_token: Some(ProviderCredentialTokenGrantSubjectToken { + source: "provider_credential".to_string(), + credential: "subject_token".to_string(), + subject_token_type: "urn:ietf:params:oauth:token-type:access_token" + .to_string(), + }), + ..Default::default() + }), + ..Default::default() + }, + ]; + profile.endpoints = vec![NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + allow_uninspected_credentials: true, + ..Default::default() + }]; + let imported = handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "provider-profile.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(imported.imported, "diagnostics: {:?}", imported.diagnostics); + + handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "exchange", + "spiffe-token-exchange-demo", + "subject_token", + "test-token", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap(); + + let stored: Provider = state + .store + .get_message_by_name("default", "exchange") + .await + .unwrap() + .unwrap(); + assert!(stored.credentials.is_empty()); + assert!(stored.credential_handles.contains_key("subject_token")); + } + #[tokio::test] async fn handle_update_provider_rejects_user_supplied_credential_handles() { let state = test_server_state().await; diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index 1909caa18a..e4a3181ebc 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -392,8 +392,10 @@ pub struct CreateProviderForm { pub generic_value: String, /// Which field is focused in the key entry form. pub key_field: ProviderKeyField, - /// True when the selected profile has no credential environment variables. + /// True when the selected profile has no accepted stored credential keys. pub is_generic: bool, + /// True when the selected profile permits creation without stored credentials. + pub allows_empty_credentials: bool, /// Status message (errors, validation). pub status: Option, /// Warning shown at top of `EnterKey` modal (e.g. autodetect failure). @@ -2384,6 +2386,18 @@ impl App { // ------------------------------------------------------------------ fn open_create_provider_form(&mut self) { + let types = self.available_provider_profile_types(); + + self.create_provider_form = Some(CreateProviderForm { + types, + status: self.provider_profiles.is_empty().then(|| { + "Provider profiles unavailable. Wait for refresh, then retry.".to_string() + }), + ..CreateProviderForm::default() + }); + } + + fn available_provider_profile_types(&self) -> Vec { let mut types = self .provider_profiles .iter() @@ -2391,11 +2405,24 @@ impl App { .collect::>(); types.sort(); types.dedup(); + types + } - self.create_provider_form = Some(CreateProviderForm { - types, - ..CreateProviderForm::default() - }); + pub(crate) fn sync_create_provider_types(&mut self) { + let types = self.available_provider_profile_types(); + let Some(form) = self.create_provider_form.as_mut() else { + return; + }; + if form.phase != CreateProviderPhase::SelectType { + return; + } + + form.types = types; + form.type_cursor = form.type_cursor.min(form.types.len().saturating_sub(1)); + form.status = form + .types + .is_empty() + .then(|| "Provider profiles unavailable. Wait for refresh, then retry.".to_string()); } fn handle_create_provider_key(&mut self, key: KeyEvent) { @@ -2415,7 +2442,13 @@ impl App { form.type_cursor = form.type_cursor.saturating_sub(1); } KeyCode::Enter => { - let selected = form.types[form.type_cursor].clone(); + let Some(selected) = form.types.get(form.type_cursor).cloned() else { + form.status = Some( + "Provider profiles unavailable. Wait for refresh, then retry." + .to_string(), + ); + return; + }; let Some(profile) = self .provider_profiles .iter() @@ -2426,20 +2459,31 @@ impl App { return; }; let profile = ProviderTypeProfile::from_proto(&profile); - let env_vars = profile.credential_env_vars(); - form.is_generic = env_vars.is_empty(); - - // Populate credential rows from all known env vars. - form.credentials = env_vars + let mut credential_keys = Vec::new(); + for key in profile + .credentials .iter() - .map(|s| (s.to_string(), String::new())) + .flat_map(|credential| credential.accepted_stored_keys()) + { + if !credential_keys.iter().any(|existing| existing == key) { + credential_keys.push(key.to_string()); + } + } + form.is_generic = credential_keys.is_empty(); + form.allows_empty_credentials = profile.allows_empty_provider_credentials(); + + // Populate credential rows from all accepted storage keys, + // including logical names for broker-only credentials. + form.credentials = credential_keys + .into_iter() + .map(|key| (key, String::new())) .collect(); form.cred_cursor = 0; // Auto-generate a unique name. form.name = unique_provider_name(&selected, &self.provider_names); - if form.is_generic { + if form.credentials.is_empty() { // Credential-less profiles can be created directly. form.discovered_credentials = Some(HashMap::new()); form.phase = CreateProviderPhase::Creating; @@ -2488,7 +2532,12 @@ impl App { form.phase = CreateProviderPhase::EnterKey; form.key_field = ProviderKeyField::Name; form.warning = Some( - "No credentials found in environment. Enter manually.".to_string(), + if form.allows_empty_credentials { + "No credentials found in environment. Enter credentials or submit without them." + } else { + "No credentials found in environment. Enter manually." + } + .to_string(), ); form.status = None; } @@ -2812,7 +2861,7 @@ impl App { creds.insert(name.clone(), value.clone()); } } - if creds.is_empty() { + if creds.is_empty() && !form.allows_empty_credentials { form.status = Some("At least one credential is required.".to_string()); return; @@ -3530,6 +3579,117 @@ mod tests { ) } + fn provider_profile( + id: &str, + credentials: Vec, + ) -> openshell_core::proto::ProviderProfile { + openshell_core::proto::ProviderProfile { + id: id.to_string(), + credentials, + ..Default::default() + } + } + + fn credential( + name: &str, + env_vars: &[&str], + required: bool, + runtime_resolvable: bool, + ) -> openshell_core::proto::ProviderProfileCredential { + openshell_core::proto::ProviderProfileCredential { + name: name.to_string(), + env_vars: env_vars.iter().map(ToString::to_string).collect(), + required, + token_grant: runtime_resolvable.then(Default::default), + ..Default::default() + } + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + #[tokio::test] + async fn create_provider_enter_with_empty_profile_catalog_is_recoverable() { + let mut app = test_app(); + app.open_create_provider_form(); + + app.handle_create_provider_key(key(KeyCode::Enter)); + + let form = app + .create_provider_form + .as_ref() + .expect("form remains open"); + assert_eq!(form.phase, CreateProviderPhase::SelectType); + assert!( + form.status + .as_deref() + .is_some_and(|status| status.contains("profiles unavailable")) + ); + assert!(!app.pending_provider_create); + + app.provider_profiles = vec![provider_profile("recovered", Vec::new())]; + app.sync_create_provider_types(); + let form = app + .create_provider_form + .as_ref() + .expect("form remains open"); + assert_eq!(form.types, vec!["recovered"]); + assert!(form.status.is_none()); + } + + #[tokio::test] + async fn create_provider_accepts_empty_credentials_when_profile_allows_them() { + for profile in [ + provider_profile("policy-only", Vec::new()), + provider_profile( + "optional-static", + vec![credential("api_key", &["API_KEY"], false, false)], + ), + provider_profile( + "runtime-token", + vec![credential("access_token", &["ACCESS_TOKEN"], true, true)], + ), + ] { + let mut app = test_app(); + app.provider_profiles = vec![profile]; + app.open_create_provider_form(); + app.handle_create_provider_key(key(KeyCode::Enter)); + + let form = app.create_provider_form.as_mut().expect("form"); + if form.phase != CreateProviderPhase::Creating { + form.phase = CreateProviderPhase::EnterKey; + form.key_field = ProviderKeyField::Submit; + app.handle_create_provider_key(key(KeyCode::Enter)); + } + + let form = app.create_provider_form.as_ref().expect("form"); + assert_eq!(form.phase, CreateProviderPhase::Creating); + assert_eq!(form.discovered_credentials, Some(HashMap::new())); + assert!(app.pending_provider_create); + } + } + + #[tokio::test] + async fn create_provider_uses_logical_key_for_broker_only_credential() { + let mut app = test_app(); + app.provider_profiles = vec![provider_profile( + "token-exchange", + vec![credential("subject_token", &[], true, false)], + )]; + app.open_create_provider_form(); + + app.handle_create_provider_key(key(KeyCode::Enter)); + + let form = app.create_provider_form.as_ref().expect("form"); + assert_eq!(form.phase, CreateProviderPhase::ChooseMethod); + assert_eq!( + form.credentials, + vec![("subject_token".to_string(), String::new())] + ); + assert!(!form.allows_empty_credentials); + } + #[tokio::test] async fn denied_platform_state_is_cleared_and_reprobed_after_gateway_switch() { let mut app = test_app(); diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index ff71c95ddf..47b107aff6 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -2144,6 +2144,7 @@ async fn refresh_providers(app: &mut App) { } } } + app.sync_create_provider_types(); app.provider_count = providers.len(); app.provider_entries = providers diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index c64fe0bef5..b68ddf5ea9 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -82,22 +82,30 @@ fn draw_select_type( form.type_cursor, usize::from(chunks[2].height), ); - let lines: Vec> = form - .types - .iter() - .enumerate() - .skip(visible_range.start) - .take(visible_range.len()) - .map(|(i, ty)| { - let is_cursor = i == form.type_cursor; - let marker = if is_cursor { ">" } else { " " }; - let style = if is_cursor { t.accent } else { t.text }; - Line::from(vec![ - Span::styled(format!(" {marker} "), style), - Span::styled(ty.as_str(), style), - ]) - }) - .collect(); + let lines: Vec> = if form.types.is_empty() { + vec![Line::from(Span::styled( + form.status + .as_deref() + .unwrap_or("No provider profiles available."), + t.status_warn, + ))] + } else { + form.types + .iter() + .enumerate() + .skip(visible_range.start) + .take(visible_range.len()) + .map(|(i, ty)| { + let is_cursor = i == form.type_cursor; + let marker = if is_cursor { ">" } else { " " }; + let style = if is_cursor { t.accent } else { t.text }; + Line::from(vec![ + Span::styled(format!(" {marker} "), style), + Span::styled(ty.as_str(), style), + ]) + }) + .collect() + }; frame.render_widget(Paragraph::new(lines), chunks[2]); let hint = Line::from(vec![ From dc5184a4574fb76115e8c32982a0c02a1ddd8b57 Mon Sep 17 00:00:00 2001 From: John Myers Date: Wed, 26 Aug 2026 17:33:41 -0700 Subject: [PATCH 6/6] fix(providers): prefer exact profile IDs Signed-off-by: John Myers --- crates/openshell-cli/src/run.rs | 64 +++++-- .../tests/provider_commands_integration.rs | 28 +++ crates/openshell-providers/src/lib.rs | 14 ++ crates/openshell-server/src/grpc/policy.rs | 60 +++++-- crates/openshell-server/src/grpc/provider.rs | 163 ++++++++++++++---- .../src/provider_profile_sources.rs | 84 ++++++++- 6 files changed, 347 insertions(+), 66 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 48cd365579..00239d8c28 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -61,8 +61,8 @@ use openshell_core::settings; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; use openshell_providers::{ ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, discover_from_profile, - normalize_provider_type, parse_profile_json, parse_profile_yaml, profile_to_json, - profile_to_yaml, profiles_to_json, profiles_to_yaml, + normalize_profile_id, normalize_provider_type, parse_profile_json, parse_profile_yaml, + profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, }; use owo_colors::OwoColorize; use std::borrow::Cow; @@ -2606,7 +2606,7 @@ pub async fn ensure_required_providers( configured_names.push(name.clone()); } } else { - let profile_id = normalize_provider_type(name).unwrap_or(name); + let profile_id = name.trim(); let profile = fetch_provider_profile(client, profile_id, workspace) .await .map_err(|_| { @@ -3264,26 +3264,52 @@ async fn fetch_provider_profile( provider_type: &str, workspace: &str, ) -> Result { - let response = client + let requested = provider_type.trim(); + let response = match fetch_provider_profile_exact(client, requested, workspace).await { + Ok(response) => response, + Err(status) if status.code() == Code::NotFound => { + let Some(alias) = normalize_provider_type(requested) + .filter(|alias| normalize_profile_id(requested).as_deref() != Some(*alias)) + else { + return Err(miette::miette!( + "provider profile '{requested}' not found; import a matching profile before using this provider type" + )); + }; + fetch_provider_profile_exact(client, alias, workspace) + .await + .map_err(|fallback_status| { + if fallback_status.code() == Code::NotFound { + miette::miette!( + "provider profile '{requested}' not found; import a matching profile before using this provider type" + ) + } else { + miette::miette!(fallback_status.to_string()) + } + })? + } + Err(status) => return Err(miette::miette!(status.to_string())), + }; + + Ok(response) +} + +async fn fetch_provider_profile_exact( + client: &mut crate::tls::GrpcClient, + provider_type: &str, + workspace: &str, +) -> std::result::Result { + client .get_provider_profile(GetProviderProfileRequest { id: provider_type.to_string(), workspace: workspace.to_string(), }) .await - .map_err(|status| { - if status.code() == Code::NotFound { - miette::miette!( - "provider profile '{provider_type}' not found; import a matching profile before using this provider type" - ) - } else { - miette::miette!(status.to_string()) - } - })?; - - response - .into_inner() - .profile - .ok_or_else(|| miette::miette!("provider profile '{provider_type}' missing from response")) + .and_then(|response| { + response + .into_inner() + .profile + .ok_or_else(|| Status::internal("provider profile missing from response")) + }) } async fn discover_existing_provider_data( @@ -3559,7 +3585,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) -> let mut client = grpc_client(server, tls).await?; - let profile_id = normalize_provider_type(provider_type).unwrap_or_else(|| provider_type.trim()); + let profile_id = provider_type.trim(); if profile_id.is_empty() { return Err(miette::miette!("provider type is required")); } diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 9a5296e0c5..d980f1b360 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -2429,6 +2429,34 @@ async fn provider_create_sends_inline_credentials() { ); } +#[tokio::test] +async fn provider_create_prefers_exact_imported_alias_profile() { + let ts = run_server().await; + install_test_profile(&ts, "gh", "GITHUB_TOKEN").await; + + run::provider_create_with_options(run::ProviderCreateOptions { + server: &ts.endpoint, + name: "enterprise-github", + provider_type: "gh", + credentials: &["GITHUB_TOKEN=test-token".to_string()], + credential_source: run::ProviderCreateCredentialSource::ExplicitCredentials, + config: &[], + workspace: "default", + profile_workspace: "default", + tls: &ts.tls, + }) + .await + .expect("create provider from exact imported alias profile"); + + let stored = ts.state.providers.lock().await; + let provider = stored.get("enterprise-github").expect("provider"); + assert_eq!(provider.r#type, "gh"); + assert_eq!( + provider.credentials.get("GITHUB_TOKEN").map(String::as_str), + Some("test-token") + ); +} + #[tokio::test] async fn provider_create_rejects_combined_from_existing_and_credentials() { let ts = run_server().await; diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index 4e53a99dff..c5112ee3f0 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -110,6 +110,20 @@ impl ProviderRegistry { plugin.inject_env(provider, env); } } + + /// Inject config for an already-resolved profile ID without alias + /// normalization. This prevents an exact custom profile whose ID resembles + /// a legacy alias from selecting an unrelated built-in compatibility plugin. + pub fn inject_env_for_profile_id( + &self, + provider: &Provider, + profile_id: &str, + env: &mut HashMap, + ) { + if let Some(plugin) = self.get(profile_id) { + plugin.inject_env(provider, env); + } + } } #[must_use] diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index acefd9d9f4..0978d8bc59 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -71,7 +71,6 @@ use openshell_prover::{ registry::load_embedded_binary_registry, report::finding_shorthand, }; -use openshell_providers::normalize_provider_type; use prost::Message; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -809,10 +808,9 @@ async fn build_credential_set_for_sandbox_with_catalog( }; let provider_type = provider.r#type.trim(); - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); let Some(profile) = super::provider::get_provider_type_profile_for_scope( catalog, - profile_id, + provider_type, &provider.profile_workspace, ) else { warn!( @@ -1777,11 +1775,9 @@ fn validate_policy_credential_binding_context( "credential_binding references provider '{provider_name}', but that provider is not attached to the sandbox" )) })?; - let profile_id = normalize_provider_type(&record.provider.r#type) - .unwrap_or(record.provider.r#type.as_str()); let profile = super::provider::get_provider_type_profile_for_scope( catalog, - profile_id, + &record.provider.r#type, &record.provider.profile_workspace, ) .ok_or_else(|| { @@ -1854,11 +1850,9 @@ fn signing_profile_for_record( catalog: &EffectiveProviderProfileCatalog, record: &super::provider::ProviderEnvironmentRecord, ) -> Option { - let profile_id = - normalize_provider_type(&record.provider.r#type).unwrap_or(record.provider.r#type.as_str()); super::provider::get_provider_type_profile_for_scope( catalog, - profile_id, + &record.provider.r#type, &record.provider.profile_workspace, ) } @@ -2732,8 +2726,7 @@ fn hash_provider_profile_revision( profile_workspace: &str, hasher: &mut Sha256, ) { - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); - catalog.hash_type_profile_revision_for_scope(profile_id, profile_workspace, hasher); + catalog.hash_type_profile_revision_for_scope(provider_type, profile_workspace, hasher); } #[cfg(test)] @@ -2793,10 +2786,9 @@ async fn provider_policy_context_with_catalog( .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; let provider_type = provider.r#type.trim(); - let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); let Some(profile) = super::provider::get_provider_type_profile_for_scope( catalog, - profile_id, + provider_type, &provider.profile_workspace, ) else { warn!( @@ -8285,6 +8277,48 @@ mod tests { assert_eq!(layers[0].rule.endpoints[0].host, "backdoor.example"); } + #[tokio::test] + async fn provider_policy_layers_prefer_exact_imported_alias_profile() { + let store = test_store().await; + store + .put_message(&test_provider("enterprise-github", "gh")) + .await + .unwrap(); + store + .put_message(&openshell_core::proto::StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-gh".to_string(), + name: "gh".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + profile: Some(openshell_core::proto::ProviderProfile { + id: "gh".to_string(), + display_name: "Enterprise GitHub".to_string(), + endpoints: vec![NetworkEndpoint { + host: "github.enterprise.example".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }), + }) + .await + .unwrap(); + + let layers = + profile_provider_policy_layers(&store, "default", &["enterprise-github".to_string()]) + .await + .unwrap(); + + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].rule.endpoints.len(), 1); + assert_eq!( + layers[0].rule.endpoints[0].host, + "github.enterprise.example" + ); + } + #[tokio::test] #[allow(deprecated)] async fn provider_policy_layers_include_custom_provider_profiles() { diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index ad3473ebce..7c3d4070b0 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1099,10 +1099,11 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin let name = &record.name; let provider = &record.provider; let mut provider_env = HashMap::new(); - let profile_id = - normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let profile = - get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace); + let profile = get_provider_type_profile_for_scope( + catalog, + &provider.r#type, + &provider.profile_workspace, + ); let profile_proto = profile.as_ref().map(ProviderTypeProfile::to_proto); let broker_only_credential_keys = profile_proto .as_ref() @@ -1279,7 +1280,7 @@ pub(super) async fn resolve_provider_environment_from_records_with_policy_bindin // provider's earlier output cannot change how this provider classifies // or populates its own keys. Cross-provider credential/config // collisions have already been rejected by the validation above. - registry.inject_env(provider, &mut provider_env); + inject_provider_plugin_environment(catalog, provider, ®istry, &mut provider_env); for (key, value) in provider_env { env.entry(key).or_insert(value); } @@ -1405,11 +1406,11 @@ fn resolve_dynamic_credentials_from_records( let mut dynamic_creds = HashMap::new(); for record in records { let provider = &record.provider; - let profile_id = - normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - let Some(profile) = - get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace) - else { + let Some(profile) = get_provider_type_profile_for_scope( + catalog, + &provider.r#type, + &provider.profile_workspace, + ) else { continue; }; insert_dynamic_credentials_for_profile( @@ -1812,7 +1813,7 @@ async fn validate_provider_environment_keys_unique_at( &mut seen_plugin_config, &provider_name, active_provider_environment_keys(store, catalog, &provider, now_ms).await?, - provider_plugin_environment_keys(&provider), + provider_plugin_environment_keys(catalog, &provider), )?; dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( catalog, &provider, @@ -1845,7 +1846,7 @@ async fn validate_provider_environment_records_unique_at( now_ms, ) .await?, - provider_plugin_environment_keys(provider), + provider_plugin_environment_keys(catalog, provider), )?; dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( catalog, provider, @@ -1855,12 +1856,33 @@ async fn validate_provider_environment_records_unique_at( Ok(()) } -fn provider_plugin_environment_keys(provider: &Provider) -> Vec { +fn provider_plugin_environment_keys( + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, +) -> Vec { let mut plugin_environment = HashMap::new(); - openshell_providers::ProviderRegistry::new().inject_env(provider, &mut plugin_environment); + let registry = openshell_providers::ProviderRegistry::new(); + inject_provider_plugin_environment(catalog, provider, ®istry, &mut plugin_environment); plugin_environment.into_keys().collect() } +fn inject_provider_plugin_environment( + catalog: &EffectiveProviderProfileCatalog, + provider: &Provider, + registry: &openshell_providers::ProviderRegistry, + environment: &mut HashMap, +) { + if let Some(profile) = + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) + { + registry.inject_env_for_profile_id(provider, &profile.id, environment); + } else { + // Preserve config projection for legacy records when their profile + // source is temporarily unavailable. + registry.inject_env(provider, environment); + } +} + fn validate_provider_environment_key_ownership( seen_credentials: &mut HashMap, seen_plugin_config: &mut HashMap, @@ -1931,9 +1953,8 @@ fn dynamic_token_grant_bindings_for_provider_with_catalog( provider: &Provider, ) -> Vec { let provider_name = provider.object_name().to_string(); - let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); let Some(profile) = - get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace) + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) else { return Vec::new(); }; @@ -2146,8 +2167,7 @@ fn broker_only_provider_credential_keys_for_provider( catalog: &EffectiveProviderProfileCatalog, provider: &Provider, ) -> HashSet { - let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); - get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace) + get_provider_type_profile_for_scope(catalog, &provider.r#type, &provider.profile_workspace) .as_ref() .map(ProviderTypeProfile::to_proto) .map(|profile| broker_only_provider_credential_keys(&profile)) @@ -3027,15 +3047,14 @@ fn resolve_provider_create_profile( if requested_type.is_empty() { return Err(Status::invalid_argument("provider.type is required")); } - let profile_id = normalize_provider_type(requested_type).unwrap_or(requested_type); let profile = get_provider_type_profile_for_scope( catalog, - profile_id, + requested_type, &provider.profile_workspace, ) .ok_or_else(|| { Status::invalid_argument(format!( - "provider profile '{profile_id}' was not found in the requested scope; import a matching profile before creating this provider" + "provider profile '{requested_type}' was not found in the requested scope; import a matching profile before creating this provider" )) })?; provider.r#type.clone_from(&profile.id); @@ -3363,8 +3382,15 @@ async fn profile_attached_sandbox_diagnostics( else { continue; }; - let profile_id = - normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); + let requested_profile_id = normalize_profile_id(&provider.r#type) + .unwrap_or_else(|| provider.r#type.trim().to_string()); + let profile_id = if candidate_profiles.contains_key(&requested_profile_id) { + requested_profile_id + } else { + normalize_provider_type(&provider.r#type) + .filter(|alias| candidate_profiles.contains_key(*alias)) + .map_or(requested_profile_id, str::to_string) + }; let scope_mismatch = (is_platform_scope && !provider.profile_workspace.is_empty()) || (!is_platform_scope && provider.profile_workspace.is_empty()); if scope_mismatch { @@ -3374,7 +3400,7 @@ async fn profile_attached_sandbox_diagnostics( if validate_policy_composition && let Some(profile) = get_provider_type_profile_for_scope( catalog, - profile_id, + &provider.r#type, &provider.profile_workspace, ) { @@ -3386,7 +3412,7 @@ async fn profile_attached_sandbox_diagnostics( } continue; } - if let Some((source, profile)) = candidate_profiles.get(profile_id) { + if let Some((source, profile)) = candidate_profiles.get(&profile_id) { let has_static_credentials = provider .credentials .keys() @@ -3402,7 +3428,7 @@ async fn profile_attached_sandbox_diagnostics( if has_static_credentials && !has_usable_endpoint && !has_policy_binding { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), - profile_id: profile_id.to_string(), + profile_id: profile_id.clone(), field: "endpoints".to_string(), message: format!( "{operation} would leave static provider credentials without an authorized endpoint on sandbox '{sandbox_name}'" @@ -3413,7 +3439,7 @@ async fn profile_attached_sandbox_diagnostics( if has_usable_endpoint && has_policy_binding { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), - profile_id: profile_id.to_string(), + profile_id: profile_id.clone(), field: "endpoints".to_string(), message: format!( "{operation} would give provider '{provider_name}' both profile endpoint bindings and sandbox policy credential bindings on sandbox '{sandbox_name}'" @@ -3432,7 +3458,7 @@ async fn profile_attached_sandbox_diagnostics( rule_name, }); } - let used = (source.clone(), profile_id.to_string()); + let used = (source.clone(), profile_id.clone()); if !imported_profiles_used.contains(&used) { imported_profiles_used.push(used); } @@ -3443,7 +3469,7 @@ async fn profile_attached_sandbox_diagnostics( if validate_policy_composition && let Some(profile) = get_provider_type_profile_for_scope( catalog, - profile_id, + &provider.r#type, &provider.profile_workspace, ) { @@ -3717,14 +3743,16 @@ pub(super) async fn handle_exchange_provider_subject_token( .await .map_err(|e| Status::internal(format!("fetch provider failed: {e}")))? .ok_or_else(|| Status::not_found("provider not found"))?; - let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); let catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) .await?; - let profile = - get_provider_type_profile_for_scope(&catalog, profile_id, &provider.profile_workspace) - .ok_or_else(|| Status::failed_precondition("provider profile not found"))?; + let profile = get_provider_type_profile_for_scope( + &catalog, + &provider.r#type, + &provider.profile_workspace, + ) + .ok_or_else(|| Status::failed_precondition("provider profile not found"))?; let profile_proto = profile.to_proto(); let credential = profile_proto .credentials @@ -8316,6 +8344,75 @@ mod tests { assert_eq!(response.r#type, "gitlab"); } + #[tokio::test] + async fn handle_create_provider_prefers_exact_imported_alias_profile() { + let mut state = test_server_state().await; + let config = state + .config + .clone() + .with_credential_drivers(["test-static"]); + let credentials = crate::credentials::CredentialRuntime::from_config(&config).unwrap(); + let state_mut = Arc::get_mut(&mut state).unwrap(); + state_mut.config = config; + state_mut.credentials = credentials; + + let mut profile = custom_profile("gh"); + profile.credentials = vec![static_credential("token", "GITHUB_TOKEN", true)]; + profile.endpoints = vec![NetworkEndpoint { + host: "github.enterprise.example".to_string(), + port: 443, + allow_uninspected_credentials: true, + ..Default::default() + }]; + let imported = handle_import_provider_profiles( + &state, + authed_request(ImportProviderProfilesRequest { + profiles: vec![ProviderProfileImportItem { + profile: Some(profile), + source: "enterprise-github.yaml".to_string(), + }], + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + assert!(imported.imported, "diagnostics: {:?}", imported.diagnostics); + + let provider = handle_create_provider( + &state, + authed_request(CreateProviderRequest { + provider: Some(provider_with_credential_value( + "enterprise-github", + "gh", + "GITHUB_TOKEN", + "test-token", + )), + workspace: "default".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .provider + .expect("provider"); + + assert_eq!(provider.r#type, "gh"); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + let resolved = get_provider_type_profile_for_scope( + &catalog, + &provider.r#type, + &provider.profile_workspace, + ) + .expect("exact imported profile"); + assert_eq!(resolved.id, "gh"); + assert_eq!(resolved.endpoints[0].host, "github.enterprise.example"); + } + #[tokio::test] async fn handle_create_provider_stores_inline_credentials_with_enabled_driver() { let mut state = test_server_state().await; diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index 1871d99154..8b515c28d7 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -15,7 +15,7 @@ use openshell_gateway_interceptors::{ }; use openshell_providers::{ ProfileValidationDiagnostic, ProviderTypeProfile, builtin_profiles, normalize_profile_id, - validate_profile_set, + normalize_provider_type, validate_profile_set, }; use prost::Message as _; use sha2::{Digest, Sha256}; @@ -448,6 +448,22 @@ impl EffectiveProviderProfileCatalog { &self, id: &str, profile_workspace: &str, + ) -> Option<&ScopedProfileEntry> { + if let Some(entry) = self.exact_scoped_type_profile_for_scope(id, profile_workspace) { + return Some(entry); + } + + let alias = normalize_provider_type(id)?; + if normalize_profile_id(id).as_deref() == Some(alias) { + return None; + } + self.exact_scoped_type_profile_for_scope(alias, profile_workspace) + } + + fn exact_scoped_type_profile_for_scope( + &self, + id: &str, + profile_workspace: &str, ) -> Option<&ScopedProfileEntry> { let id = normalize_profile_id(id)?; let entry = self.profiles.get(&id)?; @@ -1793,4 +1809,70 @@ mod tests { assert!(result.is_some()); assert_eq!(result.unwrap().display_name, "Workspace Anthropic"); } + + #[test] + fn exact_alias_shaped_profile_wins_before_builtin_alias_fallback() { + let mut builtin = profile("github"); + builtin.display_name = "Built-in GitHub".to_string(); + let mut custom = profile("gh"); + custom.display_name = "Enterprise GitHub".to_string(); + + let catalog = build_effective_profiles(vec![ + CollectedProviderProfileSnapshot { + source_id: "builtin".to_string(), + revision: "builtin-v1".to_string(), + profiles: vec![ScopedSnapshotProfile { + scope: ProfileScope::Static, + profile: builtin, + }], + user_managed: false, + allow_empty: false, + }, + CollectedProviderProfileSnapshot { + source_id: "user".to_string(), + revision: "user-v1".to_string(), + profiles: vec![ScopedSnapshotProfile { + scope: ProfileScope::Workspace, + profile: custom, + }], + user_managed: true, + allow_empty: true, + }, + ]) + .unwrap(); + + let exact = catalog + .get_type_profile_for_scope("gh", "default") + .expect("exact custom profile"); + assert_eq!(exact.id, "gh"); + assert_eq!(exact.display_name, "Enterprise GitHub"); + + let builtin = catalog + .get_type_profile_for_scope("github", "default") + .expect("built-in profile"); + assert_eq!(builtin.id, "github"); + } + + #[test] + fn custom_profile_can_reuse_default_id_when_builtin_source_is_not_loaded() { + let mut custom = profile("github"); + custom.display_name = "Private GitHub".to_string(); + let catalog = build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "user".to_string(), + revision: "user-v1".to_string(), + profiles: vec![ScopedSnapshotProfile { + scope: ProfileScope::Workspace, + profile: custom, + }], + user_managed: true, + allow_empty: true, + }]) + .unwrap(); + + let resolved = catalog + .get_type_profile_for_scope("github", "default") + .expect("custom profile reusing unloaded default ID"); + assert_eq!(resolved.id, "github"); + assert_eq!(resolved.display_name, "Private GitHub"); + } }