diff --git a/Cargo.lock b/Cargo.lock index 0e59eb64f..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", ] @@ -3359,6 +3361,7 @@ dependencies = [ "openshell-driver-podman", "openshell-ocsf", "openshell-policy", + "openshell-providers", "openshell-router", "petname", "pin-project-lite", 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 088bd7592..ce0d588d0 100644 --- a/architecture/sandbox-providers.md +++ b/architecture/sandbox-providers.md @@ -41,13 +41,67 @@ 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. 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. 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 +`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 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 + +Sandbox policy fetch uses just-in-time composition: + +```text +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 type has a built-in profile. + +Provider-generated network rules use reserved `_provider_*` names derived from the +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 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` @@ -174,6 +228,7 @@ Also supported: - `openshell provider get ` - `openshell provider list` +- `openshell provider list-types` - `openshell provider update ...` - `openshell provider delete [...]` @@ -232,6 +287,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..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 @@ -700,6 +699,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 +2631,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..a5863601b 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -22,16 +22,18 @@ 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, 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}; @@ -3579,7 +3581,7 @@ 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, }), @@ -3706,6 +3708,68 @@ 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 = i32::MIN; + for profile in profiles { + if profile.category != current_category { + current_category = profile.category; + println!(); + println!(" {}", display_provider_category(current_category).bold()); + } + print_provider_type_row(&profile); + } + + Ok(()) +} + +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) { + 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, diff --git a/crates/openshell-cli/tests/ensure_providers_integration.rs b/crates/openshell-cli/tests/ensure_providers_integration.rs index a5a485735..0b29f73f4 100644 --- a/crates/openshell-cli/tests/ensure_providers_integration.rs +++ b/crates/openshell-cli/tests/ensure_providers_integration.rs @@ -251,6 +251,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, 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..802e0bb7e 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -201,6 +201,27 @@ impl OpenShell for TestOpenShell { Ok(Response::new(ListProvidersResponse { providers })) } + async fn list_provider_profiles( + &self, + _request: tonic::Request, + ) -> Result, Status> { + Ok(Response::new( + openshell_core::proto::ListProviderProfilesResponse { + profiles: openshell_providers::default_profiles() + .iter() + .map(openshell_providers::ProviderTypeProfile::to_proto) + .collect(), + }, + )) + } + + 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, @@ -529,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-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-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-policy/src/compose.rs b/crates/openshell-policy/src/compose.rs new file mode 100644 index 000000000..2fd4cfbaa --- /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.clone_from(&key); + } + 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/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..7b526008f --- /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: agent +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..c29d8878d --- /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: agent +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..74f9a4cd8 --- /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: agent +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/github.yaml b/crates/openshell-providers/profiles/github.yaml new file mode 100644 index 000000000..cc24ae922 --- /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..6d6535c75 --- /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..e8cf646dd --- /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: agent +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/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..515783c01 --- /dev/null +++ b/crates/openshell-providers/src/profiles.rs @@ -0,0 +1,360 @@ +// 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, ProviderProfileCategory, + ProviderProfileCredential, +}; +use serde::{Deserialize, Deserializer, de}; +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/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, Deserialize, PartialEq, Eq)] +pub struct CredentialProfile { + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub env_vars: Vec, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub auth_style: String, + #[serde(default)] + pub header_name: String, + #[serde(default)] + pub query_param: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct EndpointProfile { + pub host: String, + pub port: u32, + #[serde(default)] + pub protocol: String, + #[serde(default)] + pub access: String, + #[serde(default)] + pub enforcement: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ProviderTypeProfile { + pub id: String, + pub display_name: String, + #[serde(default)] + pub description: String, + #[serde( + default = "default_category", + deserialize_with = "deserialize_category" + )] + pub category: ProviderProfileCategory, + #[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<&str> { + let mut vars = Vec::new(); + 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()); + } + } + } + vars + } + + #[must_use] + pub fn to_proto(&self) -> ProviderProfile { + ProviderProfile { + id: self.id.clone(), + display_name: self.display_name.clone(), + description: self.description.clone(), + category: self.category as i32, + credentials: self + .credentials + .iter() + .map(|credential| ProviderProfileCredential { + name: credential.name.clone(), + description: credential.description.clone(), + env_vars: credential.env_vars.clone(), + required: credential.required, + 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.clone(), + 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.endpoints.iter().map(endpoint_to_proto).collect(), + binaries: self + .binaries + .iter() + .map(|path| NetworkBinary { + path: path.clone(), + harness: false, + }) + .collect(), + } + } +} + +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() { + "" | "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(), + 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, + } +} + +pub fn parse_profile_yaml(input: &str) -> Result { + Ok(serde_yml::from_str::(input)?) +} + +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) +} + +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())); + } + + 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(), + }); + } + } + } + + 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(()) +} + +static DEFAULT_PROFILES: OnceLock> = OnceLock::new(); + +#[must_use] +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] +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 openshell_core::proto::ProviderProfileCategory; + + 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.as_str()) + .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, + ProviderProfileCategory::SourceControl as i32 + ); + 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"] + ); + } + + #[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.category, ProviderProfileCategory::Other); + 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/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..31970e9c5 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,23 @@ impl OpenShell for OpenShellService { provider::handle_list_providers(&self.state, request).await } + async fn list_provider_profiles( + &self, + request: Request, + ) -> Result, Status> { + 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) + } + 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..520f29ae1 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 @@ -398,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; @@ -417,6 +426,19 @@ pub(super) async fn handle_get_sandbox_config( } } + 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() { + 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 +453,49 @@ 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")))?; + + let provider_type = provider.r#type.trim(); + let Some(profile) = get_default_profile(provider_type) else { + warn!( + provider_name = %name, + provider_type, + "provider type has no default profile; 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) +} + +fn bool_setting_enabled(settings: &StoredSettings, key: &str) -> Result { + match settings.settings.get(key) { + None => Ok(false), + Some(StoredSettingValue::Bool(value)) => Ok(*value), + Some(_) => Err(Status::internal(format!( + "setting '{key}' has invalid value type; expected bool" + ))), + } +} + pub(super) async fn handle_get_gateway_config( state: &Arc, _request: Request, @@ -2623,6 +2688,511 @@ mod tests { assert!(loaded.spec.unwrap().policy.is_none()); } + fn test_provider(name: &str, provider_type: &str) -> Provider { + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("provider-{name}"), + name: name.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + }), + r#type: provider_type.to_string(), + credentials: std::iter::once(("GITHUB_TOKEN".to_string(), "ghp-test".to_string())) + .collect(), + config: HashMap::new(), + } + } + + fn test_policy_with_rule(rule_name: &str, host: &str) -> ProtoSandboxPolicy { + ProtoSandboxPolicy { + network_policies: std::iter::once(( + rule_name.to_string(), + NetworkPolicyRule { + name: rule_name.to_string(), + endpoints: vec![NetworkEndpoint { + host: host.to_string(), + port: 443, + ..Default::default() + }], + ..Default::default() + }, + )) + .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: std::iter::once(( + settings::USE_PROVIDERS_V2_KEY.to_string(), + StoredSettingValue::Bool(true), + )) + .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(); + store + .put_message(&test_provider("custom-provider", "custom")) + .await + .unwrap(); + + let layers = profile_provider_policy_layers(&store, &["custom-provider".to_string()]) + .await + .unwrap(); + + assert!(layers.is_empty()); + } + + #[tokio::test] + async fn provider_policy_layers_include_known_provider_profiles() { + let store = Store::connect("sqlite::memory:").await.unwrap(); + store + .put_message(&test_provider("work-github", "github")) + .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") + ); + } + + #[test] + fn use_providers_v2_defaults_false_when_unset() { + assert!( + !bool_setting_enabled(&StoredSettings::default(), settings::USE_PROVIDERS_V2_KEY) + .unwrap() + ); + } + + #[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), + ); + + 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::{ + 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: std::iter::once(( + "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() + }, + )) + .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: std::iter::once(( + "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() + }, + )) + .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}; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index a9b18b7eb..b9d87b446 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -273,9 +273,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 +320,40 @@ pub(super) async fn handle_list_providers( Ok(Response::new(ListProvidersResponse { providers })) } +pub(super) fn handle_list_provider_profiles( + _state: &Arc, + request: Request, +) -> Response { + 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(openshell_providers::ProviderTypeProfile::to_proto) + .collect(); + + Response::new(ListProviderProfilesResponse { profiles }) +} + +pub(super) 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, @@ -349,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() { @@ -395,6 +443,85 @@ 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, + }), + ) + .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(), + }), + ) + .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(), + }), + ) + .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") 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/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index fbfc4d380..72b23e97d 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,15 @@ 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. Provider profile +network policy is gateway opt-in: + +```shell +openshell settings set --global --key use_providers_v2 --value true +``` + +Without `use_providers_v2=true`, provider behavior remains credential-only. + ## Manage Providers List, inspect, update, and delete providers from the active cluster. @@ -84,7 +100,10 @@ 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 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 diff --git a/proto/openshell.proto b/proto/openshell.proto index 75490f338..529ee0629 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,62 @@ 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; +} + +// 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; + ProviderProfileCategory 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;