From 3a39bff6247a9da7516220d02eddebc02ecacbe0 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:25:52 -0700 Subject: [PATCH 1/7] feat(providers): add profile policy composition --- Cargo.lock | 1 + architecture/sandbox-providers.md | 51 ++ crates/openshell-cli/src/main.rs | 7 + crates/openshell-cli/src/run.rs | 75 ++- .../tests/ensure_providers_integration.rs | 18 + .../openshell-cli/tests/mtls_integration.rs | 14 + .../tests/provider_commands_integration.rs | 16 + .../sandbox_create_lifecycle_integration.rs | 14 + .../sandbox_name_fallback_integration.rs | 14 + crates/openshell-policy/src/compose.rs | 143 ++++++ crates/openshell-policy/src/lib.rs | 2 + crates/openshell-providers/src/lib.rs | 12 + crates/openshell-providers/src/profiles.rs | 464 ++++++++++++++++++ crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/src/grpc/mod.rs | 28 +- crates/openshell-server/src/grpc/policy.rs | 129 ++++- crates/openshell-server/src/grpc/provider.rs | 75 ++- .../openshell-server/src/grpc/validation.rs | 2 + crates/openshell-server/src/inference.rs | 6 + .../tests/auth_endpoint_integration.rs | 16 + .../tests/edge_tunnel_auth.rs | 14 + .../tests/multiplex_integration.rs | 14 + .../tests/multiplex_tls_integration.rs | 14 + .../tests/supervisor_relay_integration.rs | 15 + .../tests/ws_tunnel_integration.rs | 14 + crates/openshell-tui/src/lib.rs | 4 + docs/sandboxes/manage-providers.mdx | 16 +- proto/datamodel.proto | 10 + proto/openshell.proto | 52 ++ 29 files changed, 1224 insertions(+), 17 deletions(-) create mode 100644 crates/openshell-policy/src/compose.rs create mode 100644 crates/openshell-providers/src/profiles.rs diff --git a/Cargo.lock b/Cargo.lock index 0e59eb64f..b4101d140 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3359,6 +3359,7 @@ dependencies = [ "openshell-driver-podman", "openshell-ocsf", "openshell-policy", + "openshell-providers", "openshell-router", "petname", "pin-project-lite", diff --git a/architecture/sandbox-providers.md b/architecture/sandbox-providers.md index 088bd7592..e7bdf37dd 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -35,15 +35,63 @@ Provider is defined in `proto/datamodel.proto`: - `type`: canonical provider slug (`claude`, `gitlab`, `github`, etc.) - `credentials`: `map` for secret values - `config`: `map` for non-secret settings +- `profile_id`: provider type profile used for profile-backed policy composition +- `profile_policy_enabled`: explicit opt-in for provider-generated policy entries The gRPC surface is defined in `proto/openshell.proto`: - `CreateProvider` - `GetProvider` - `ListProviders` +- `ListProviderProfiles` +- `GetProviderProfile` - `UpdateProvider` - `DeleteProvider` +## Provider Type Profiles + +Provider type profiles are declarative metadata for provider types. Profiles live in +`crates/openshell-providers` and are exposed through `ListProviderProfiles` and +`GetProviderProfile`. They describe credential names and environment variables, +known network endpoints, expected binaries, category, and whether the provider is +inference-capable. + +Profiles are additive to provider records. A provider record with only `type`, +`credentials`, and `config` remains a legacy credential-only provider. The gateway +does not infer provider-managed network policy from `provider.type` alone. A provider +contributes profile-generated policy only when `profile_policy_enabled` is true and +`profile_id` names a known profile. Existing stored provider records deserialize with +`profile_policy_enabled = false`, so OpenShell upgrades do not silently broaden network +access for existing sandboxes. + +New providers created by current clients can set `profile_id` to the provider type and +enable `profile_policy_enabled`. This makes the compatibility boundary explicit while +keeping the normal CLI experience simple. + +### Provider Policy Composition + +Sandbox policy fetch uses just-in-time composition: + +```text +effective policy = base/static policy + enabled provider profile rules + user rules +``` + +The composed policy is derived data. The sandbox still receives one normal +`SandboxPolicy`, but provider-generated entries are not persisted as user-authored +policy revisions. Full policy replacement and incremental policy updates continue to +mutate the user-authored policy layer. Provider-generated rules are re-added during +composition for each attached provider whose profile policy is enabled. + +Provider-generated network rules use reserved `_provider_*` names derived from the +provider record name. If a user policy already has the same key, composition keeps the +user entry and adds a numeric suffix to the provider entry. Duplicate host/port +endpoints across user and provider rules are valid; OPA evaluates all rules, so allow +decisions are the union of matching allows and deny rules continue to win globally. + +Gateway-global policy remains a full override. When a global policy is active, the +gateway serves the global policy as-is rather than composing provider layers into it, +and the existing blocks on sandbox-scoped policy mutations remain unchanged. + ## Components - `crates/openshell-providers` @@ -174,6 +222,7 @@ Also supported: - `openshell provider get ` - `openshell provider list` +- `openshell provider list-types` - `openshell provider update ...` - `openshell provider delete [...]` @@ -232,6 +281,8 @@ Key behaviors: - Only `credentials` are injected, not `config`. - Invalid env var keys (containing `.`, `-`, spaces, etc.) are skipped. - Credentials are never persisted in the sandbox spec's environment map. +- Provider profiles do not change credential injection in the first iteration. + Injection still uses the existing placeholder environment path. ### Sandbox Supervisor: Fetching Credentials diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 57f3dbc84..dc31e5c9b 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -700,6 +700,10 @@ enum ProviderCommands { names: bool, }, + /// List available provider types. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + ListTypes, + /// Update an existing provider's credentials or config. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Update { @@ -2628,6 +2632,9 @@ async fn main() -> Result<()> { } => { run::provider_list(endpoint, limit, offset, names, &tls).await?; } + ProviderCommands::ListTypes => { + run::provider_list_types(endpoint, &tls).await?; + } ProviderCommands::Update { name, from_existing, diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 8f96124aa..ae3eee402 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -27,11 +27,12 @@ use openshell_core::proto::{ CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest, ExecSandboxRequest, GetClusterInferenceRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, - GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProvidersRequest, - ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource, PolicyStatus, Provider, - RejectDraftChunkRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, - SetClusterInferenceRequest, SettingScope, SettingValue, UpdateConfigRequest, - UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value, + GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProviderProfilesRequest, + ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource, + PolicyStatus, Provider, ProviderProfile, RejectDraftChunkRequest, Sandbox, SandboxPhase, + SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, + SettingValue, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest, + exec_sandbox_event, setting_value, }; use openshell_core::settings::{self, SettingValueKind}; use openshell_core::{ObjectId, ObjectName}; @@ -3384,6 +3385,8 @@ async fn auto_create_provider( r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), + profile_id: provider_type.to_string(), + profile_policy_enabled: true, }), }; @@ -3424,6 +3427,8 @@ async fn auto_create_provider( r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), + profile_id: provider_type.to_string(), + profile_policy_enabled: true, }), }; @@ -3579,9 +3584,11 @@ pub async fn provider_create( created_at_ms: 0, labels: HashMap::new(), }), - r#type: provider_type, + r#type: provider_type.clone(), credentials: credential_map, config: config_map, + profile_id: provider_type, + profile_policy_enabled: true, }), }) .await @@ -3706,6 +3713,60 @@ pub async fn provider_list( Ok(()) } +pub async fn provider_list_types(server: &str, tls: &TlsOptions) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .list_provider_profiles(ListProviderProfilesRequest { + limit: 100, + offset: 0, + }) + .await + .into_diagnostic()?; + let mut profiles = response.into_inner().profiles; + profiles.sort_by(|left, right| { + left.category + .cmp(&right.category) + .then_with(|| left.id.cmp(&right.id)) + }); + + if profiles.is_empty() { + println!("No provider types found."); + return Ok(()); + } + + println!("{}", "Available Provider Types:".cyan().bold()); + let mut current_category = String::new(); + for profile in profiles { + if profile.category != current_category { + current_category = profile.category.clone(); + println!(); + println!(" {}", display_provider_category(¤t_category).bold()); + } + print_provider_type_row(&profile); + } + + Ok(()) +} + +fn display_provider_category(category: &str) -> String { + category.replace('-', " ").to_ascii_uppercase() +} + +fn print_provider_type_row(profile: &ProviderProfile) { + let inference = if profile.inference_capable { + " inference" + } else { + "" + }; + println!( + " {:<12} {:<42} endpoints: {:<2}{}", + profile.id, + profile.display_name, + profile.endpoints.len(), + inference + ); +} + pub async fn provider_update( server: &str, name: &str, @@ -3768,6 +3829,8 @@ pub async fn provider_update( r#type: String::new(), credentials: credential_map, config: config_map, + profile_id: String::new(), + profile_policy_enabled: false, }), }) .await diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index a5a485735..97219b238 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -115,6 +115,8 @@ impl TestOpenShell { r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ); } @@ -251,6 +253,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListProvidersResponse { providers })) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, request: tonic::Request, @@ -293,6 +309,8 @@ impl OpenShell for TestOpenShell { r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), config: merge(existing.config, provider.config), + profile_id: existing.profile_id, + profile_policy_enabled: existing.profile_policy_enabled, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); diff --git a/crates/openshell-cli/tests/mtls_integration.rs b/crates/openshell-cli/tests/mtls_integration.rs index 77d33f7b0..69d7b7354 100644 --- a/crates/openshell-cli/tests/mtls_integration.rs +++ b/crates/openshell-cli/tests/mtls_integration.rs @@ -177,6 +177,20 @@ impl OpenShell for TestOpenShell { )) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index d151e5a1c..b39b9b3e7 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -201,6 +201,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListProvidersResponse { providers })) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, request: tonic::Request, @@ -243,6 +257,8 @@ impl OpenShell for TestOpenShell { r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), config: merge(existing.config, provider.config), + profile_id: existing.profile_id, + profile_policy_enabled: existing.profile_policy_enabled, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index e69d06f4f..fde07dacb 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -231,6 +231,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListProvidersResponse::default())) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index 7d6a9536a..bfad9a7d5 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -207,6 +207,20 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListProvidersResponse::default())) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-policy/src/compose.rs b/crates/openshell-policy/src/compose.rs new file mode 100644 index 000000000..7831aece2 --- /dev/null +++ b/crates/openshell-policy/src/compose.rs @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Policy layer composition helpers. + +use openshell_core::proto::{NetworkPolicyRule, SandboxPolicy}; + +#[derive(Debug, Clone, PartialEq)] +pub struct ProviderPolicyLayer { + pub rule_name: String, + pub rule: NetworkPolicyRule, +} + +#[must_use] +pub fn provider_rule_name(provider_name: &str) -> String { + let sanitized = provider_name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .to_string(); + + if sanitized.is_empty() { + "_provider_unnamed".to_string() + } else { + format!("_provider_{sanitized}") + } +} + +/// Compose a normal sandbox policy from user-authored policy plus provider +/// policy layers. +/// +/// The returned policy is derived data. It preserves the source policy's +/// static fields and user-authored network policies, then concatenates each +/// provider rule under a reserved `_provider_*` key. Existing user keys are not +/// overwritten; a numeric suffix is added if needed. +#[must_use] +pub fn compose_effective_policy( + source_policy: &SandboxPolicy, + provider_layers: &[ProviderPolicyLayer], +) -> SandboxPolicy { + let mut effective = source_policy.clone(); + + for layer in provider_layers { + let key = unique_provider_rule_key(&effective, &layer.rule_name); + let mut rule = layer.rule.clone(); + if rule.name.is_empty() { + rule.name = key.clone(); + } + effective.network_policies.insert(key, rule); + } + + effective +} + +fn unique_provider_rule_key(policy: &SandboxPolicy, preferred: &str) -> String { + if !policy.network_policies.contains_key(preferred) { + return preferred.to_string(); + } + + for suffix in 2_u32.. { + let candidate = format!("{preferred}_{suffix}"); + if !policy.network_policies.contains_key(&candidate) { + return candidate; + } + } + + unreachable!("unbounded suffix search must find an unused provider policy key") +} + +#[cfg(test)] +mod tests { + use super::{ProviderPolicyLayer, compose_effective_policy, provider_rule_name}; + use openshell_core::proto::{NetworkEndpoint, NetworkPolicyRule, SandboxPolicy}; + + fn rule(name: &str, host: &str) -> NetworkPolicyRule { + NetworkPolicyRule { + name: name.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + protocol: "rest".to_string(), + tls: String::new(), + enforcement: "enforce".to_string(), + access: "read-write".to_string(), + rules: Vec::new(), + allowed_ips: Vec::new(), + ports: Vec::new(), + deny_rules: Vec::new(), + allow_encoded_slash: false, + }], + binaries: Vec::new(), + } + } + + #[test] + fn provider_rule_name_sanitizes_provider_names() { + assert_eq!(provider_rule_name("my-github"), "_provider_my_github"); + assert_eq!(provider_rule_name("Work GitHub!"), "_provider_work_github"); + assert_eq!(provider_rule_name("..."), "_provider_unnamed"); + } + + #[test] + fn compose_concatenates_provider_rules_without_overwriting_user_rules() { + let mut source = SandboxPolicy::default(); + source.network_policies.insert( + "custom_github".to_string(), + rule("custom_github", "api.github.com"), + ); + source.network_policies.insert( + "_provider_work_github".to_string(), + rule("_provider_work_github", "example.com"), + ); + + let effective = compose_effective_policy( + &source, + &[ProviderPolicyLayer { + rule_name: "_provider_work_github".to_string(), + rule: rule("_provider_work_github", "github.com"), + }], + ); + + assert!(effective.network_policies.contains_key("custom_github")); + assert!( + effective + .network_policies + .contains_key("_provider_work_github") + ); + assert!( + effective + .network_policies + .contains_key("_provider_work_github_2") + ); + assert_eq!(source.network_policies.len(), 2); + assert_eq!(effective.network_policies.len(), 3); + } +} diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index f1abda06b..04b8462d1 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -9,6 +9,7 @@ //! policy schema. Both parsing (YAML→proto) and serialization (proto→YAML) use //! these types, ensuring round-trip fidelity. +mod compose; mod merge; use std::collections::{BTreeMap, HashMap}; @@ -22,6 +23,7 @@ use openshell_core::proto::{ }; use serde::{Deserialize, Serialize}; +pub use compose::{ProviderPolicyLayer, compose_effective_policy, provider_rule_name}; pub use merge::{ PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, generated_rule_name, merge_policy, diff --git a/crates/openshell-providers/src/lib.rs b/crates/openshell-providers/src/lib.rs index e2bcc0c09..b2bf1e234 100644 --- a/crates/openshell-providers/src/lib.rs +++ b/crates/openshell-providers/src/lib.rs @@ -5,6 +5,7 @@ mod context; mod discovery; +mod profiles; mod providers; #[cfg(test)] mod test_helpers; @@ -16,6 +17,7 @@ pub use openshell_core::proto::Provider; pub use context::{DiscoveryContext, RealDiscoveryContext}; pub use discovery::discover_with_spec; +pub use profiles::{ProviderTypeProfile, default_profiles, get_default_profile}; #[derive(Debug, thiserror::Error)] pub enum ProviderError { @@ -115,6 +117,16 @@ impl ProviderRegistry { .map_or(&[], ProviderPlugin::credential_env_vars) } + #[must_use] + pub fn profile(&self, id: &str) -> Option<&'static ProviderTypeProfile> { + get_default_profile(id) + } + + #[must_use] + pub fn profiles(&self) -> Vec<&'static ProviderTypeProfile> { + default_profiles().iter().collect() + } + #[must_use] pub fn known_types(&self) -> Vec<&'static str> { let mut types = self.plugins.keys().copied().collect::>(); diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs new file mode 100644 index 000000000..174602c9c --- /dev/null +++ b/crates/openshell-providers/src/profiles.rs @@ -0,0 +1,464 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Declarative provider type profiles. + +#![allow(deprecated)] // NetworkBinary::harness remains in the public proto for compatibility. + +use openshell_core::proto::{ + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderProfile, ProviderProfileCredential, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialProfile { + pub name: &'static str, + pub description: &'static str, + pub env_vars: &'static [&'static str], + pub required: bool, + pub auth_style: &'static str, + pub header_name: &'static str, + pub query_param: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndpointProfile { + pub host: &'static str, + pub port: u32, + pub protocol: &'static str, + pub access: &'static str, + pub enforcement: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderTypeProfile { + pub id: &'static str, + pub display_name: &'static str, + pub description: &'static str, + pub category: &'static str, + pub credentials: &'static [CredentialProfile], + pub endpoints: &'static [EndpointProfile], + pub binaries: &'static [&'static str], + pub inference_capable: bool, +} + +impl ProviderTypeProfile { + #[must_use] + pub fn credential_env_vars(&self) -> Vec<&'static str> { + let mut vars = Vec::new(); + for credential in self.credentials { + for env_var in credential.env_vars { + if !vars.contains(env_var) { + vars.push(*env_var); + } + } + } + vars + } + + #[must_use] + pub fn to_proto(&self) -> ProviderProfile { + ProviderProfile { + id: self.id.to_string(), + display_name: self.display_name.to_string(), + description: self.description.to_string(), + category: self.category.to_string(), + credentials: self + .credentials + .iter() + .map(|credential| ProviderProfileCredential { + name: credential.name.to_string(), + description: credential.description.to_string(), + env_vars: credential + .env_vars + .iter() + .map(|env_var| (*env_var).to_string()) + .collect(), + required: credential.required, + auth_style: credential.auth_style.to_string(), + header_name: credential.header_name.to_string(), + query_param: credential.query_param.to_string(), + }) + .collect(), + endpoints: self + .endpoints + .iter() + .map(|endpoint| NetworkEndpoint { + host: endpoint.host.to_string(), + port: endpoint.port, + protocol: endpoint.protocol.to_string(), + tls: String::new(), + enforcement: endpoint.enforcement.to_string(), + access: endpoint.access.to_string(), + rules: Vec::new(), + allowed_ips: Vec::new(), + ports: Vec::new(), + deny_rules: Vec::new(), + allow_encoded_slash: false, + }) + .collect(), + binaries: self + .binaries + .iter() + .map(|path| NetworkBinary { + path: (*path).to_string(), + harness: false, + }) + .collect(), + inference_capable: self.inference_capable, + } + } + + #[must_use] + pub fn network_policy_rule(&self, rule_name: &str) -> NetworkPolicyRule { + NetworkPolicyRule { + name: rule_name.to_string(), + endpoints: self.to_proto().endpoints, + binaries: self + .binaries + .iter() + .map(|path| NetworkBinary { + path: (*path).to_string(), + harness: false, + }) + .collect(), + } + } +} + +const CLAUDE_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_key", + description: "Anthropic API key used by Claude Code", + env_vars: &["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"], + required: true, + auth_style: "header", + header_name: "x-api-key", + query_param: "", +}]; + +const ANTHROPIC_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_key", + description: "Anthropic API key", + env_vars: &["ANTHROPIC_API_KEY"], + required: true, + auth_style: "header", + header_name: "x-api-key", + query_param: "", +}]; + +const OPENAI_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_key", + description: "OpenAI API key", + env_vars: &["OPENAI_API_KEY"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const OPENCODE_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_key", + description: "OpenCode-compatible API key", + env_vars: &["OPENCODE_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const NVIDIA_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_key", + description: "NVIDIA API key", + env_vars: &["NVIDIA_API_KEY"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const GITHUB_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_token", + description: "GitHub token", + env_vars: &["GITHUB_TOKEN", "GH_TOKEN"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const COPILOT_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "github_token", + description: "GitHub token used by Copilot tooling", + env_vars: &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const GITLAB_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { + name: "api_token", + description: "GitLab token", + env_vars: &["GITLAB_TOKEN", "GLAB_TOKEN", "CI_JOB_TOKEN"], + required: true, + auth_style: "bearer", + header_name: "authorization", + query_param: "", +}]; + +const GENERIC_CREDENTIALS: &[CredentialProfile] = &[]; +const OUTLOOK_CREDENTIALS: &[CredentialProfile] = &[]; + +const CLAUDE_ENDPOINTS: &[EndpointProfile] = &[ + EndpointProfile { + host: "api.anthropic.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, + EndpointProfile { + host: "statsig.anthropic.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, + EndpointProfile { + host: "sentry.io", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, +]; + +const ANTHROPIC_ENDPOINTS: &[EndpointProfile] = &[EndpointProfile { + host: "api.anthropic.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", +}]; + +const OPENAI_ENDPOINTS: &[EndpointProfile] = &[EndpointProfile { + host: "api.openai.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", +}]; + +const NVIDIA_ENDPOINTS: &[EndpointProfile] = &[EndpointProfile { + host: "integrate.api.nvidia.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", +}]; + +const GITHUB_ENDPOINTS: &[EndpointProfile] = &[ + EndpointProfile { + host: "api.github.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, + EndpointProfile { + host: "github.com", + port: 443, + protocol: "rest", + access: "read-only", + enforcement: "enforce", + }, +]; + +const GITLAB_ENDPOINTS: &[EndpointProfile] = &[ + EndpointProfile { + host: "gitlab.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, + EndpointProfile { + host: "api.gitlab.com", + port: 443, + protocol: "rest", + access: "read-write", + enforcement: "enforce", + }, +]; + +const EMPTY_ENDPOINTS: &[EndpointProfile] = &[]; + +const DEFAULT_PROFILES: &[ProviderTypeProfile] = &[ + ProviderTypeProfile { + id: "anthropic", + display_name: "Anthropic API", + description: "Anthropic API access for Claude models", + category: "inference", + credentials: ANTHROPIC_CREDENTIALS, + endpoints: ANTHROPIC_ENDPOINTS, + binaries: &["/usr/bin/curl", "/usr/local/bin/curl"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "claude", + display_name: "Claude Code", + description: "Claude Code CLI", + category: "inference", + credentials: CLAUDE_CREDENTIALS, + endpoints: CLAUDE_ENDPOINTS, + binaries: &["/usr/bin/claude", "/usr/local/bin/claude"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "codex", + display_name: "Codex", + description: "Codex CLI using OpenAI-compatible API credentials", + category: "inference", + credentials: OPENAI_CREDENTIALS, + endpoints: OPENAI_ENDPOINTS, + binaries: &["/usr/bin/codex", "/usr/local/bin/codex"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "copilot", + display_name: "GitHub Copilot", + description: "GitHub Copilot tooling", + category: "inference", + credentials: COPILOT_CREDENTIALS, + endpoints: GITHUB_ENDPOINTS, + binaries: &["/usr/bin/copilot", "/usr/local/bin/copilot"], + inference_capable: false, + }, + ProviderTypeProfile { + id: "generic", + display_name: "Generic", + description: "Generic provider record without managed policy defaults", + category: "custom", + credentials: GENERIC_CREDENTIALS, + endpoints: EMPTY_ENDPOINTS, + binaries: &[], + inference_capable: false, + }, + ProviderTypeProfile { + id: "github", + display_name: "GitHub", + description: "GitHub API and Git operations", + category: "source-control", + credentials: GITHUB_CREDENTIALS, + endpoints: GITHUB_ENDPOINTS, + binaries: &[ + "/usr/bin/gh", + "/usr/local/bin/gh", + "/usr/bin/git", + "/usr/local/bin/git", + ], + inference_capable: false, + }, + ProviderTypeProfile { + id: "gitlab", + display_name: "GitLab", + description: "GitLab API and Git operations", + category: "source-control", + credentials: GITLAB_CREDENTIALS, + endpoints: GITLAB_ENDPOINTS, + binaries: &[ + "/usr/bin/glab", + "/usr/local/bin/glab", + "/usr/bin/git", + "/usr/local/bin/git", + ], + inference_capable: false, + }, + ProviderTypeProfile { + id: "nvidia", + display_name: "NVIDIA", + description: "NVIDIA inference endpoints", + category: "inference", + credentials: NVIDIA_CREDENTIALS, + endpoints: NVIDIA_ENDPOINTS, + binaries: &["/usr/bin/curl", "/usr/local/bin/curl"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "openai", + display_name: "OpenAI", + description: "OpenAI API access", + category: "inference", + credentials: OPENAI_CREDENTIALS, + endpoints: OPENAI_ENDPOINTS, + binaries: &["/usr/bin/curl", "/usr/local/bin/curl"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "opencode", + display_name: "OpenCode", + description: "OpenCode-compatible inference provider", + category: "inference", + credentials: OPENCODE_CREDENTIALS, + endpoints: OPENAI_ENDPOINTS, + binaries: &["/usr/bin/opencode", "/usr/local/bin/opencode"], + inference_capable: true, + }, + ProviderTypeProfile { + id: "outlook", + display_name: "Outlook", + description: "Outlook provider record without managed policy defaults", + category: "messaging", + credentials: OUTLOOK_CREDENTIALS, + endpoints: EMPTY_ENDPOINTS, + binaries: &[], + inference_capable: false, + }, +]; + +#[must_use] +pub const fn default_profiles() -> &'static [ProviderTypeProfile] { + DEFAULT_PROFILES +} + +#[must_use] +pub fn get_default_profile(id: &str) -> Option<&'static ProviderTypeProfile> { + default_profiles() + .iter() + .find(|profile| profile.id.eq_ignore_ascii_case(id)) +} + +#[cfg(test)] +mod tests { + use super::{default_profiles, get_default_profile}; + + #[test] + fn default_profiles_are_sorted_by_id() { + let ids = default_profiles() + .iter() + .map(|profile| profile.id) + .collect::>(); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + assert_eq!(ids, sorted); + } + + #[test] + fn github_profile_materializes_policy_metadata() { + let profile = get_default_profile("github").expect("github profile"); + let proto = profile.to_proto(); + + assert_eq!(proto.id, "github"); + assert_eq!(proto.category, "source-control"); + assert_eq!(proto.endpoints.len(), 2); + assert_eq!(proto.binaries.len(), 4); + } + + #[test] + fn credential_env_vars_are_deduplicated_in_profile_order() { + let profile = get_default_profile("copilot").expect("copilot profile"); + assert_eq!( + profile.credential_env_vars(), + vec!["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] + ); + } +} diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 28bc46257..0123f4224 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -21,6 +21,7 @@ openshell-driver-kubernetes = { path = "../openshell-driver-kubernetes" } openshell-driver-podman = { path = "../openshell-driver-podman" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } +openshell-providers = { path = "../openshell-providers" } openshell-router = { path = "../openshell-router" } # Async runtime diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index 89e639ac9..b72d6ba8f 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -15,13 +15,15 @@ use openshell_core::proto::{ DeleteProviderRequest, DeleteProviderResponse, DeleteSandboxRequest, DeleteSandboxResponse, EditDraftChunkRequest, EditDraftChunkResponse, ExecSandboxEvent, ExecSandboxRequest, GatewayMessage, GetDraftHistoryRequest, GetDraftHistoryResponse, GetDraftPolicyRequest, - GetDraftPolicyResponse, GetGatewayConfigRequest, GetGatewayConfigResponse, GetProviderRequest, - GetSandboxConfigRequest, GetSandboxConfigResponse, GetSandboxLogsRequest, - GetSandboxLogsResponse, GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, + GetDraftPolicyResponse, GetGatewayConfigRequest, GetGatewayConfigResponse, + GetProviderProfileRequest, GetProviderRequest, GetSandboxConfigRequest, + GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxLogsResponse, + GetSandboxPolicyStatusRequest, GetSandboxPolicyStatusResponse, GetSandboxProviderEnvironmentRequest, GetSandboxProviderEnvironmentResponse, GetSandboxRequest, - HealthRequest, HealthResponse, ListProvidersRequest, ListProvidersResponse, - ListSandboxPoliciesRequest, ListSandboxPoliciesResponse, ListSandboxesRequest, - ListSandboxesResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, + HealthRequest, HealthResponse, ListProviderProfilesRequest, ListProviderProfilesResponse, + ListProvidersRequest, ListProvidersResponse, ListSandboxPoliciesRequest, + ListSandboxPoliciesResponse, ListSandboxesRequest, ListSandboxesResponse, + ProviderProfileResponse, ProviderResponse, PushSandboxLogsRequest, PushSandboxLogsResponse, RejectDraftChunkRequest, RejectDraftChunkResponse, RelayFrame, ReportPolicyStatusRequest, ReportPolicyStatusResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, SandboxResponse, SandboxStreamEvent, ServiceStatus, SubmitPolicyAnalysisRequest, SubmitPolicyAnalysisResponse, @@ -252,6 +254,20 @@ impl OpenShell for OpenShellService { provider::handle_list_providers(&self.state, request).await } + async fn list_provider_profiles( + &self, + request: Request, + ) -> Result, Status> { + provider::handle_list_provider_profiles(&self.state, request).await + } + + async fn get_provider_profile( + &self, + request: Request, + ) -> Result, Status> { + provider::handle_get_provider_profile(&self.state, request).await + } + async fn update_provider( &self, request: Request, diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 25a4bd17c..d4f82afca 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -33,7 +33,7 @@ use openshell_core::proto::{ UndoDraftChunkResponse, UpdateConfigRequest, UpdateConfigResponse, }; use openshell_core::proto::{ - L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Sandbox, + L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Provider, Sandbox, SandboxPolicy as ProtoSandboxPolicy, }; use openshell_core::{ @@ -43,7 +43,10 @@ use openshell_core::{ use openshell_ocsf::{ ConfigStateChangeBuilder, OCSF_TARGET, OcsfEvent, SandboxContext, SeverityId, StateId, StatusId, }; -use openshell_policy::{PolicyMergeOp, merge_policy}; +use openshell_policy::{ + PolicyMergeOp, ProviderPolicyLayer, compose_effective_policy, merge_policy, +}; +use openshell_providers::get_default_profile; use prost::Message; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap}; @@ -321,6 +324,11 @@ pub(super) async fn handle_get_sandbox_config( .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? .ok_or_else(|| Status::not_found("sandbox not found"))?; + let sandbox_provider_names = sandbox + .spec + .as_ref() + .map(|spec| spec.providers.clone()) + .unwrap_or_default(); // Try to get the latest policy from the policy history table. let latest = state @@ -417,6 +425,18 @@ pub(super) async fn handle_get_sandbox_config( } } + if policy_source == PolicySource::Sandbox + && let Some(source_policy) = policy.as_ref() + { + let provider_layers = + profile_provider_policy_layers(state.store.as_ref(), &sandbox_provider_names).await?; + if !provider_layers.is_empty() { + let effective_policy = compose_effective_policy(source_policy, &provider_layers); + policy_hash = deterministic_policy_hash(&effective_policy); + policy = Some(effective_policy); + } + } + let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); @@ -431,6 +451,51 @@ pub(super) async fn handle_get_sandbox_config( })) } +async fn profile_provider_policy_layers( + store: &Store, + provider_names: &[String], +) -> Result, Status> { + let mut layers = Vec::new(); + + for name in provider_names { + let provider = store + .get_message_by_name::(name) + .await + .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? + .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; + + if !provider.profile_policy_enabled { + continue; + } + + let profile_id = provider.profile_id.trim(); + if profile_id.is_empty() { + warn!( + provider_name = %name, + "provider profile policy enabled without a profile id; skipping provider policy layer" + ); + continue; + } + + let Some(profile) = get_default_profile(profile_id) else { + warn!( + provider_name = %name, + profile_id, + "provider profile id is unknown; skipping provider policy layer" + ); + continue; + }; + + let rule_name = openshell_policy::provider_rule_name(provider.object_name()); + layers.push(ProviderPolicyLayer { + rule_name: rule_name.clone(), + rule: profile.network_policy_rule(&rule_name), + }); + } + + Ok(layers) +} + pub(super) async fn handle_get_gateway_config( state: &Arc, _request: Request, @@ -2623,6 +2688,66 @@ mod tests { assert!(loaded.spec.unwrap().policy.is_none()); } + fn test_provider(name: &str, profile_enabled: bool) -> Provider { + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("provider-{name}"), + name: name.to_string(), + created_at_ms: 1000000, + labels: HashMap::new(), + }), + r#type: "github".to_string(), + credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) + .collect(), + config: HashMap::new(), + profile_id: if profile_enabled { + "github".to_string() + } else { + String::new() + }, + profile_policy_enabled: profile_enabled, + } + } + + #[tokio::test] + async fn provider_policy_layers_skip_legacy_providers() { + let store = Store::connect("sqlite::memory:").await.unwrap(); + store + .put_message(&test_provider("legacy-github", false)) + .await + .unwrap(); + + let layers = profile_provider_policy_layers(&store, &["legacy-github".to_string()]) + .await + .unwrap(); + + assert!(layers.is_empty()); + } + + #[tokio::test] + async fn provider_policy_layers_include_profile_enabled_providers() { + let store = Store::connect("sqlite::memory:").await.unwrap(); + store + .put_message(&test_provider("work-github", true)) + .await + .unwrap(); + + let layers = profile_provider_policy_layers(&store, &["work-github".to_string()]) + .await + .unwrap(); + + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].rule_name, "_provider_work_github"); + assert_eq!(layers[0].rule.endpoints.len(), 2); + assert!( + layers[0] + .rule + .endpoints + .iter() + .any(|endpoint| endpoint.host == "api.github.com") + ); + } + #[tokio::test] async fn sandbox_policy_backfill_on_update_when_no_baseline() { use openshell_core::proto::{FilesystemPolicy, LandlockPolicy, SandboxPhase, SandboxSpec}; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index a9b18b7eb..2418c8f71 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -155,6 +155,8 @@ pub(super) async fn update_provider_record( r#type: existing.r#type, credentials: merge_map(existing.credentials, provider.credentials), config: merge_map(existing.config, provider.config), + profile_id: existing.profile_id, + profile_policy_enabled: existing.profile_policy_enabled, }; // Ensure metadata is valid (defense in depth - existing.metadata should always be valid) @@ -273,9 +275,12 @@ impl ObjectType for Provider { use crate::ServerState; use openshell_core::proto::{ - CreateProviderRequest, DeleteProviderRequest, DeleteProviderResponse, GetProviderRequest, - ListProvidersRequest, ListProvidersResponse, ProviderResponse, UpdateProviderRequest, + CreateProviderRequest, DeleteProviderRequest, DeleteProviderResponse, + GetProviderProfileRequest, GetProviderRequest, ListProviderProfilesRequest, + ListProviderProfilesResponse, ListProvidersRequest, ListProvidersResponse, + ProviderProfileResponse, ProviderResponse, UpdateProviderRequest, }; +use openshell_providers::{default_profiles, get_default_profile}; use std::sync::Arc; use tonic::{Request, Response}; @@ -317,6 +322,40 @@ pub(super) async fn handle_list_providers( Ok(Response::new(ListProvidersResponse { providers })) } +pub(super) async fn handle_list_provider_profiles( + _state: &Arc, + request: Request, +) -> Result, Status> { + let request = request.into_inner(); + let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; + let offset = request.offset as usize; + let profiles = default_profiles() + .iter() + .skip(offset) + .take(limit) + .map(|profile| profile.to_proto()) + .collect(); + + Ok(Response::new(ListProviderProfilesResponse { profiles })) +} + +pub(super) async fn handle_get_provider_profile( + _state: &Arc, + request: Request, +) -> Result, Status> { + let id = request.into_inner().id; + if id.trim().is_empty() { + return Err(Status::invalid_argument("id is required")); + } + let profile = get_default_profile(id.trim()) + .ok_or_else(|| Status::not_found("provider profile not found"))? + .to_proto(); + + Ok(Response::new(ProviderProfileResponse { + profile: Some(profile), + })) +} + pub(super) async fn handle_update_provider( state: &Arc, request: Request, @@ -392,6 +431,8 @@ mod tests { ] .into_iter() .collect(), + profile_id: String::new(), + profile_policy_enabled: false, } } @@ -437,6 +478,8 @@ mod tests { .collect(), config: std::iter::once(("endpoint".to_string(), "https://gitlab.com".to_string())) .collect(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -505,6 +548,8 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -529,6 +574,8 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -557,6 +604,8 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -604,6 +653,8 @@ mod tests { r#type: String::new(), credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), config: std::iter::once(("region".to_string(), String::new())).collect(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -655,6 +706,8 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -684,6 +737,8 @@ mod tests { r#type: "openai".to_string(), credentials: HashMap::new(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -715,6 +770,8 @@ mod tests { r#type: String::new(), credentials: std::iter::once((oversized_key, "value".to_string())).collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -752,6 +809,8 @@ mod tests { "https://api.anthropic.com".to_string(), )) .collect(), + profile_id: String::new(), + profile_policy_enabled: false, }; create_provider_record(&store, provider).await.unwrap(); @@ -792,6 +851,8 @@ mod tests { .into_iter() .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }; create_provider_record(&store, provider).await.unwrap(); @@ -822,6 +883,8 @@ mod tests { )) .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -839,6 +902,8 @@ mod tests { credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -870,6 +935,8 @@ mod tests { credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -890,6 +957,8 @@ mod tests { )) .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await @@ -926,6 +995,8 @@ mod tests { )) .collect(), config: HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, }, ) .await diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 160b7e031..22b1fb173 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -878,6 +878,8 @@ mod tests { r#type: provider_type.to_string(), credentials, config, + profile_id: String::new(), + profile_policy_enabled: false, } } diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index b52700f0d..2d40f17d5 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -511,6 +511,8 @@ mod tests { r#type: provider_type.to_string(), credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), config: std::collections::HashMap::new(), + profile_id: String::new(), + profile_policy_enabled: false, } } @@ -675,6 +677,8 @@ mod tests { "https://station.example.com/v1".to_string(), )) .collect(), + profile_id: String::new(), + profile_policy_enabled: false, }; store .put_message(&provider) @@ -749,6 +753,8 @@ mod tests { credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-rotated".to_string())) .collect(), config: provider.config.clone(), + profile_id: provider.profile_id.clone(), + profile_policy_enabled: provider.profile_policy_enabled, }; store .put_message(&rotated_provider) diff --git a/crates/openshell-server/tests/auth_endpoint_integration.rs b/crates/openshell-server/tests/auth_endpoint_integration.rs index 12f302b63..e5f9dc4e9 100644 --- a/crates/openshell-server/tests/auth_endpoint_integration.rs +++ b/crates/openshell-server/tests/auth_endpoint_integration.rs @@ -508,6 +508,22 @@ impl openshell_core::proto::open_shell_server::OpenShell for TestOpenShell { Err(tonic::Status::unimplemented("test")) } + async fn list_provider_profiles( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> + { + Err(tonic::Status::unimplemented("test")) + } + + async fn get_provider_profile( + &self, + _: tonic::Request, + ) -> Result, tonic::Status> + { + Err(tonic::Status::unimplemented("test")) + } + async fn update_provider( &self, _: tonic::Request, diff --git a/crates/openshell-server/tests/edge_tunnel_auth.rs b/crates/openshell-server/tests/edge_tunnel_auth.rs index 15df2f9d8..ed6ed398f 100644 --- a/crates/openshell-server/tests/edge_tunnel_auth.rs +++ b/crates/openshell-server/tests/edge_tunnel_auth.rs @@ -171,6 +171,20 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/multiplex_integration.rs b/crates/openshell-server/tests/multiplex_integration.rs index dd14c63ec..99f452556 100644 --- a/crates/openshell-server/tests/multiplex_integration.rs +++ b/crates/openshell-server/tests/multiplex_integration.rs @@ -135,6 +135,20 @@ impl OpenShell for TestOpenShell { )) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/multiplex_tls_integration.rs b/crates/openshell-server/tests/multiplex_tls_integration.rs index 83ba76988..6942d66f7 100644 --- a/crates/openshell-server/tests/multiplex_tls_integration.rs +++ b/crates/openshell-server/tests/multiplex_tls_integration.rs @@ -148,6 +148,20 @@ impl OpenShell for TestOpenShell { )) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-server/tests/supervisor_relay_integration.rs b/crates/openshell-server/tests/supervisor_relay_integration.rs index 85d263223..d77cfd375 100644 --- a/crates/openshell-server/tests/supervisor_relay_integration.rs +++ b/crates/openshell-server/tests/supervisor_relay_integration.rs @@ -172,6 +172,21 @@ impl OpenShell for RelayGateway { ) -> Result, Status> { Err(Status::unimplemented("unused")) } + + async fn list_provider_profiles( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + + async fn get_provider_profile( + &self, + _: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("unused")) + } + async fn delete_provider( &self, _: tonic::Request, diff --git a/crates/openshell-server/tests/ws_tunnel_integration.rs b/crates/openshell-server/tests/ws_tunnel_integration.rs index 949c0200a..f196edb07 100644 --- a/crates/openshell-server/tests/ws_tunnel_integration.rs +++ b/crates/openshell-server/tests/ws_tunnel_integration.rs @@ -165,6 +165,20 @@ impl OpenShell for TestOpenShell { Err(Status::unimplemented("not implemented in test")) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + + async fn get_provider_profile( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Err(Status::unimplemented("not implemented in test")) + } + async fn update_provider( &self, _request: tonic::Request, diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 8571ebbe1..ee9ee4b78 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1569,6 +1569,8 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { r#type: ptype.clone(), credentials: credentials.clone(), config: HashMap::default(), + profile_id: ptype.clone(), + profile_policy_enabled: true, }), }; @@ -1659,6 +1661,8 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { r#type: ptype, credentials, config: HashMap::default(), + profile_id: String::new(), + profile_policy_enabled: false, }), }; diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index fbfc4d380..052688ef9 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -12,6 +12,13 @@ AI agents typically need credentials to access external services: an API key for Create and manage providers that supply credentials to sandboxes. +Provider types include profile metadata for known endpoints and binaries. View +the available provider types before creating a provider: + +```shell +openshell provider list-types +``` + ## Create a Provider Providers can be created from local environment variables or with explicit credential values. @@ -47,6 +54,11 @@ openshell provider create --name my-api --type generic --credential API_KEY This looks up the current value of `$API_KEY` in your shell and stores it. +Providers created by current clients use the default profile for their type when +one exists. Profile-managed providers add their known network endpoints to the +sandbox's effective policy when attached at sandbox creation. Providers created +before profile support remain credential-only until recreated or upgraded. + ## Manage Providers List, inspect, update, and delete providers from the active cluster. @@ -84,7 +96,9 @@ openshell sandbox create --provider my-claude --provider my-github -- claude ``` Each `--provider` flag attaches one provider. The sandbox receives all -credentials from every attached provider at runtime. +credentials from every attached provider at runtime. Profile-managed providers +also contribute provider-generated network policy entries. Legacy providers keep +the previous behavior and only provide credentials. Providers cannot be added to a running sandbox. If you need to attach an diff --git a/proto/datamodel.proto b/proto/datamodel.proto index 534b043ae..3aa2c3df8 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -34,4 +34,14 @@ message Provider { map credentials = 3; // Non-secret provider configuration. map config = 4; + // Provider type profile used for profile-backed policy composition. + // + // Empty means the provider is legacy/manual and must not contribute + // provider-generated network policy. + string profile_id = 5; + // Whether this provider contributes its profile's generated policy layer. + // + // This is explicit for backwards compatibility: existing provider records + // deserialize to false and keep the legacy credential-only behavior. + bool profile_policy_enabled = 6; } diff --git a/proto/openshell.proto b/proto/openshell.proto index 75490f338..0b5cd2b0f 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -51,6 +51,14 @@ service OpenShell { // List providers. rpc ListProviders(ListProvidersRequest) returns (ListProvidersResponse); + // List available provider type profiles. + rpc ListProviderProfiles(ListProviderProfilesRequest) + returns (ListProviderProfilesResponse); + + // Fetch one provider type profile by id. + rpc GetProviderProfile(GetProviderProfileRequest) + returns (ProviderProfileResponse); + // Update an existing provider by name. rpc UpdateProvider(UpdateProviderRequest) returns (ProviderResponse); @@ -562,6 +570,50 @@ message ListProvidersResponse { repeated openshell.datamodel.v1.Provider providers = 1; } +// List provider type profiles request. +message ListProviderProfilesRequest { + uint32 limit = 1; + uint32 offset = 2; +} + +// Fetch provider type profile request. +message GetProviderProfileRequest { + string id = 1; +} + +// Provider credential declaration. +message ProviderProfileCredential { + string name = 1; + string description = 2; + repeated string env_vars = 3; + bool required = 4; + string auth_style = 5; + string header_name = 6; + string query_param = 7; +} + +// Provider type profile metadata exposed to clients. +message ProviderProfile { + string id = 1; + string display_name = 2; + string description = 3; + string category = 4; + repeated ProviderProfileCredential credentials = 5; + repeated openshell.sandbox.v1.NetworkEndpoint endpoints = 6; + repeated openshell.sandbox.v1.NetworkBinary binaries = 7; + bool inference_capable = 8; +} + +// Provider profile response. +message ProviderProfileResponse { + ProviderProfile profile = 1; +} + +// List provider profiles response. +message ListProviderProfilesResponse { + repeated ProviderProfile profiles = 1; +} + // Delete provider response. message DeleteProviderResponse { bool deleted = 1; From dccb2b97380c4cb48aae497e6a9b99c3c724b88e Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:22:42 -0700 Subject: [PATCH 2/7] feat(providers): gate profile policy by sandbox flag --- architecture/sandbox-providers.md | 22 +++--- crates/openshell-cli/src/main.rs | 7 ++ crates/openshell-cli/src/run.rs | 20 +++-- .../tests/ensure_providers_integration.rs | 4 - .../tests/provider_commands_integration.rs | 2 - .../sandbox_create_lifecycle_integration.rs | 5 ++ crates/openshell-server/src/grpc/policy.rs | 75 ++++++++++++------- crates/openshell-server/src/grpc/provider.rs | 34 --------- .../openshell-server/src/grpc/validation.rs | 2 - crates/openshell-server/src/inference.rs | 6 -- crates/openshell-tui/src/lib.rs | 8 +- docs/sandboxes/manage-providers.mdx | 12 ++- proto/datamodel.proto | 10 --- 13 files changed, 86 insertions(+), 121 deletions(-) diff --git a/architecture/sandbox-providers.md b/architecture/sandbox-providers.md index e7bdf37dd..5111da8d5 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -35,8 +35,6 @@ Provider is defined in `proto/datamodel.proto`: - `type`: canonical provider slug (`claude`, `gitlab`, `github`, etc.) - `credentials`: `map` for secret values - `config`: `map` for non-secret settings -- `profile_id`: provider type profile used for profile-backed policy composition -- `profile_policy_enabled`: explicit opt-in for provider-generated policy entries The gRPC surface is defined in `proto/openshell.proto`: @@ -57,30 +55,28 @@ known network endpoints, expected binaries, category, and whether the provider i inference-capable. Profiles are additive to provider records. A provider record with only `type`, -`credentials`, and `config` remains a legacy credential-only provider. The gateway -does not infer provider-managed network policy from `provider.type` alone. A provider -contributes profile-generated policy only when `profile_policy_enabled` is true and -`profile_id` names a known profile. Existing stored provider records deserialize with -`profile_policy_enabled = false`, so OpenShell upgrades do not silently broaden network -access for existing sandboxes. +`credentials`, and `config` can be matched to built-in profile metadata by +`provider.type`. Profile-generated policy is still sandbox-scoped: the gateway composes +provider profile rules only for sandboxes whose `SandboxSpec.features.provider_profile_policy` +flag is true. -New providers created by current clients can set `profile_id` to the provider type and -enable `profile_policy_enabled`. This makes the compatibility boundary explicit while -keeping the normal CLI experience simple. +This keeps the compatibility boundary on the sandbox that consumes effective policy, +rather than on provider records shared by many sandboxes. A provider can be attached to +both legacy sandboxes and profile-policy sandboxes at the same time. ### Provider Policy Composition Sandbox policy fetch uses just-in-time composition: ```text -effective policy = base/static policy + enabled provider profile rules + user rules +effective policy = base/static policy + provider profile rules + user rules ``` The composed policy is derived data. The sandbox still receives one normal `SandboxPolicy`, but provider-generated entries are not persisted as user-authored policy revisions. Full policy replacement and incremental policy updates continue to mutate the user-authored policy layer. Provider-generated rules are re-added during -composition for each attached provider whose profile policy is enabled. +composition for each attached provider whose type has a built-in profile. Provider-generated network rules use reserved `_provider_*` names derived from the provider record name. If a user policy already has the same key, composition keeps the diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index dc31e5c9b..7373fc84f 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1206,6 +1206,10 @@ enum SandboxCommands { #[arg(long, overrides_with = "auto_providers")] no_auto_providers: bool, + /// Opt into provider profile network policy composition for this sandbox. + #[arg(long)] + provider_profile_policy: bool, + /// Attach labels to the sandbox (key=value format, repeatable). #[arg(long = "label")] labels: Vec, @@ -2328,6 +2332,7 @@ async fn main() -> Result<()> { no_bootstrap, auto_providers, no_auto_providers, + provider_profile_policy, labels, command, } => { @@ -2423,6 +2428,7 @@ async fn main() -> Result<()> { tty_override, Some(false), auto_providers_override, + provider_profile_policy, &labels_map, &tls, )) @@ -2447,6 +2453,7 @@ async fn main() -> Result<()> { tty_override, bootstrap_override, auto_providers_override, + provider_profile_policy, )) .await?; } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index ae3eee402..a60d79f55 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -29,9 +29,9 @@ use openshell_core::proto::{ GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource, - PolicyStatus, Provider, ProviderProfile, RejectDraftChunkRequest, Sandbox, SandboxPhase, - SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, - SettingValue, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest, + PolicyStatus, Provider, ProviderProfile, RejectDraftChunkRequest, Sandbox, SandboxFeatures, + SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, + SettingScope, SettingValue, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value, }; use openshell_core::settings::{self, SettingValueKind}; @@ -1940,6 +1940,7 @@ pub async fn sandbox_create_with_bootstrap( tty_override: Option, bootstrap_override: Option, auto_providers_override: Option, + provider_profile_policy: bool, ) -> Result<()> { if !crate::bootstrap::confirm_bootstrap(bootstrap_override)? { return Err(miette::miette!( @@ -1976,6 +1977,7 @@ pub async fn sandbox_create_with_bootstrap( tty_override, Some(false), auto_providers_override, + provider_profile_policy, &HashMap::new(), &tls, )) @@ -2033,6 +2035,7 @@ pub async fn sandbox_create( tty_override: Option, bootstrap_override: Option, auto_providers_override: Option, + provider_profile_policy: bool, labels: &HashMap, tls: &TlsOptions, ) -> Result<()> { @@ -2138,6 +2141,9 @@ pub async fn sandbox_create( policy, providers: configured_providers, template, + features: provider_profile_policy.then_some(SandboxFeatures { + provider_profile_policy, + }), ..SandboxSpec::default() }), name: name.unwrap_or_default().to_string(), @@ -3385,8 +3391,6 @@ async fn auto_create_provider( r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), - profile_id: provider_type.to_string(), - profile_policy_enabled: true, }), }; @@ -3427,8 +3431,6 @@ async fn auto_create_provider( r#type: provider_type.to_string(), credentials: discovered.credentials.clone(), config: discovered.config.clone(), - profile_id: provider_type.to_string(), - profile_policy_enabled: true, }), }; @@ -3587,8 +3589,6 @@ pub async fn provider_create( r#type: provider_type.clone(), credentials: credential_map, config: config_map, - profile_id: provider_type, - profile_policy_enabled: true, }), }) .await @@ -3829,8 +3829,6 @@ pub async fn provider_update( r#type: String::new(), credentials: credential_map, config: config_map, - profile_id: String::new(), - profile_policy_enabled: false, }), }) .await diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index 97219b238..0b29f73f4 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -115,8 +115,6 @@ impl TestOpenShell { r#type: provider_type.to_string(), credentials: HashMap::new(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ); } @@ -309,8 +307,6 @@ impl OpenShell for TestOpenShell { r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), config: merge(existing.config, provider.config), - profile_id: existing.profile_id, - profile_policy_enabled: existing.profile_policy_enabled, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index b39b9b3e7..8952c8f79 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -257,8 +257,6 @@ impl OpenShell for TestOpenShell { r#type: existing.r#type, credentials: merge(existing.credentials, provider.credentials), config: merge(existing.config, provider.config), - profile_id: existing.profile_id, - profile_policy_enabled: existing.profile_policy_enabled, }; let updated_name = updated.object_name().to_string(); providers.insert(updated_name, updated.clone()); diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index fde07dacb..89ea33131 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -595,6 +595,7 @@ async fn sandbox_create_keeps_command_sessions_by_default() { Some(false), Some(false), Some(false), + false, &HashMap::new(), &tls, ) @@ -637,6 +638,7 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { Some(false), Some(false), Some(false), + false, &HashMap::new(), &tls, ) @@ -682,6 +684,7 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { Some(true), Some(false), Some(false), + false, &HashMap::new(), &tls, ) @@ -727,6 +730,7 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { Some(false), Some(false), Some(false), + false, &HashMap::new(), &tls, ) @@ -769,6 +773,7 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { Some(false), Some(false), Some(false), + false, &HashMap::new(), &tls, ) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index d4f82afca..60703193a 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -329,6 +329,7 @@ pub(super) async fn handle_get_sandbox_config( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); + let provider_profile_policy_enabled = sandbox_uses_provider_profile_policy(&sandbox); // Try to get the latest policy from the policy history table. let latest = state @@ -426,6 +427,7 @@ pub(super) async fn handle_get_sandbox_config( } if policy_source == PolicySource::Sandbox + && provider_profile_policy_enabled && let Some(source_policy) = policy.as_ref() { let provider_layers = @@ -464,24 +466,12 @@ async fn profile_provider_policy_layers( .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; - if !provider.profile_policy_enabled { - continue; - } - - let profile_id = provider.profile_id.trim(); - if profile_id.is_empty() { - warn!( - provider_name = %name, - "provider profile policy enabled without a profile id; skipping provider policy layer" - ); - continue; - } - - let Some(profile) = get_default_profile(profile_id) else { + let provider_type = provider.r#type.trim(); + let Some(profile) = get_default_profile(provider_type) else { warn!( provider_name = %name, - profile_id, - "provider profile id is unknown; skipping provider policy layer" + provider_type, + "provider type has no default profile; skipping provider policy layer" ); continue; }; @@ -496,6 +486,14 @@ async fn profile_provider_policy_layers( Ok(layers) } +fn sandbox_uses_provider_profile_policy(sandbox: &Sandbox) -> bool { + sandbox + .spec + .as_ref() + .and_then(|spec| spec.features.as_ref()) + .is_some_and(|features| features.provider_profile_policy) +} + pub(super) async fn handle_get_gateway_config( state: &Arc, _request: Request, @@ -2688,7 +2686,7 @@ mod tests { assert!(loaded.spec.unwrap().policy.is_none()); } - fn test_provider(name: &str, profile_enabled: bool) -> Provider { + fn test_provider(name: &str, provider_type: &str) -> Provider { Provider { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("provider-{name}"), @@ -2696,28 +2694,22 @@ mod tests { created_at_ms: 1000000, labels: HashMap::new(), }), - r#type: "github".to_string(), + r#type: provider_type.to_string(), credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) .collect(), config: HashMap::new(), - profile_id: if profile_enabled { - "github".to_string() - } else { - String::new() - }, - profile_policy_enabled: profile_enabled, } } #[tokio::test] - async fn provider_policy_layers_skip_legacy_providers() { + async fn provider_policy_layers_skip_unknown_provider_types() { let store = Store::connect("sqlite::memory:").await.unwrap(); store - .put_message(&test_provider("legacy-github", false)) + .put_message(&test_provider("custom-provider", "custom")) .await .unwrap(); - let layers = profile_provider_policy_layers(&store, &["legacy-github".to_string()]) + let layers = profile_provider_policy_layers(&store, &["custom-provider".to_string()]) .await .unwrap(); @@ -2725,10 +2717,10 @@ mod tests { } #[tokio::test] - async fn provider_policy_layers_include_profile_enabled_providers() { + async fn provider_policy_layers_include_known_provider_profiles() { let store = Store::connect("sqlite::memory:").await.unwrap(); store - .put_message(&test_provider("work-github", true)) + .put_message(&test_provider("work-github", "github")) .await .unwrap(); @@ -2748,6 +2740,31 @@ mod tests { ); } + #[test] + fn sandbox_provider_profile_policy_requires_feature_flag() { + use openshell_core::proto::{SandboxFeatures, SandboxSpec}; + + let disabled = Sandbox { + spec: Some(SandboxSpec { + features: None, + ..Default::default() + }), + ..Default::default() + }; + assert!(!sandbox_uses_provider_profile_policy(&disabled)); + + let enabled = Sandbox { + spec: Some(SandboxSpec { + features: Some(SandboxFeatures { + provider_profile_policy: true, + }), + ..Default::default() + }), + ..Default::default() + }; + assert!(sandbox_uses_provider_profile_policy(&enabled)); + } + #[tokio::test] async fn sandbox_policy_backfill_on_update_when_no_baseline() { use openshell_core::proto::{FilesystemPolicy, LandlockPolicy, SandboxPhase, SandboxSpec}; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 2418c8f71..7d5ac9b92 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -155,8 +155,6 @@ pub(super) async fn update_provider_record( r#type: existing.r#type, credentials: merge_map(existing.credentials, provider.credentials), config: merge_map(existing.config, provider.config), - profile_id: existing.profile_id, - profile_policy_enabled: existing.profile_policy_enabled, }; // Ensure metadata is valid (defense in depth - existing.metadata should always be valid) @@ -431,8 +429,6 @@ mod tests { ] .into_iter() .collect(), - profile_id: String::new(), - profile_policy_enabled: false, } } @@ -478,8 +474,6 @@ mod tests { .collect(), config: std::iter::once(("endpoint".to_string(), "https://gitlab.com".to_string())) .collect(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -548,8 +542,6 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -574,8 +566,6 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -604,8 +594,6 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -653,8 +641,6 @@ mod tests { r#type: String::new(), credentials: std::iter::once(("SECONDARY".to_string(), String::new())).collect(), config: std::iter::once(("region".to_string(), String::new())).collect(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -706,8 +692,6 @@ mod tests { r#type: String::new(), credentials: HashMap::new(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -737,8 +721,6 @@ mod tests { r#type: "openai".to_string(), credentials: HashMap::new(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -770,8 +752,6 @@ mod tests { r#type: String::new(), credentials: std::iter::once((oversized_key, "value".to_string())).collect(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -809,8 +789,6 @@ mod tests { "https://api.anthropic.com".to_string(), )) .collect(), - profile_id: String::new(), - profile_policy_enabled: false, }; create_provider_record(&store, provider).await.unwrap(); @@ -851,8 +829,6 @@ mod tests { .into_iter() .collect(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }; create_provider_record(&store, provider).await.unwrap(); @@ -883,8 +859,6 @@ mod tests { )) .collect(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -902,8 +876,6 @@ mod tests { credentials: std::iter::once(("GITLAB_TOKEN".to_string(), "glpat-xyz".to_string())) .collect(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -935,8 +907,6 @@ mod tests { credentials: std::iter::once(("SHARED_KEY".to_string(), "first-value".to_string())) .collect(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -957,8 +927,6 @@ mod tests { )) .collect(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await @@ -995,8 +963,6 @@ mod tests { )) .collect(), config: HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, }, ) .await diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 22b1fb173..160b7e031 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -878,8 +878,6 @@ mod tests { r#type: provider_type.to_string(), credentials, config, - profile_id: String::new(), - profile_policy_enabled: false, } } diff --git a/crates/openshell-server/src/inference.rs b/crates/openshell-server/src/inference.rs index 2d40f17d5..b52700f0d 100644 --- a/crates/openshell-server/src/inference.rs +++ b/crates/openshell-server/src/inference.rs @@ -511,8 +511,6 @@ mod tests { r#type: provider_type.to_string(), credentials: std::iter::once((key_name.to_string(), key_value.to_string())).collect(), config: std::collections::HashMap::new(), - profile_id: String::new(), - profile_policy_enabled: false, } } @@ -677,8 +675,6 @@ mod tests { "https://station.example.com/v1".to_string(), )) .collect(), - profile_id: String::new(), - profile_policy_enabled: false, }; store .put_message(&provider) @@ -753,8 +749,6 @@ mod tests { credentials: std::iter::once(("OPENAI_API_KEY".to_string(), "sk-rotated".to_string())) .collect(), config: provider.config.clone(), - profile_id: provider.profile_id.clone(), - profile_policy_enabled: provider.profile_policy_enabled, }; store .put_message(&rotated_provider) diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index ee9ee4b78..933c6e884 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1568,9 +1568,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { }), r#type: ptype.clone(), credentials: credentials.clone(), - config: HashMap::default(), - profile_id: ptype.clone(), - profile_policy_enabled: true, + config: Default::default(), }), }; @@ -1660,9 +1658,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { }), r#type: ptype, credentials, - config: HashMap::default(), - profile_id: String::new(), - profile_policy_enabled: false, + config: Default::default(), }), }; diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 052688ef9..b0f589747 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -54,10 +54,14 @@ openshell provider create --name my-api --type generic --credential API_KEY This looks up the current value of `$API_KEY` in your shell and stores it. -Providers created by current clients use the default profile for their type when -one exists. Profile-managed providers add their known network endpoints to the -sandbox's effective policy when attached at sandbox creation. Providers created -before profile support remain credential-only until recreated or upgraded. +Provider profile metadata is available for known provider types. A sandbox uses +provider profile network policy only when you opt in at sandbox creation: + +```shell +openshell sandbox create --provider my-claude --provider-profile-policy -- claude +``` + +Without `--provider-profile-policy`, provider behavior remains credential-only. ## Manage Providers diff --git a/proto/datamodel.proto b/proto/datamodel.proto index 3aa2c3df8..534b043ae 100644 --- a/proto/datamodel.proto +++ b/proto/datamodel.proto @@ -34,14 +34,4 @@ message Provider { map credentials = 3; // Non-secret provider configuration. map config = 4; - // Provider type profile used for profile-backed policy composition. - // - // Empty means the provider is legacy/manual and must not contribute - // provider-generated network policy. - string profile_id = 5; - // Whether this provider contributes its profile's generated policy layer. - // - // This is explicit for backwards compatibility: existing provider records - // deserialize to false and keep the legacy credential-only behavior. - bool profile_policy_enabled = 6; } From 44b64913530ad409ef43c6ae0bfc4dcf2d0d24fb Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 28 Apr 2026 15:54:11 -0700 Subject: [PATCH 3/7] feat(providers): gate profile composition by global setting --- Cargo.lock | 2 + architecture/gateway-settings.md | 28 +- architecture/sandbox-providers.md | 40 +- crates/openshell-cli/src/main.rs | 16 +- crates/openshell-cli/src/run.rs | 12 +- .../sandbox_create_lifecycle_integration.rs | 5 - crates/openshell-core/src/settings.rs | 19 +- crates/openshell-providers/Cargo.toml | 2 + .../profiles/anthropic.yaml | 22 + .../openshell-providers/profiles/claude.yaml | 32 + .../openshell-providers/profiles/codex.yaml | 22 + .../openshell-providers/profiles/copilot.yaml | 26 + .../openshell-providers/profiles/generic.yaml | 7 + .../openshell-providers/profiles/github.yaml | 26 + .../openshell-providers/profiles/gitlab.yaml | 26 + .../openshell-providers/profiles/nvidia.yaml | 22 + .../openshell-providers/profiles/openai.yaml | 22 + .../profiles/opencode.yaml | 22 + .../openshell-providers/profiles/outlook.yaml | 7 + crates/openshell-providers/src/profiles.rs | 551 +++++++----------- crates/openshell-server/src/grpc/policy.rs | 55 +- docs/sandboxes/manage-providers.mdx | 13 +- 22 files changed, 535 insertions(+), 442 deletions(-) create mode 100644 crates/openshell-providers/profiles/anthropic.yaml create mode 100644 crates/openshell-providers/profiles/claude.yaml create mode 100644 crates/openshell-providers/profiles/codex.yaml create mode 100644 crates/openshell-providers/profiles/copilot.yaml create mode 100644 crates/openshell-providers/profiles/generic.yaml create mode 100644 crates/openshell-providers/profiles/github.yaml create mode 100644 crates/openshell-providers/profiles/gitlab.yaml create mode 100644 crates/openshell-providers/profiles/nvidia.yaml create mode 100644 crates/openshell-providers/profiles/openai.yaml create mode 100644 crates/openshell-providers/profiles/opencode.yaml create mode 100644 crates/openshell-providers/profiles/outlook.yaml diff --git a/Cargo.lock b/Cargo.lock index b4101d140..9ba561692 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3265,6 +3265,8 @@ name = "openshell-providers" version = "0.0.0" dependencies = [ "openshell-core", + "serde", + "serde_yml", "thiserror 2.0.18", ] diff --git a/architecture/gateway-settings.md b/architecture/gateway-settings.md index 4ae191de0..2d1402b97 100644 --- a/architecture/gateway-settings.md +++ b/architecture/gateway-settings.md @@ -30,9 +30,8 @@ The `REGISTERED_SETTINGS` static array defines the allowed setting keys and thei ```rust pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ - RegisteredSetting { key: "log_level", kind: SettingValueKind::String }, - RegisteredSetting { key: "dummy_int", kind: SettingValueKind::Int }, - RegisteredSetting { key: "dummy_bool", kind: SettingValueKind::Bool }, + RegisteredSetting { key: "use_providers_v2", kind: SettingValueKind::Bool }, + RegisteredSetting { key: "ocsf_json_enabled", kind: SettingValueKind::Bool }, ]; ``` @@ -373,15 +372,14 @@ Set a single setting key at sandbox or global scope. ```bash # Sandbox-scoped -openshell settings set my-sandbox --key log_level --value debug +openshell settings set my-sandbox --key ocsf_json_enabled --value true # Global (requires confirmation) -openshell settings set --global --key log_level --value warn -openshell settings set --global --key dummy_bool --value yes -openshell settings set --global --key dummy_int --value 42 +openshell settings set --global --key use_providers_v2 --value true +openshell settings set --global --key ocsf_json_enabled --value true # Skip confirmation -openshell settings set --global --key log_level --value info --yes +openshell settings set --global --key use_providers_v2 --value true --yes ``` Value parsing is type-aware: bool keys accept `true/false/yes/no/1/0/on/off` via `parse_bool_like()`. Int keys parse as base-10 `i64`. String keys accept any value. @@ -392,7 +390,7 @@ Delete a setting key from the specified scope. ```bash # Global delete (unlocks sandbox control) -openshell settings delete --global --key log_level --yes +openshell settings delete --global --key use_providers_v2 --yes ``` ### `policy set --global --policy FILE [--yes]` @@ -502,26 +500,26 @@ Settings are refreshed on each 2-second polling tick alongside the sandbox list ## Data Flow: Setting a Global Key -End-to-end trace for `openshell settings set --global --key log_level --value debug --yes`: +End-to-end trace for `openshell settings set --global --key use_providers_v2 --value true --yes`: 1. **CLI** (`crates/openshell-cli/src/run.rs` -- `gateway_setting_set()`): - - `parse_cli_setting_value("log_level", "debug")` -- looks up `SettingValueKind::String` in the registry, wraps as `SettingValue { string_value: "debug" }` + - `parse_cli_setting_value("use_providers_v2", "true")` -- looks up `SettingValueKind::Bool` in the registry, wraps as `SettingValue { bool_value: true }` - `confirm_global_setting_takeover()` -- skipped because `--yes` - - Sends `UpdateSettingsRequest { setting_key: "log_level", setting_value: Some(...), global: true }` + - Sends `UpdateSettingsRequest { setting_key: "use_providers_v2", setting_value: Some(...), global: true }` 2. **Gateway** (`crates/openshell-server/src/grpc.rs` -- `update_settings()`): - Acquires `settings_mutex` for the duration of the operation - Detects `global=true`, `has_setting=true` - - `validate_registered_setting_key("log_level")` -- passes (key is in registry) + - `validate_registered_setting_key("use_providers_v2")` -- passes (key is in registry) - `load_global_settings()` -- reads `gateway_settings` record from store - - `proto_setting_to_stored()` -- converts proto value to `StoredSettingValue::String("debug")` + - `proto_setting_to_stored()` -- converts proto value to `StoredSettingValue::Bool(true)` - `upsert_setting_value()` -- inserts into `BTreeMap`, returns `true` (changed) - Increments `revision`, calls `save_global_settings()` - Returns `UpdateSettingsResponse { settings_revision: N }` 3. **Sandbox** (next poll tick in `run_policy_poll_loop()`): - `poll_settings(sandbox_id)` returns new `config_revision` - - `log_setting_changes()` logs: `Setting changed key="log_level" old="" new="debug"` + - `log_setting_changes()` logs: `Setting changed key="use_providers_v2" old="" new="true"` - `policy_hash` unchanged -- no OPA reload - Updates tracked `current_config_revision` and `current_settings` diff --git a/architecture/sandbox-providers.md b/architecture/sandbox-providers.md index 5111da8d5..ec5ee7468 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -48,21 +48,24 @@ The gRPC surface is defined in `proto/openshell.proto`: ## Provider Type Profiles -Provider type profiles are declarative metadata for provider types. Profiles live in -`crates/openshell-providers` and are exposed through `ListProviderProfiles` and -`GetProviderProfile`. They describe credential names and environment variables, -known network endpoints, expected binaries, category, and whether the provider is +Provider type profiles are declarative metadata for provider types. Built-in profiles +live as one YAML document per provider under +`crates/openshell-providers/profiles/` and are exposed through +`ListProviderProfiles` and `GetProviderProfile`. The profile loader validates the +YAML catalog and materializes the same proto-backed shape that future API imports +will accept. Profiles describe credential names and environment variables, known +network endpoints, expected binaries, category, and whether the provider is inference-capable. Profiles are additive to provider records. A provider record with only `type`, `credentials`, and `config` can be matched to built-in profile metadata by -`provider.type`. Profile-generated policy is still sandbox-scoped: the gateway composes -provider profile rules only for sandboxes whose `SandboxSpec.features.provider_profile_policy` -flag is true. +`provider.type`. Profile-generated policy is still opt-in: the gateway composes provider +profile rules only when the gateway-global `use_providers_v2` setting is true. -This keeps the compatibility boundary on the sandbox that consumes effective policy, -rather than on provider records shared by many sandboxes. A provider can be attached to -both legacy sandboxes and profile-policy sandboxes at the same time. +This keeps the compatibility boundary at the gateway. A gateway without +`use_providers_v2=true` keeps the existing credential-only provider behavior, while a +gateway with the flag enabled routes all attached known provider types through the +profile-backed policy path. ### Provider Policy Composition @@ -79,19 +82,22 @@ mutate the user-authored policy layer. Provider-generated rules are re-added dur composition for each attached provider whose type has a built-in profile. Provider-generated network rules use reserved `_provider_*` names derived from the -provider record name. If a user policy already has the same key, composition keeps the -user entry and adds a numeric suffix to the provider entry. Duplicate host/port -endpoints across user and provider rules are valid; OPA evaluates all rules, so allow -decisions are the union of matching allows and deny rules continue to win globally. +provider record name. If a user or global policy already has the same key, composition +keeps the policy entry and adds a numeric suffix to the provider entry. Duplicate +host/port endpoints across policy and provider rules are valid; OPA evaluates all +rules, so allow decisions are the union of matching allows and deny rules continue to +win globally. -Gateway-global policy remains a full override. When a global policy is active, the -gateway serves the global policy as-is rather than composing provider layers into it, -and the existing blocks on sandbox-scoped policy mutations remain unchanged. +Gateway-global policy still overrides sandbox-authored policy. When `use_providers_v2` +is true, provider layers compose JIT onto the effective policy source, whether that +source is sandbox-scoped or global. The composed payload is derived data and is not +persisted as a policy revision. ## Components - `crates/openshell-providers` - canonical provider type normalization and command detection, + - YAML-backed built-in provider profiles, - provider registry and per-provider discovery plugins, - shared discovery engine and context abstraction for testability. - `crates/openshell-cli` diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 7373fc84f..624c315b2 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -264,11 +264,10 @@ 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 my-sandbox --key log_level --value debug - $ openshell settings set --global --key log_level --value warn - $ openshell settings set --global --key dummy_bool --value yes - $ openshell settings set --global --key dummy_int --value 42 - $ openshell settings delete --global --key log_level + $ openshell settings set --global --key use_providers_v2 --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 use_providers_v2 "; const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m @@ -1206,10 +1205,6 @@ enum SandboxCommands { #[arg(long, overrides_with = "auto_providers")] no_auto_providers: bool, - /// Opt into provider profile network policy composition for this sandbox. - #[arg(long)] - provider_profile_policy: bool, - /// Attach labels to the sandbox (key=value format, repeatable). #[arg(long = "label")] labels: Vec, @@ -2332,7 +2327,6 @@ async fn main() -> Result<()> { no_bootstrap, auto_providers, no_auto_providers, - provider_profile_policy, labels, command, } => { @@ -2428,7 +2422,6 @@ async fn main() -> Result<()> { tty_override, Some(false), auto_providers_override, - provider_profile_policy, &labels_map, &tls, )) @@ -2453,7 +2446,6 @@ async fn main() -> Result<()> { tty_override, bootstrap_override, auto_providers_override, - provider_profile_policy, )) .await?; } diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index a60d79f55..fedbf7d44 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -29,9 +29,9 @@ use openshell_core::proto::{ GetGatewayConfigRequest, GetProviderRequest, GetSandboxConfigRequest, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, HealthRequest, ListProviderProfilesRequest, ListProvidersRequest, ListSandboxPoliciesRequest, ListSandboxesRequest, PolicySource, - PolicyStatus, Provider, ProviderProfile, RejectDraftChunkRequest, Sandbox, SandboxFeatures, - SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, - SettingScope, SettingValue, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest, + PolicyStatus, Provider, ProviderProfile, RejectDraftChunkRequest, Sandbox, SandboxPhase, + SandboxPolicy, SandboxSpec, SandboxTemplate, SetClusterInferenceRequest, SettingScope, + SettingValue, UpdateConfigRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, setting_value, }; use openshell_core::settings::{self, SettingValueKind}; @@ -1940,7 +1940,6 @@ pub async fn sandbox_create_with_bootstrap( tty_override: Option, bootstrap_override: Option, auto_providers_override: Option, - provider_profile_policy: bool, ) -> Result<()> { if !crate::bootstrap::confirm_bootstrap(bootstrap_override)? { return Err(miette::miette!( @@ -1977,7 +1976,6 @@ pub async fn sandbox_create_with_bootstrap( tty_override, Some(false), auto_providers_override, - provider_profile_policy, &HashMap::new(), &tls, )) @@ -2035,7 +2033,6 @@ pub async fn sandbox_create( tty_override: Option, bootstrap_override: Option, auto_providers_override: Option, - provider_profile_policy: bool, labels: &HashMap, tls: &TlsOptions, ) -> Result<()> { @@ -2141,9 +2138,6 @@ pub async fn sandbox_create( policy, providers: configured_providers, template, - features: provider_profile_policy.then_some(SandboxFeatures { - provider_profile_policy, - }), ..SandboxSpec::default() }), name: name.unwrap_or_default().to_string(), diff --git a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs index 89ea33131..fde07dacb 100644 --- a/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs +++ b/crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs @@ -595,7 +595,6 @@ async fn sandbox_create_keeps_command_sessions_by_default() { Some(false), Some(false), Some(false), - false, &HashMap::new(), &tls, ) @@ -638,7 +637,6 @@ async fn sandbox_create_deletes_command_sessions_with_no_keep() { Some(false), Some(false), Some(false), - false, &HashMap::new(), &tls, ) @@ -684,7 +682,6 @@ async fn sandbox_create_deletes_shell_sessions_with_no_keep() { Some(true), Some(false), Some(false), - false, &HashMap::new(), &tls, ) @@ -730,7 +727,6 @@ async fn sandbox_create_keeps_sandbox_with_hidden_keep_flag() { Some(false), Some(false), Some(false), - false, &HashMap::new(), &tls, ) @@ -773,7 +769,6 @@ async fn sandbox_create_keeps_sandbox_with_forwarding() { Some(false), Some(false), Some(false), - false, &HashMap::new(), &tls, ) diff --git a/crates/openshell-core/src/settings.rs b/crates/openshell-core/src/settings.rs index 995fe6e2a..d493b27de 100644 --- a/crates/openshell-core/src/settings.rs +++ b/crates/openshell-core/src/settings.rs @@ -48,7 +48,15 @@ pub struct 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 USE_PROVIDERS_V2_KEY: &str = "use_providers_v2"; + pub const REGISTERED_SETTINGS: &[RegisteredSetting] = &[ + // Gateway-level opt-in for provider profile policy composition. Defaults + // to false when unset. + RegisteredSetting { + key: USE_PROVIDERS_V2_KEY, + kind: SettingValueKind::Bool, + }, // 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). @@ -99,8 +107,8 @@ pub fn parse_bool_like(raw: &str) -> Option { #[cfg(test)] mod tests { use super::{ - REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, parse_bool_like, - registered_keys_csv, setting_for_key, + REGISTERED_SETTINGS, RegisteredSetting, SettingValueKind, USE_PROVIDERS_V2_KEY, + parse_bool_like, registered_keys_csv, setting_for_key, }; #[cfg(feature = "dev-settings")] @@ -123,6 +131,13 @@ mod tests { assert!(setting_for_key("policy").is_none()); } + #[test] + fn setting_for_key_returns_use_providers_v2() { + let setting = + setting_for_key(USE_PROVIDERS_V2_KEY).expect("use_providers_v2 should be registered"); + assert_eq!(setting.kind, SettingValueKind::Bool); + } + // ---- parse_bool_like ---- #[test] diff --git a/crates/openshell-providers/Cargo.toml b/crates/openshell-providers/Cargo.toml index 41f9ed6c0..1a3bda8f6 100644 --- a/crates/openshell-providers/Cargo.toml +++ b/crates/openshell-providers/Cargo.toml @@ -12,6 +12,8 @@ repository.workspace = true [dependencies] openshell-core = { path = "../openshell-core" } +serde = { workspace = true } +serde_yml = { workspace = true } thiserror = { workspace = true } [lints] diff --git a/crates/openshell-providers/profiles/anthropic.yaml b/crates/openshell-providers/profiles/anthropic.yaml new file mode 100644 index 000000000..64aecac42 --- /dev/null +++ b/crates/openshell-providers/profiles/anthropic.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: anthropic +display_name: Anthropic API +description: Anthropic API access for Claude models +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 +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/crates/openshell-providers/profiles/claude.yaml b/crates/openshell-providers/profiles/claude.yaml new file mode 100644 index 000000000..b8e24e29c --- /dev/null +++ b/crates/openshell-providers/profiles/claude.yaml @@ -0,0 +1,32 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: claude +display_name: Claude Code +description: Claude Code CLI +category: inference +inference_capable: true +credentials: + - name: api_key + description: Anthropic API key used by Claude Code + env_vars: [ANTHROPIC_API_KEY, CLAUDE_API_KEY] + required: true + auth_style: header + header_name: x-api-key +endpoints: + - host: api.anthropic.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: statsig.anthropic.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: sentry.io + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/claude, /usr/local/bin/claude] diff --git a/crates/openshell-providers/profiles/codex.yaml b/crates/openshell-providers/profiles/codex.yaml new file mode 100644 index 000000000..6cc7d3590 --- /dev/null +++ b/crates/openshell-providers/profiles/codex.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: codex +display_name: Codex +description: Codex CLI using OpenAI-compatible API credentials +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 +endpoints: + - host: api.openai.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/codex, /usr/local/bin/codex] diff --git a/crates/openshell-providers/profiles/copilot.yaml b/crates/openshell-providers/profiles/copilot.yaml new file mode 100644 index 000000000..2d448924b --- /dev/null +++ b/crates/openshell-providers/profiles/copilot.yaml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: copilot +display_name: GitHub Copilot +description: GitHub Copilot tooling +category: inference +credentials: + - name: github_token + description: GitHub token used by Copilot tooling + env_vars: [COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN] + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: api.github.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: [/usr/bin/copilot, /usr/local/bin/copilot] diff --git a/crates/openshell-providers/profiles/generic.yaml b/crates/openshell-providers/profiles/generic.yaml new file mode 100644 index 000000000..470885ee2 --- /dev/null +++ b/crates/openshell-providers/profiles/generic.yaml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: generic +display_name: Generic +description: Generic provider record without managed policy defaults +category: custom diff --git a/crates/openshell-providers/profiles/github.yaml b/crates/openshell-providers/profiles/github.yaml new file mode 100644 index 000000000..fa1e8a569 --- /dev/null +++ b/crates/openshell-providers/profiles/github.yaml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: github +display_name: GitHub +description: GitHub API and Git operations +category: source-control +credentials: + - name: api_token + description: GitHub token + env_vars: [GITHUB_TOKEN, GH_TOKEN] + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: api.github.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: github.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: [/usr/bin/gh, /usr/local/bin/gh, /usr/bin/git, /usr/local/bin/git] diff --git a/crates/openshell-providers/profiles/gitlab.yaml b/crates/openshell-providers/profiles/gitlab.yaml new file mode 100644 index 000000000..19894fe14 --- /dev/null +++ b/crates/openshell-providers/profiles/gitlab.yaml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: gitlab +display_name: GitLab +description: GitLab API and Git operations +category: source-control +credentials: + - name: api_token + description: GitLab token + env_vars: [GITLAB_TOKEN, GLAB_TOKEN, CI_JOB_TOKEN] + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: gitlab.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: api.gitlab.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/glab, /usr/local/bin/glab, /usr/bin/git, /usr/local/bin/git] diff --git a/crates/openshell-providers/profiles/nvidia.yaml b/crates/openshell-providers/profiles/nvidia.yaml new file mode 100644 index 000000000..42ea7f7df --- /dev/null +++ b/crates/openshell-providers/profiles/nvidia.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: nvidia +display_name: NVIDIA +description: NVIDIA inference endpoints +category: inference +inference_capable: true +credentials: + - name: api_key + description: NVIDIA API key + env_vars: [NVIDIA_API_KEY] + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: integrate.api.nvidia.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/curl, /usr/local/bin/curl] diff --git a/crates/openshell-providers/profiles/openai.yaml b/crates/openshell-providers/profiles/openai.yaml new file mode 100644 index 000000000..632687f5e --- /dev/null +++ b/crates/openshell-providers/profiles/openai.yaml @@ -0,0 +1,22 @@ +# 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 API access +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 +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/crates/openshell-providers/profiles/opencode.yaml b/crates/openshell-providers/profiles/opencode.yaml new file mode 100644 index 000000000..1a85ba2e3 --- /dev/null +++ b/crates/openshell-providers/profiles/opencode.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: opencode +display_name: OpenCode +description: OpenCode-compatible inference provider +category: inference +inference_capable: true +credentials: + - name: api_key + description: OpenCode-compatible API key + env_vars: [OPENCODE_API_KEY, OPENROUTER_API_KEY, OPENAI_API_KEY] + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: api.openai.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/opencode, /usr/local/bin/opencode] diff --git a/crates/openshell-providers/profiles/outlook.yaml b/crates/openshell-providers/profiles/outlook.yaml new file mode 100644 index 000000000..6295bcc59 --- /dev/null +++ b/crates/openshell-providers/profiles/outlook.yaml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: outlook +display_name: Outlook +description: Outlook provider record without managed policy defaults +category: messaging diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 174602c9c..886c64f85 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -8,47 +8,93 @@ use openshell_core::proto::{ NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderProfile, ProviderProfileCredential, }; +use serde::Deserialize; +use std::collections::HashSet; +use std::sync::OnceLock; + +const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ + include_str!("../profiles/anthropic.yaml"), + include_str!("../profiles/claude.yaml"), + include_str!("../profiles/codex.yaml"), + include_str!("../profiles/copilot.yaml"), + include_str!("../profiles/generic.yaml"), + include_str!("../profiles/github.yaml"), + include_str!("../profiles/gitlab.yaml"), + include_str!("../profiles/nvidia.yaml"), + include_str!("../profiles/openai.yaml"), + include_str!("../profiles/opencode.yaml"), + include_str!("../profiles/outlook.yaml"), +]; + +#[derive(Debug, thiserror::Error)] +pub enum ProfileError { + #[error("failed to parse provider profile YAML: {0}")] + Parse(#[from] serde_yml::Error), + #[error("provider profile id is required")] + MissingId, + #[error("duplicate provider profile id: {0}")] + DuplicateId(String), + #[error("provider profile '{id}' has invalid endpoint '{host}:{port}'")] + InvalidEndpoint { id: String, host: String, port: u32 }, + #[error("provider profile '{id}' has duplicate credential env var '{env_var}'")] + DuplicateCredentialEnvVar { id: String, env_var: String }, +} -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct CredentialProfile { - pub name: &'static str, - pub description: &'static str, - pub env_vars: &'static [&'static str], + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub env_vars: Vec, + #[serde(default)] pub required: bool, - pub auth_style: &'static str, - pub header_name: &'static str, - pub query_param: &'static str, + #[serde(default)] + pub auth_style: String, + #[serde(default)] + pub header_name: String, + #[serde(default)] + pub query_param: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct EndpointProfile { - pub host: &'static str, + pub host: String, pub port: u32, - pub protocol: &'static str, - pub access: &'static str, - pub enforcement: &'static str, + #[serde(default)] + pub protocol: String, + #[serde(default)] + pub access: String, + #[serde(default)] + pub enforcement: String, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct ProviderTypeProfile { - pub id: &'static str, - pub display_name: &'static str, - pub description: &'static str, - pub category: &'static str, - pub credentials: &'static [CredentialProfile], - pub endpoints: &'static [EndpointProfile], - pub binaries: &'static [&'static str], + pub id: String, + pub display_name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub category: String, + #[serde(default)] + pub credentials: Vec, + #[serde(default)] + pub endpoints: Vec, + #[serde(default)] + pub binaries: Vec, + #[serde(default)] pub inference_capable: bool, } impl ProviderTypeProfile { #[must_use] - pub fn credential_env_vars(&self) -> Vec<&'static str> { + pub fn credential_env_vars(&self) -> Vec<&str> { let mut vars = Vec::new(); - for credential in self.credentials { - for env_var in credential.env_vars { - if !vars.contains(env_var) { - vars.push(*env_var); + for credential in &self.credentials { + for env_var in &credential.env_vars { + if !vars.contains(&env_var.as_str()) { + vars.push(env_var.as_str()); } } } @@ -58,49 +104,29 @@ impl ProviderTypeProfile { #[must_use] pub fn to_proto(&self) -> ProviderProfile { ProviderProfile { - id: self.id.to_string(), - display_name: self.display_name.to_string(), - description: self.description.to_string(), - category: self.category.to_string(), + id: self.id.clone(), + display_name: self.display_name.clone(), + description: self.description.clone(), + category: self.category.clone(), credentials: self .credentials .iter() .map(|credential| ProviderProfileCredential { - name: credential.name.to_string(), - description: credential.description.to_string(), - env_vars: credential - .env_vars - .iter() - .map(|env_var| (*env_var).to_string()) - .collect(), + name: credential.name.clone(), + description: credential.description.clone(), + env_vars: credential.env_vars.clone(), required: credential.required, - auth_style: credential.auth_style.to_string(), - header_name: credential.header_name.to_string(), - query_param: credential.query_param.to_string(), - }) - .collect(), - endpoints: self - .endpoints - .iter() - .map(|endpoint| NetworkEndpoint { - host: endpoint.host.to_string(), - port: endpoint.port, - protocol: endpoint.protocol.to_string(), - tls: String::new(), - enforcement: endpoint.enforcement.to_string(), - access: endpoint.access.to_string(), - rules: Vec::new(), - allowed_ips: Vec::new(), - ports: Vec::new(), - deny_rules: Vec::new(), - allow_encoded_slash: false, + auth_style: credential.auth_style.clone(), + header_name: credential.header_name.clone(), + query_param: credential.query_param.clone(), }) .collect(), + endpoints: self.endpoints.iter().map(endpoint_to_proto).collect(), binaries: self .binaries .iter() .map(|path| NetworkBinary { - path: (*path).to_string(), + path: path.clone(), harness: false, }) .collect(), @@ -112,12 +138,12 @@ impl ProviderTypeProfile { pub fn network_policy_rule(&self, rule_name: &str) -> NetworkPolicyRule { NetworkPolicyRule { name: rule_name.to_string(), - endpoints: self.to_proto().endpoints, + endpoints: self.endpoints.iter().map(endpoint_to_proto).collect(), binaries: self .binaries .iter() .map(|path| NetworkBinary { - path: (*path).to_string(), + path: path.clone(), harness: false, }) .collect(), @@ -125,299 +151,83 @@ impl ProviderTypeProfile { } } -const CLAUDE_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { - name: "api_key", - description: "Anthropic API key used by Claude Code", - env_vars: &["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"], - required: true, - auth_style: "header", - header_name: "x-api-key", - query_param: "", -}]; - -const ANTHROPIC_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { - name: "api_key", - description: "Anthropic API key", - env_vars: &["ANTHROPIC_API_KEY"], - required: true, - auth_style: "header", - header_name: "x-api-key", - query_param: "", -}]; - -const OPENAI_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { - name: "api_key", - description: "OpenAI API key", - env_vars: &["OPENAI_API_KEY"], - required: true, - auth_style: "bearer", - header_name: "authorization", - query_param: "", -}]; - -const OPENCODE_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { - name: "api_key", - description: "OpenCode-compatible API key", - env_vars: &["OPENCODE_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY"], - required: true, - auth_style: "bearer", - header_name: "authorization", - query_param: "", -}]; - -const NVIDIA_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { - name: "api_key", - description: "NVIDIA API key", - env_vars: &["NVIDIA_API_KEY"], - required: true, - auth_style: "bearer", - header_name: "authorization", - query_param: "", -}]; - -const GITHUB_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { - name: "api_token", - description: "GitHub token", - env_vars: &["GITHUB_TOKEN", "GH_TOKEN"], - required: true, - auth_style: "bearer", - header_name: "authorization", - query_param: "", -}]; - -const COPILOT_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { - name: "github_token", - description: "GitHub token used by Copilot tooling", - env_vars: &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], - required: true, - auth_style: "bearer", - header_name: "authorization", - query_param: "", -}]; - -const GITLAB_CREDENTIALS: &[CredentialProfile] = &[CredentialProfile { - name: "api_token", - description: "GitLab token", - env_vars: &["GITLAB_TOKEN", "GLAB_TOKEN", "CI_JOB_TOKEN"], - required: true, - auth_style: "bearer", - header_name: "authorization", - query_param: "", -}]; - -const GENERIC_CREDENTIALS: &[CredentialProfile] = &[]; -const OUTLOOK_CREDENTIALS: &[CredentialProfile] = &[]; - -const CLAUDE_ENDPOINTS: &[EndpointProfile] = &[ - EndpointProfile { - host: "api.anthropic.com", - port: 443, - protocol: "rest", - access: "read-write", - enforcement: "enforce", - }, - EndpointProfile { - host: "statsig.anthropic.com", - port: 443, - protocol: "rest", - access: "read-write", - enforcement: "enforce", - }, - EndpointProfile { - host: "sentry.io", - port: 443, - protocol: "rest", - access: "read-write", - enforcement: "enforce", - }, -]; - -const ANTHROPIC_ENDPOINTS: &[EndpointProfile] = &[EndpointProfile { - host: "api.anthropic.com", - port: 443, - protocol: "rest", - access: "read-write", - enforcement: "enforce", -}]; +fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { + NetworkEndpoint { + host: endpoint.host.clone(), + port: endpoint.port, + protocol: endpoint.protocol.clone(), + tls: String::new(), + enforcement: endpoint.enforcement.clone(), + access: endpoint.access.clone(), + rules: Vec::new(), + allowed_ips: Vec::new(), + ports: Vec::new(), + deny_rules: Vec::new(), + allow_encoded_slash: false, + } +} -const OPENAI_ENDPOINTS: &[EndpointProfile] = &[EndpointProfile { - host: "api.openai.com", - port: 443, - protocol: "rest", - access: "read-write", - enforcement: "enforce", -}]; +pub fn parse_profile_yaml(input: &str) -> Result { + Ok(serde_yml::from_str::(input)?) +} -const NVIDIA_ENDPOINTS: &[EndpointProfile] = &[EndpointProfile { - host: "integrate.api.nvidia.com", - port: 443, - protocol: "rest", - access: "read-write", - enforcement: "enforce", -}]; +pub fn parse_profile_catalog_yamls( + inputs: &[&str], +) -> Result, ProfileError> { + let mut profiles = inputs + .iter() + .map(|input| parse_profile_yaml(input)) + .collect::, _>>()?; + validate_profiles(&profiles)?; + profiles.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(profiles) +} -const GITHUB_ENDPOINTS: &[EndpointProfile] = &[ - EndpointProfile { - host: "api.github.com", - port: 443, - protocol: "rest", - access: "read-write", - enforcement: "enforce", - }, - EndpointProfile { - host: "github.com", - port: 443, - protocol: "rest", - access: "read-only", - enforcement: "enforce", - }, -]; +fn validate_profiles(profiles: &[ProviderTypeProfile]) -> Result<(), ProfileError> { + let mut ids = HashSet::new(); + for profile in profiles { + if profile.id.trim().is_empty() { + return Err(ProfileError::MissingId); + } + if !ids.insert(profile.id.clone()) { + return Err(ProfileError::DuplicateId(profile.id.clone())); + } -const GITLAB_ENDPOINTS: &[EndpointProfile] = &[ - EndpointProfile { - host: "gitlab.com", - port: 443, - protocol: "rest", - access: "read-write", - enforcement: "enforce", - }, - EndpointProfile { - host: "api.gitlab.com", - port: 443, - protocol: "rest", - access: "read-write", - enforcement: "enforce", - }, -]; + let mut env_vars = HashSet::new(); + for credential in &profile.credentials { + for env_var in &credential.env_vars { + if !env_vars.insert(env_var) { + return Err(ProfileError::DuplicateCredentialEnvVar { + id: profile.id.clone(), + env_var: env_var.clone(), + }); + } + } + } -const EMPTY_ENDPOINTS: &[EndpointProfile] = &[]; + for endpoint in &profile.endpoints { + if endpoint.host.trim().is_empty() || endpoint.port == 0 || endpoint.port > 65_535 { + return Err(ProfileError::InvalidEndpoint { + id: profile.id.clone(), + host: endpoint.host.clone(), + port: endpoint.port, + }); + } + } + } + Ok(()) +} -const DEFAULT_PROFILES: &[ProviderTypeProfile] = &[ - ProviderTypeProfile { - id: "anthropic", - display_name: "Anthropic API", - description: "Anthropic API access for Claude models", - category: "inference", - credentials: ANTHROPIC_CREDENTIALS, - endpoints: ANTHROPIC_ENDPOINTS, - binaries: &["/usr/bin/curl", "/usr/local/bin/curl"], - inference_capable: true, - }, - ProviderTypeProfile { - id: "claude", - display_name: "Claude Code", - description: "Claude Code CLI", - category: "inference", - credentials: CLAUDE_CREDENTIALS, - endpoints: CLAUDE_ENDPOINTS, - binaries: &["/usr/bin/claude", "/usr/local/bin/claude"], - inference_capable: true, - }, - ProviderTypeProfile { - id: "codex", - display_name: "Codex", - description: "Codex CLI using OpenAI-compatible API credentials", - category: "inference", - credentials: OPENAI_CREDENTIALS, - endpoints: OPENAI_ENDPOINTS, - binaries: &["/usr/bin/codex", "/usr/local/bin/codex"], - inference_capable: true, - }, - ProviderTypeProfile { - id: "copilot", - display_name: "GitHub Copilot", - description: "GitHub Copilot tooling", - category: "inference", - credentials: COPILOT_CREDENTIALS, - endpoints: GITHUB_ENDPOINTS, - binaries: &["/usr/bin/copilot", "/usr/local/bin/copilot"], - inference_capable: false, - }, - ProviderTypeProfile { - id: "generic", - display_name: "Generic", - description: "Generic provider record without managed policy defaults", - category: "custom", - credentials: GENERIC_CREDENTIALS, - endpoints: EMPTY_ENDPOINTS, - binaries: &[], - inference_capable: false, - }, - ProviderTypeProfile { - id: "github", - display_name: "GitHub", - description: "GitHub API and Git operations", - category: "source-control", - credentials: GITHUB_CREDENTIALS, - endpoints: GITHUB_ENDPOINTS, - binaries: &[ - "/usr/bin/gh", - "/usr/local/bin/gh", - "/usr/bin/git", - "/usr/local/bin/git", - ], - inference_capable: false, - }, - ProviderTypeProfile { - id: "gitlab", - display_name: "GitLab", - description: "GitLab API and Git operations", - category: "source-control", - credentials: GITLAB_CREDENTIALS, - endpoints: GITLAB_ENDPOINTS, - binaries: &[ - "/usr/bin/glab", - "/usr/local/bin/glab", - "/usr/bin/git", - "/usr/local/bin/git", - ], - inference_capable: false, - }, - ProviderTypeProfile { - id: "nvidia", - display_name: "NVIDIA", - description: "NVIDIA inference endpoints", - category: "inference", - credentials: NVIDIA_CREDENTIALS, - endpoints: NVIDIA_ENDPOINTS, - binaries: &["/usr/bin/curl", "/usr/local/bin/curl"], - inference_capable: true, - }, - ProviderTypeProfile { - id: "openai", - display_name: "OpenAI", - description: "OpenAI API access", - category: "inference", - credentials: OPENAI_CREDENTIALS, - endpoints: OPENAI_ENDPOINTS, - binaries: &["/usr/bin/curl", "/usr/local/bin/curl"], - inference_capable: true, - }, - ProviderTypeProfile { - id: "opencode", - display_name: "OpenCode", - description: "OpenCode-compatible inference provider", - category: "inference", - credentials: OPENCODE_CREDENTIALS, - endpoints: OPENAI_ENDPOINTS, - binaries: &["/usr/bin/opencode", "/usr/local/bin/opencode"], - inference_capable: true, - }, - ProviderTypeProfile { - id: "outlook", - display_name: "Outlook", - description: "Outlook provider record without managed policy defaults", - category: "messaging", - credentials: OUTLOOK_CREDENTIALS, - endpoints: EMPTY_ENDPOINTS, - binaries: &[], - inference_capable: false, - }, -]; +static DEFAULT_PROFILES: OnceLock> = OnceLock::new(); #[must_use] -pub const fn default_profiles() -> &'static [ProviderTypeProfile] { +pub fn default_profiles() -> &'static [ProviderTypeProfile] { DEFAULT_PROFILES + .get_or_init(|| { + parse_profile_catalog_yamls(BUILT_IN_PROFILE_YAMLS) + .expect("built-in provider profiles must be valid YAML") + }) + .as_slice() } #[must_use] @@ -429,13 +239,16 @@ pub fn get_default_profile(id: &str) -> Option<&'static ProviderTypeProfile> { #[cfg(test)] mod tests { - use super::{default_profiles, get_default_profile}; + use super::{ + ProfileError, default_profiles, get_default_profile, parse_profile_catalog_yamls, + parse_profile_yaml, + }; #[test] fn default_profiles_are_sorted_by_id() { let ids = default_profiles() .iter() - .map(|profile| profile.id) + .map(|profile| profile.id.as_str()) .collect::>(); let mut sorted = ids.clone(); sorted.sort_unstable(); @@ -461,4 +274,52 @@ mod tests { vec!["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] ); } + + #[test] + fn parse_profile_yaml_reads_single_provider_document() { + let profile = parse_profile_yaml( + r#" +id: example +display_name: Example +credentials: + - name: api_key + env_vars: [EXAMPLE_API_KEY] +"#, + ) + .expect("profile should parse"); + + assert_eq!(profile.id, "example"); + assert_eq!(profile.credential_env_vars(), vec!["EXAMPLE_API_KEY"]); + } + + #[test] + fn parse_profile_catalog_yamls_rejects_duplicate_ids() { + let err = parse_profile_catalog_yamls(&[ + r#" +id: duplicate +display_name: First +"#, + r#" +id: duplicate +display_name: Second +"#, + ]) + .unwrap_err(); + + assert!(matches!(err, ProfileError::DuplicateId(id) if id == "duplicate")); + } + + #[test] + fn parse_profile_catalog_yamls_rejects_invalid_endpoint_ports() { + let err = parse_profile_catalog_yamls(&[r#" +id: bad-endpoint +display_name: Bad Endpoint +endpoints: + - host: api.example.com + port: 0 +"#]) + .unwrap_err(); + + assert!(matches!(err, ProfileError::InvalidEndpoint { id, .. } if id == "bad-endpoint")); + } } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 60703193a..c2d4e3415 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -329,7 +329,6 @@ pub(super) async fn handle_get_sandbox_config( .as_ref() .map(|spec| spec.providers.clone()) .unwrap_or_default(); - let provider_profile_policy_enabled = sandbox_uses_provider_profile_policy(&sandbox); // Try to get the latest policy from the policy history table. let latest = state @@ -407,6 +406,7 @@ 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(), sandbox.object_name()).await?; + let use_providers_v2 = bool_setting_enabled(&global_settings, settings::USE_PROVIDERS_V2_KEY)?; let mut global_policy_version: u32 = 0; @@ -426,10 +426,7 @@ pub(super) async fn handle_get_sandbox_config( } } - if policy_source == PolicySource::Sandbox - && provider_profile_policy_enabled - && let Some(source_policy) = policy.as_ref() - { + if use_providers_v2 && let Some(source_policy) = policy.as_ref() { let provider_layers = profile_provider_policy_layers(state.store.as_ref(), &sandbox_provider_names).await?; if !provider_layers.is_empty() { @@ -486,12 +483,14 @@ async fn profile_provider_policy_layers( Ok(layers) } -fn sandbox_uses_provider_profile_policy(sandbox: &Sandbox) -> bool { - sandbox - .spec - .as_ref() - .and_then(|spec| spec.features.as_ref()) - .is_some_and(|features| features.provider_profile_policy) +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( @@ -2741,28 +2740,22 @@ mod tests { } #[test] - fn sandbox_provider_profile_policy_requires_feature_flag() { - use openshell_core::proto::{SandboxFeatures, SandboxSpec}; + fn use_providers_v2_defaults_false_when_unset() { + assert!( + !bool_setting_enabled(&StoredSettings::default(), settings::USE_PROVIDERS_V2_KEY) + .unwrap() + ); + } - let disabled = Sandbox { - spec: Some(SandboxSpec { - features: None, - ..Default::default() - }), - ..Default::default() - }; - assert!(!sandbox_uses_provider_profile_policy(&disabled)); + #[test] + fn use_providers_v2_reads_global_bool_setting() { + let mut settings = StoredSettings::default(); + settings.settings.insert( + settings::USE_PROVIDERS_V2_KEY.to_string(), + StoredSettingValue::Bool(true), + ); - let enabled = Sandbox { - spec: Some(SandboxSpec { - features: Some(SandboxFeatures { - provider_profile_policy: true, - }), - ..Default::default() - }), - ..Default::default() - }; - assert!(sandbox_uses_provider_profile_policy(&enabled)); + assert!(bool_setting_enabled(&settings, settings::USE_PROVIDERS_V2_KEY).unwrap()); } #[tokio::test] diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index b0f589747..72b23e97d 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -54,14 +54,14 @@ openshell provider create --name my-api --type generic --credential API_KEY This looks up the current value of `$API_KEY` in your shell and stores it. -Provider profile metadata is available for known provider types. A sandbox uses -provider profile network policy only when you opt in at sandbox creation: +Provider profile metadata is available for known provider types. Provider profile +network policy is gateway opt-in: ```shell -openshell sandbox create --provider my-claude --provider-profile-policy -- claude +openshell settings set --global --key use_providers_v2 --value true ``` -Without `--provider-profile-policy`, provider behavior remains credential-only. +Without `use_providers_v2=true`, provider behavior remains credential-only. ## Manage Providers @@ -101,8 +101,9 @@ openshell sandbox create --provider my-claude --provider my-github -- claude Each `--provider` flag attaches one provider. The sandbox receives all credentials from every attached provider at runtime. Profile-managed providers -also contribute provider-generated network policy entries. Legacy providers keep -the previous behavior and only provide credentials. +also contribute provider-generated network policy entries when +`use_providers_v2` is enabled at the gateway. When the setting is disabled, +providers keep the previous behavior and only provide credentials. Providers cannot be added to a running sandbox. If you need to attach an From 140c080eb8df0d4bdc7e9f56ff32ece3ce13bf8b Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:51:32 -0700 Subject: [PATCH 4/7] feat(providers): add profile category enum --- architecture/sandbox-providers.md | 6 ++- crates/openshell-cli/src/run.rs | 19 +++++-- .../openshell-providers/profiles/claude.yaml | 2 +- .../openshell-providers/profiles/codex.yaml | 2 +- .../openshell-providers/profiles/copilot.yaml | 2 +- .../openshell-providers/profiles/generic.yaml | 7 --- .../openshell-providers/profiles/github.yaml | 2 +- .../openshell-providers/profiles/gitlab.yaml | 2 +- .../profiles/opencode.yaml | 2 +- crates/openshell-providers/src/profiles.rs | 50 ++++++++++++++++--- proto/openshell.proto | 14 +++++- 11 files changed, 81 insertions(+), 27 deletions(-) delete mode 100644 crates/openshell-providers/profiles/generic.yaml diff --git a/architecture/sandbox-providers.md b/architecture/sandbox-providers.md index ec5ee7468..ce0d588d0 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -55,7 +55,11 @@ live as one YAML document per provider under YAML catalog and materializes the same proto-backed shape that future API imports will accept. Profiles describe credential names and environment variables, known network endpoints, expected binaries, category, and whether the provider is -inference-capable. +inference-capable. Categories are a proto enum so clients can group and filter +provider types without parsing display strings. Current values are `other`, +`inference`, `agent`, `source_control`, `messaging`, `data`, and `knowledge`. +Agent profiles such as `claude`, `codex`, and `opencode` can still be +inference-capable when their tool talks to an inference API. Profiles are additive to provider records. A provider record with only `type`, `credentials`, and `config` can be matched to built-in profile metadata by diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index fedbf7d44..a5863601b 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -22,6 +22,7 @@ use openshell_bootstrap::{ get_gateway_metadata, list_gateways, load_active_gateway, remove_gateway_metadata, resolve_ssh_hostname, save_active_gateway, save_last_sandbox, store_gateway_metadata, }; +use openshell_core::proto::ProviderProfileCategory; use openshell_core::proto::{ ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, CreateProviderRequest, CreateSandboxRequest, DeleteProviderRequest, DeleteSandboxRequest, @@ -3729,12 +3730,12 @@ pub async fn provider_list_types(server: &str, tls: &TlsOptions) -> Result<()> { } println!("{}", "Available Provider Types:".cyan().bold()); - let mut current_category = String::new(); + let mut current_category = i32::MIN; for profile in profiles { if profile.category != current_category { - current_category = profile.category.clone(); + current_category = profile.category; println!(); - println!(" {}", display_provider_category(¤t_category).bold()); + println!(" {}", display_provider_category(current_category).bold()); } print_provider_type_row(&profile); } @@ -3742,8 +3743,16 @@ pub async fn provider_list_types(server: &str, tls: &TlsOptions) -> Result<()> { Ok(()) } -fn display_provider_category(category: &str) -> String { - category.replace('-', " ").to_ascii_uppercase() +fn display_provider_category(category: i32) -> &'static str { + match ProviderProfileCategory::try_from(category).unwrap_or(ProviderProfileCategory::Other) { + ProviderProfileCategory::Inference => "INFERENCE", + ProviderProfileCategory::Agent => "AGENT", + ProviderProfileCategory::SourceControl => "SOURCE CONTROL", + ProviderProfileCategory::Messaging => "MESSAGING", + ProviderProfileCategory::Data => "DATA", + ProviderProfileCategory::Knowledge => "KNOWLEDGE", + ProviderProfileCategory::Other | ProviderProfileCategory::Unspecified => "OTHER", + } } fn print_provider_type_row(profile: &ProviderProfile) { diff --git a/crates/openshell-providers/profiles/claude.yaml b/crates/openshell-providers/profiles/claude.yaml index b8e24e29c..7b526008f 100644 --- a/crates/openshell-providers/profiles/claude.yaml +++ b/crates/openshell-providers/profiles/claude.yaml @@ -4,7 +4,7 @@ id: claude display_name: Claude Code description: Claude Code CLI -category: inference +category: agent inference_capable: true credentials: - name: api_key diff --git a/crates/openshell-providers/profiles/codex.yaml b/crates/openshell-providers/profiles/codex.yaml index 6cc7d3590..c29d8878d 100644 --- a/crates/openshell-providers/profiles/codex.yaml +++ b/crates/openshell-providers/profiles/codex.yaml @@ -4,7 +4,7 @@ id: codex display_name: Codex description: Codex CLI using OpenAI-compatible API credentials -category: inference +category: agent inference_capable: true credentials: - name: api_key diff --git a/crates/openshell-providers/profiles/copilot.yaml b/crates/openshell-providers/profiles/copilot.yaml index 2d448924b..74f9a4cd8 100644 --- a/crates/openshell-providers/profiles/copilot.yaml +++ b/crates/openshell-providers/profiles/copilot.yaml @@ -4,7 +4,7 @@ id: copilot display_name: GitHub Copilot description: GitHub Copilot tooling -category: inference +category: agent credentials: - name: github_token description: GitHub token used by Copilot tooling diff --git a/crates/openshell-providers/profiles/generic.yaml b/crates/openshell-providers/profiles/generic.yaml deleted file mode 100644 index 470885ee2..000000000 --- a/crates/openshell-providers/profiles/generic.yaml +++ /dev/null @@ -1,7 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -id: generic -display_name: Generic -description: Generic provider record without managed policy defaults -category: custom diff --git a/crates/openshell-providers/profiles/github.yaml b/crates/openshell-providers/profiles/github.yaml index fa1e8a569..cc24ae922 100644 --- a/crates/openshell-providers/profiles/github.yaml +++ b/crates/openshell-providers/profiles/github.yaml @@ -4,7 +4,7 @@ id: github display_name: GitHub description: GitHub API and Git operations -category: source-control +category: source_control credentials: - name: api_token description: GitHub token diff --git a/crates/openshell-providers/profiles/gitlab.yaml b/crates/openshell-providers/profiles/gitlab.yaml index 19894fe14..6d6535c75 100644 --- a/crates/openshell-providers/profiles/gitlab.yaml +++ b/crates/openshell-providers/profiles/gitlab.yaml @@ -4,7 +4,7 @@ id: gitlab display_name: GitLab description: GitLab API and Git operations -category: source-control +category: source_control credentials: - name: api_token description: GitLab token diff --git a/crates/openshell-providers/profiles/opencode.yaml b/crates/openshell-providers/profiles/opencode.yaml index 1a85ba2e3..e8cf646dd 100644 --- a/crates/openshell-providers/profiles/opencode.yaml +++ b/crates/openshell-providers/profiles/opencode.yaml @@ -4,7 +4,7 @@ id: opencode display_name: OpenCode description: OpenCode-compatible inference provider -category: inference +category: agent inference_capable: true credentials: - name: api_key diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 886c64f85..3c080bcd8 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -6,9 +6,10 @@ #![allow(deprecated)] // NetworkBinary::harness remains in the public proto for compatibility. use openshell_core::proto::{ - NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderProfile, ProviderProfileCredential, + NetworkBinary, NetworkEndpoint, NetworkPolicyRule, ProviderProfile, ProviderProfileCategory, + ProviderProfileCredential, }; -use serde::Deserialize; +use serde::{Deserialize, Deserializer, de}; use std::collections::HashSet; use std::sync::OnceLock; @@ -17,7 +18,6 @@ const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../profiles/claude.yaml"), include_str!("../profiles/codex.yaml"), include_str!("../profiles/copilot.yaml"), - include_str!("../profiles/generic.yaml"), include_str!("../profiles/github.yaml"), include_str!("../profiles/gitlab.yaml"), include_str!("../profiles/nvidia.yaml"), @@ -75,8 +75,11 @@ pub struct ProviderTypeProfile { pub display_name: String, #[serde(default)] pub description: String, - #[serde(default)] - pub category: String, + #[serde( + default = "default_category", + deserialize_with = "deserialize_category" + )] + pub category: ProviderProfileCategory, #[serde(default)] pub credentials: Vec, #[serde(default)] @@ -107,7 +110,7 @@ impl ProviderTypeProfile { id: self.id.clone(), display_name: self.display_name.clone(), description: self.description.clone(), - category: self.category.clone(), + category: self.category as i32, credentials: self .credentials .iter() @@ -151,6 +154,33 @@ impl ProviderTypeProfile { } } +fn default_category() -> ProviderProfileCategory { + ProviderProfileCategory::Other +} + +fn deserialize_category<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let raw = String::deserialize(deserializer)?; + provider_profile_category_from_yaml(&raw) + .ok_or_else(|| de::Error::custom(format!("unsupported provider profile category: {raw}"))) +} + +fn provider_profile_category_from_yaml(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() { + "" => Some(ProviderProfileCategory::Other), + "other" => Some(ProviderProfileCategory::Other), + "inference" => Some(ProviderProfileCategory::Inference), + "agent" => Some(ProviderProfileCategory::Agent), + "source_control" => Some(ProviderProfileCategory::SourceControl), + "messaging" => Some(ProviderProfileCategory::Messaging), + "data" => Some(ProviderProfileCategory::Data), + "knowledge" => Some(ProviderProfileCategory::Knowledge), + _ => None, + } +} + fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { NetworkEndpoint { host: endpoint.host.clone(), @@ -239,6 +269,8 @@ pub fn get_default_profile(id: &str) -> Option<&'static ProviderTypeProfile> { #[cfg(test)] mod tests { + use openshell_core::proto::ProviderProfileCategory; + use super::{ ProfileError, default_profiles, get_default_profile, parse_profile_catalog_yamls, parse_profile_yaml, @@ -261,7 +293,10 @@ mod tests { let proto = profile.to_proto(); assert_eq!(proto.id, "github"); - assert_eq!(proto.category, "source-control"); + assert_eq!( + proto.category, + ProviderProfileCategory::SourceControl as i32 + ); assert_eq!(proto.endpoints.len(), 2); assert_eq!(proto.binaries.len(), 4); } @@ -289,6 +324,7 @@ credentials: .expect("profile should parse"); assert_eq!(profile.id, "example"); + assert_eq!(profile.category, ProviderProfileCategory::Other); assert_eq!(profile.credential_env_vars(), vec!["EXAMPLE_API_KEY"]); } diff --git a/proto/openshell.proto b/proto/openshell.proto index 0b5cd2b0f..529ee0629 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -592,12 +592,24 @@ message ProviderProfileCredential { string query_param = 7; } +// Stable provider profile categories used by clients for grouping and filtering. +enum ProviderProfileCategory { + PROVIDER_PROFILE_CATEGORY_UNSPECIFIED = 0; + PROVIDER_PROFILE_CATEGORY_OTHER = 1; + PROVIDER_PROFILE_CATEGORY_INFERENCE = 2; + PROVIDER_PROFILE_CATEGORY_AGENT = 3; + PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL = 4; + PROVIDER_PROFILE_CATEGORY_MESSAGING = 5; + PROVIDER_PROFILE_CATEGORY_DATA = 6; + PROVIDER_PROFILE_CATEGORY_KNOWLEDGE = 7; +} + // Provider type profile metadata exposed to clients. message ProviderProfile { string id = 1; string display_name = 2; string description = 3; - string category = 4; + ProviderProfileCategory category = 4; repeated ProviderProfileCredential credentials = 5; repeated openshell.sandbox.v1.NetworkEndpoint endpoints = 6; repeated openshell.sandbox.v1.NetworkBinary binaries = 7; From ad245c97681a141ffaf3a97630e3ba9349f380de Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:35:01 -0700 Subject: [PATCH 5/7] fix(providers): preserve global policy override --- crates/openshell-server/src/grpc/policy.rs | 118 ++++++++++++++++++++- 1 file changed, 117 insertions(+), 1 deletion(-) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index c2d4e3415..ce89b1719 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -426,7 +426,10 @@ pub(super) async fn handle_get_sandbox_config( } } - if use_providers_v2 && let Some(source_policy) = policy.as_ref() { + if use_providers_v2 + && !matches!(policy_source, PolicySource::Global) + && let Some(source_policy) = policy.as_ref() + { let provider_layers = profile_provider_policy_layers(state.store.as_ref(), &sandbox_provider_names).await?; if !provider_layers.is_empty() { @@ -2758,6 +2761,119 @@ mod tests { assert!(bool_setting_enabled(&settings, settings::USE_PROVIDERS_V2_KEY).unwrap()); } + #[tokio::test] + async fn global_policy_suppresses_provider_profile_layers_when_v2_enabled() { + use openshell_core::proto::{ + GetSandboxConfigRequest, NetworkEndpoint, NetworkPolicyRule, SandboxPhase, + SandboxPolicy, SandboxSpec, + }; + + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + + let sandbox_policy = SandboxPolicy { + network_policies: [( + "sandbox_only".to_string(), + NetworkPolicyRule { + name: "sandbox_only".to_string(), + endpoints: vec![NetworkEndpoint { + host: "sandbox.example.com".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }, + )] + .into_iter() + .collect(), + ..Default::default() + }; + let sandbox = Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sb-global-profile".to_string(), + name: "global-profile-sandbox".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(sandbox_policy), + providers: vec!["work-github".to_string()], + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + }; + state.store.put_message(&sandbox).await.unwrap(); + + let global_policy = SandboxPolicy { + network_policies: [( + "global_only".to_string(), + NetworkPolicyRule { + name: "global_only".to_string(), + endpoints: vec![NetworkEndpoint { + host: "global.example.com".to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }, + )] + .into_iter() + .collect(), + ..Default::default() + }; + let global_settings = StoredSettings { + revision: 1, + settings: [ + ( + settings::USE_PROVIDERS_V2_KEY.to_string(), + StoredSettingValue::Bool(true), + ), + ( + POLICY_SETTING_KEY.to_string(), + StoredSettingValue::Bytes(hex::encode(global_policy.encode_to_vec())), + ), + ] + .into_iter() + .collect(), + }; + save_global_settings(state.store.as_ref(), &global_settings) + .await + .unwrap(); + + let response = handle_get_sandbox_config( + &state, + Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-global-profile".to_string(), + }), + ) + .await + .unwrap() + .into_inner(); + + let effective_policy = response.policy.expect("global policy should be returned"); + assert_eq!(response.policy_source, PolicySource::Global as i32); + assert!( + effective_policy + .network_policies + .contains_key("global_only") + ); + assert!( + !effective_policy + .network_policies + .contains_key("sandbox_only") + ); + assert!( + !effective_policy + .network_policies + .contains_key("_provider_work_github") + ); + } + #[tokio::test] async fn sandbox_policy_backfill_on_update_when_no_baseline() { use openshell_core::proto::{FilesystemPolicy, LandlockPolicy, SandboxPhase, SandboxSpec}; From 3189d226df0fbdf2d231d2754777a5d88ab15b48 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:10:23 -0700 Subject: [PATCH 6/7] test(providers): cover profile composition behavior --- .../tests/provider_commands_integration.rs | 18 +- crates/openshell-server/src/grpc/policy.rs | 323 ++++++++++++++++++ crates/openshell-server/src/grpc/provider.rs | 96 +++++- 3 files changed, 435 insertions(+), 2 deletions(-) diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 8952c8f79..4ce046d68 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -205,7 +205,14 @@ impl OpenShell for TestOpenShell { &self, _request: tonic::Request, ) -> Result, Status> { - Err(Status::unimplemented("not implemented in test")) + Ok(Response::new( + openshell_core::proto::ListProviderProfilesResponse { + profiles: openshell_providers::default_profiles() + .iter() + .map(|profile| profile.to_proto()) + .collect(), + }, + )) } async fn get_provider_profile( @@ -543,6 +550,15 @@ async fn provider_cli_run_functions_support_full_crud_flow() { .expect("provider delete"); } +#[tokio::test] +async fn provider_list_types_cli_uses_profile_browsing_rpc() { + let ts = run_server().await; + + run::provider_list_types(&ts.endpoint, &ts.tls) + .await + .expect("provider list-types"); +} + #[tokio::test] async fn provider_create_rejects_key_only_credentials_without_local_env_value() { let ts = run_server().await; diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index ce89b1719..84e50dee7 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2703,6 +2703,80 @@ mod tests { } } + fn test_policy_with_rule(rule_name: &str, host: &str) -> ProtoSandboxPolicy { + ProtoSandboxPolicy { + network_policies: [( + rule_name.to_string(), + NetworkPolicyRule { + name: rule_name.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }, + )] + .into_iter() + .collect(), + ..Default::default() + } + } + + fn test_sandbox( + id: &str, + name: &str, + policy: ProtoSandboxPolicy, + providers: Vec, + ) -> Sandbox { + use openshell_core::proto::{SandboxPhase, SandboxSpec}; + + Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: id.to_string(), + name: name.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + }), + spec: Some(SandboxSpec { + policy: Some(policy), + providers, + ..Default::default() + }), + phase: SandboxPhase::Ready as i32, + ..Default::default() + } + } + + async fn enable_providers_v2(state: &Arc) { + let global_settings = StoredSettings { + revision: 1, + settings: [( + settings::USE_PROVIDERS_V2_KEY.to_string(), + StoredSettingValue::Bool(true), + )] + .into_iter() + .collect(), + }; + 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, + Request::new(GetSandboxConfigRequest { + sandbox_id: sandbox_id.to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .policy + .expect("sandbox config should include policy") + } + #[tokio::test] async fn provider_policy_layers_skip_unknown_provider_types() { let store = Store::connect("sqlite::memory:").await.unwrap(); @@ -2761,6 +2835,255 @@ mod tests { assert!(bool_setting_enabled(&settings, settings::USE_PROVIDERS_V2_KEY).unwrap()); } + #[tokio::test] + async fn sandbox_config_omits_provider_layers_when_v2_disabled() { + 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")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-v2-enabled", + "v2-enabled", + 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-enabled").await; + + assert!( + effective_policy + .network_policies + .contains_key("sandbox_only") + ); + assert!( + effective_policy + .network_policies + .contains_key("_provider_work_github") + ); + assert!( + effective_policy + .network_policies + .get("_provider_work_github") + .unwrap() + .endpoints + .iter() + .any(|endpoint| endpoint.host == "api.github.com") + ); + } + + #[tokio::test] + async fn sandbox_config_skips_profileless_provider_types_when_v2_enabled() { + let state = test_server_state().await; + enable_providers_v2(&state).await; + state + .store + .put_message(&test_provider("legacy-generic", "generic")) + .await + .unwrap(); + state + .store + .put_message(&test_provider("custom-provider", "custom")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-profileless", + "profileless", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["legacy-generic".to_string(), "custom-provider".to_string()], + )) + .await + .unwrap(); + + let effective_policy = get_sandbox_policy(&state, "sb-profileless").await; + + assert_eq!(effective_policy.network_policies.len(), 1); + assert!( + effective_policy + .network_policies + .contains_key("sandbox_only") + ); + } + + #[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")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-jit", + "jit", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string()], + )) + .await + .unwrap(); + + let effective_policy = get_sandbox_policy(&state, "sb-jit").await; + assert!( + effective_policy + .network_policies + .contains_key("_provider_work_github") + ); + + let persisted = state + .store + .get_latest_policy("sb-jit") + .await + .unwrap() + .expect("sandbox policy should be lazily backfilled"); + let persisted_policy = ProtoSandboxPolicy::decode(persisted.policy_payload.as_slice()) + .expect("persisted sandbox policy should decode"); + assert!( + persisted_policy + .network_policies + .contains_key("sandbox_only") + ); + assert!( + !persisted_policy + .network_policies + .contains_key("_provider_work_github") + ); + } + + #[tokio::test] + async fn sandbox_config_preserves_overlapping_user_and_provider_rules() { + let state = test_server_state().await; + enable_providers_v2(&state).await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-overlap", + "overlap", + test_policy_with_rule("_provider_work_github", "api.github.com"), + vec!["work-github".to_string()], + )) + .await + .unwrap(); + + let effective_policy = get_sandbox_policy(&state, "sb-overlap").await; + + assert!( + effective_policy + .network_policies + .contains_key("_provider_work_github") + ); + assert!( + effective_policy + .network_policies + .contains_key("_provider_work_github_2") + ); + assert_eq!( + effective_policy + .network_policies + .get("_provider_work_github") + .unwrap() + .endpoints[0] + .host, + "api.github.com" + ); + } + + #[tokio::test] + async fn provider_environment_resolution_is_unchanged_by_providers_v2_setting() { + use openshell_core::proto::GetSandboxProviderEnvironmentRequest; + + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-provider-env", + "provider-env", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string()], + )) + .await + .unwrap(); + + let legacy_env = handle_get_sandbox_provider_environment( + &state, + Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-provider-env".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .environment; + + enable_providers_v2(&state).await; + let v2_env = handle_get_sandbox_provider_environment( + &state, + Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-provider-env".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .environment; + + assert_eq!(legacy_env, v2_env); + assert_eq!(v2_env.get("GITHUB_TOKEN"), Some(&"ghp-test".to_string())); + } + #[tokio::test] async fn global_policy_suppresses_provider_profile_layers_when_v2_enabled() { use openshell_core::proto::{ diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 7d5ac9b92..af0ef9260 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -386,10 +386,21 @@ pub(super) async fn handle_delete_provider( #[cfg(test)] mod tests { use super::*; + use crate::ServerState; + use crate::compute::new_test_runtime; use crate::grpc::MAX_MAP_KEY_LEN; + use crate::sandbox_index::SandboxIndex; + use crate::sandbox_watch::SandboxWatchBus; + use crate::supervisor_session::SupervisorSessionRegistry; + use crate::tracing_bus::TracingLogBus; + use openshell_core::Config; + use openshell_core::proto::{ + GetProviderProfileRequest, ListProviderProfilesRequest, ProviderProfileCategory, + }; use openshell_core::{ObjectId, ObjectName}; use std::collections::HashMap; - use tonic::Code; + use std::sync::Arc; + use tonic::{Code, Request}; #[test] fn env_key_validation_accepts_valid_keys() { @@ -432,6 +443,89 @@ mod tests { } } + async fn test_server_state() -> Arc { + let store = Arc::new( + Store::connect("sqlite::memory:?cache=shared") + .await + .unwrap(), + ); + let compute = new_test_runtime(store.clone()).await; + Arc::new(ServerState::new( + Config::new(None) + .with_database_url("sqlite::memory:?cache=shared") + .with_ssh_handshake_secret("test-secret"), + store, + compute, + SandboxIndex::new(), + SandboxWatchBus::new(), + TracingLogBus::new(), + Arc::new(SupervisorSessionRegistry::new()), + )) + } + + #[tokio::test] + async fn list_provider_profiles_returns_built_in_profile_categories() { + let state = test_server_state().await; + let response = handle_list_provider_profiles( + &state, + Request::new(ListProviderProfilesRequest { + limit: 100, + offset: 0, + }), + ) + .await + .unwrap() + .into_inner(); + + let github = response + .profiles + .iter() + .find(|profile| profile.id == "github") + .expect("github profile should be listed"); + assert_eq!( + github.category, + ProviderProfileCategory::SourceControl as i32 + ); + assert!( + response + .profiles + .iter() + .all(|profile| profile.id != "generic"), + "generic remains a legacy provider type without a v2 profile" + ); + } + + #[tokio::test] + async fn get_provider_profile_returns_profile_or_not_found() { + let state = test_server_state().await; + let github = handle_get_provider_profile( + &state, + Request::new(GetProviderProfileRequest { + id: "github".to_string(), + }), + ) + .await + .unwrap() + .into_inner() + .profile + .expect("github profile should be returned"); + assert_eq!(github.id, "github"); + assert_eq!( + github.category, + ProviderProfileCategory::SourceControl as i32 + ); + + let generic_err = handle_get_provider_profile( + &state, + Request::new(GetProviderProfileRequest { + id: "generic".to_string(), + }), + ) + .await + .unwrap_err(); + assert_eq!(generic_err.code(), Code::NotFound); + } + #[tokio::test] async fn provider_crud_round_trip_and_semantics() { let store = Store::connect("sqlite::memory:?cache=shared") From 68e6947fd55b872dd43a621bc7c64059901a0cbb Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 30 Apr 2026 09:16:59 -0700 Subject: [PATCH 7/7] fix(providers): resolve main rebase lint --- .../tests/provider_commands_integration.rs | 2 +- crates/openshell-policy/src/compose.rs | 2 +- crates/openshell-providers/src/profiles.rs | 19 ++++++++-------- crates/openshell-server/src/grpc/mod.rs | 7 ++++-- crates/openshell-server/src/grpc/policy.rs | 22 ++++++++----------- crates/openshell-server/src/grpc/provider.rs | 14 +++++------- crates/openshell-tui/src/lib.rs | 4 ++-- 7 files changed, 32 insertions(+), 38 deletions(-) diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 4ce046d68..802e0bb7e 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -209,7 +209,7 @@ impl OpenShell for TestOpenShell { openshell_core::proto::ListProviderProfilesResponse { profiles: openshell_providers::default_profiles() .iter() - .map(|profile| profile.to_proto()) + .map(openshell_providers::ProviderTypeProfile::to_proto) .collect(), }, )) diff --git a/crates/openshell-policy/src/compose.rs b/crates/openshell-policy/src/compose.rs index 7831aece2..2fd4cfbaa 100644 --- a/crates/openshell-policy/src/compose.rs +++ b/crates/openshell-policy/src/compose.rs @@ -51,7 +51,7 @@ pub fn compose_effective_policy( let key = unique_provider_rule_key(&effective, &layer.rule_name); let mut rule = layer.rule.clone(); if rule.name.is_empty() { - rule.name = key.clone(); + rule.name.clone_from(&key); } effective.network_policies.insert(key, rule); } diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 3c080bcd8..515783c01 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -169,8 +169,7 @@ where fn provider_profile_category_from_yaml(raw: &str) -> Option { match raw.trim().to_ascii_lowercase().replace('-', "_").as_str() { - "" => Some(ProviderProfileCategory::Other), - "other" => Some(ProviderProfileCategory::Other), + "" | "other" => Some(ProviderProfileCategory::Other), "inference" => Some(ProviderProfileCategory::Inference), "agent" => Some(ProviderProfileCategory::Agent), "source_control" => Some(ProviderProfileCategory::SourceControl), @@ -313,13 +312,13 @@ mod tests { #[test] fn parse_profile_yaml_reads_single_provider_document() { let profile = parse_profile_yaml( - r#" + r" id: example display_name: Example credentials: - name: api_key env_vars: [EXAMPLE_API_KEY] -"#, +", ) .expect("profile should parse"); @@ -331,14 +330,14 @@ credentials: #[test] fn parse_profile_catalog_yamls_rejects_duplicate_ids() { let err = parse_profile_catalog_yamls(&[ - r#" + r" id: duplicate display_name: First -"#, - r#" +", + r" id: duplicate display_name: Second -"#, +", ]) .unwrap_err(); @@ -347,13 +346,13 @@ display_name: Second #[test] fn parse_profile_catalog_yamls_rejects_invalid_endpoint_ports() { - let err = parse_profile_catalog_yamls(&[r#" + let err = parse_profile_catalog_yamls(&[r" id: bad-endpoint display_name: Bad Endpoint endpoints: - host: api.example.com port: 0 -"#]) +"]) .unwrap_err(); assert!(matches!(err, ProfileError::InvalidEndpoint { id, .. } if id == "bad-endpoint")); diff --git a/crates/openshell-server/src/grpc/mod.rs b/crates/openshell-server/src/grpc/mod.rs index b72d6ba8f..31970e9c5 100644 --- a/crates/openshell-server/src/grpc/mod.rs +++ b/crates/openshell-server/src/grpc/mod.rs @@ -258,14 +258,17 @@ impl OpenShell for OpenShellService { &self, request: Request, ) -> Result, Status> { - provider::handle_list_provider_profiles(&self.state, request).await + Ok(provider::handle_list_provider_profiles( + &self.state, + request, + )) } async fn get_provider_profile( &self, request: Request, ) -> Result, Status> { - provider::handle_get_provider_profile(&self.state, request).await + provider::handle_get_provider_profile(&self.state, request) } async fn update_provider( diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 84e50dee7..520f29ae1 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2693,7 +2693,7 @@ mod tests { metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { id: format!("provider-{name}"), name: name.to_string(), - created_at_ms: 1000000, + created_at_ms: 1_000_000, labels: HashMap::new(), }), r#type: provider_type.to_string(), @@ -2705,7 +2705,7 @@ mod tests { fn test_policy_with_rule(rule_name: &str, host: &str) -> ProtoSandboxPolicy { ProtoSandboxPolicy { - network_policies: [( + network_policies: std::iter::once(( rule_name.to_string(), NetworkPolicyRule { name: rule_name.to_string(), @@ -2716,8 +2716,7 @@ mod tests { }], ..Default::default() }, - )] - .into_iter() + )) .collect(), ..Default::default() } @@ -2751,11 +2750,10 @@ mod tests { async fn enable_providers_v2(state: &Arc) { let global_settings = StoredSettings { revision: 1, - settings: [( + settings: std::iter::once(( settings::USE_PROVIDERS_V2_KEY.to_string(), StoredSettingValue::Bool(true), - )] - .into_iter() + )) .collect(), }; save_global_settings(state.store.as_ref(), &global_settings) @@ -3099,7 +3097,7 @@ mod tests { .unwrap(); let sandbox_policy = SandboxPolicy { - network_policies: [( + network_policies: std::iter::once(( "sandbox_only".to_string(), NetworkPolicyRule { name: "sandbox_only".to_string(), @@ -3110,8 +3108,7 @@ mod tests { }], ..Default::default() }, - )] - .into_iter() + )) .collect(), ..Default::default() }; @@ -3133,7 +3130,7 @@ mod tests { state.store.put_message(&sandbox).await.unwrap(); let global_policy = SandboxPolicy { - network_policies: [( + network_policies: std::iter::once(( "global_only".to_string(), NetworkPolicyRule { name: "global_only".to_string(), @@ -3144,8 +3141,7 @@ mod tests { }], ..Default::default() }, - )] - .into_iter() + )) .collect(), ..Default::default() }; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index af0ef9260..b9d87b446 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -320,10 +320,10 @@ pub(super) async fn handle_list_providers( Ok(Response::new(ListProvidersResponse { providers })) } -pub(super) async fn handle_list_provider_profiles( +pub(super) fn handle_list_provider_profiles( _state: &Arc, request: Request, -) -> Result, Status> { +) -> Response { let request = request.into_inner(); let limit = clamp_limit(request.limit, 100, MAX_PAGE_SIZE) as usize; let offset = request.offset as usize; @@ -331,13 +331,13 @@ pub(super) async fn handle_list_provider_profiles( .iter() .skip(offset) .take(limit) - .map(|profile| profile.to_proto()) + .map(openshell_providers::ProviderTypeProfile::to_proto) .collect(); - Ok(Response::new(ListProviderProfilesResponse { profiles })) + Response::new(ListProviderProfilesResponse { profiles }) } -pub(super) async fn handle_get_provider_profile( +pub(super) fn handle_get_provider_profile( _state: &Arc, request: Request, ) -> Result, Status> { @@ -473,8 +473,6 @@ mod tests { offset: 0, }), ) - .await - .unwrap() .into_inner(); let github = response @@ -504,7 +502,6 @@ mod tests { id: "github".to_string(), }), ) - .await .unwrap() .into_inner() .profile @@ -521,7 +518,6 @@ mod tests { id: "generic".to_string(), }), ) - .await .unwrap_err(); assert_eq!(generic_err.code(), Code::NotFound); } diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 933c6e884..8571ebbe1 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -1568,7 +1568,7 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender) { }), r#type: ptype.clone(), credentials: credentials.clone(), - config: Default::default(), + config: HashMap::default(), }), }; @@ -1658,7 +1658,7 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender) { }), r#type: ptype, credentials, - config: Default::default(), + config: HashMap::default(), }), };