mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-30 07:33:59 +08:00
feat(providers): add profile policy composition
This commit is contained in:
Generated
+1
@@ -3338,6 +3338,7 @@ dependencies = [
|
||||
"openshell-driver-podman",
|
||||
"openshell-ocsf",
|
||||
"openshell-policy",
|
||||
"openshell-providers",
|
||||
"openshell-router",
|
||||
"petname",
|
||||
"pin-project-lite",
|
||||
|
||||
@@ -35,15 +35,63 @@ Provider is defined in `proto/datamodel.proto`:
|
||||
- `type`: canonical provider slug (`claude`, `gitlab`, `github`, etc.)
|
||||
- `credentials`: `map<string, string>` for secret values
|
||||
- `config`: `map<string, string>` 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 <name>`
|
||||
- `openshell provider list`
|
||||
- `openshell provider list-types`
|
||||
- `openshell provider update <name> ...`
|
||||
- `openshell provider delete <name> [<name>...]`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -2619,6 +2623,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,
|
||||
|
||||
@@ -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};
|
||||
@@ -1964,7 +1965,7 @@ pub async fn sandbox_create_with_bootstrap(
|
||||
tty_override,
|
||||
Some(false),
|
||||
auto_providers_override,
|
||||
&std::collections::HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&tls,
|
||||
)
|
||||
.await
|
||||
@@ -2020,7 +2021,7 @@ pub async fn sandbox_create(
|
||||
tty_override: Option<bool>,
|
||||
bootstrap_override: Option<bool>,
|
||||
auto_providers_override: Option<bool>,
|
||||
labels: &std::collections::HashMap<String, String>,
|
||||
labels: &HashMap<String, String>,
|
||||
tls: &TlsOptions,
|
||||
) -> Result<()> {
|
||||
if editor.is_some() && !command.is_empty() {
|
||||
@@ -3371,11 +3372,13 @@ async fn auto_create_provider(
|
||||
id: String::new(),
|
||||
name: exact_name.to_string(),
|
||||
created_at_ms: 0,
|
||||
labels: std::collections::HashMap::new(),
|
||||
labels: HashMap::new(),
|
||||
}),
|
||||
r#type: provider_type.to_string(),
|
||||
credentials: discovered.credentials.clone(),
|
||||
config: discovered.config.clone(),
|
||||
profile_id: provider_type.to_string(),
|
||||
profile_policy_enabled: true,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -3411,11 +3414,13 @@ async fn auto_create_provider(
|
||||
id: String::new(),
|
||||
name: name.clone(),
|
||||
created_at_ms: 0,
|
||||
labels: std::collections::HashMap::new(),
|
||||
labels: HashMap::new(),
|
||||
}),
|
||||
r#type: provider_type.to_string(),
|
||||
credentials: discovered.credentials.clone(),
|
||||
config: discovered.config.clone(),
|
||||
profile_id: provider_type.to_string(),
|
||||
profile_policy_enabled: true,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -3569,11 +3574,13 @@ pub async fn provider_create(
|
||||
id: String::new(),
|
||||
name: name.to_string(),
|
||||
created_at_ms: 0,
|
||||
labels: std::collections::HashMap::new(),
|
||||
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
|
||||
@@ -3702,6 +3709,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,
|
||||
@@ -3759,11 +3820,13 @@ pub async fn provider_update(
|
||||
id: String::new(),
|
||||
name: name.to_string(),
|
||||
created_at_ms: 0,
|
||||
labels: std::collections::HashMap::new(),
|
||||
labels: HashMap::new(),
|
||||
}),
|
||||
r#type: String::new(),
|
||||
credentials: credential_map,
|
||||
config: config_map,
|
||||
profile_id: String::new(),
|
||||
profile_policy_enabled: false,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -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<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
request: tonic::Request<UpdateProviderRequest>,
|
||||
@@ -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());
|
||||
|
||||
@@ -177,6 +177,20 @@ impl OpenShell for TestOpenShell {
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_provider_profiles(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
_request: tonic::Request<UpdateProviderRequest>,
|
||||
|
||||
@@ -201,6 +201,20 @@ impl OpenShell for TestOpenShell {
|
||||
Ok(Response::new(ListProvidersResponse { providers }))
|
||||
}
|
||||
|
||||
async fn list_provider_profiles(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
request: tonic::Request<UpdateProviderRequest>,
|
||||
@@ -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());
|
||||
|
||||
@@ -231,6 +231,20 @@ impl OpenShell for TestOpenShell {
|
||||
Ok(Response::new(ListProvidersResponse::default()))
|
||||
}
|
||||
|
||||
async fn list_provider_profiles(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
_request: tonic::Request<UpdateProviderRequest>,
|
||||
|
||||
@@ -207,6 +207,20 @@ impl OpenShell for TestOpenShell {
|
||||
Ok(Response::new(ListProvidersResponse::default()))
|
||||
}
|
||||
|
||||
async fn list_provider_profiles(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
_request: tonic::Request<UpdateProviderRequest>,
|
||||
|
||||
@@ -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::<String>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,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
|
||||
|
||||
@@ -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,
|
||||
@@ -250,6 +252,20 @@ impl OpenShell for OpenShellService {
|
||||
provider::handle_list_providers(&self.state, request).await
|
||||
}
|
||||
|
||||
async fn list_provider_profiles(
|
||||
&self,
|
||||
request: Request<ListProviderProfilesRequest>,
|
||||
) -> Result<Response<ListProviderProfilesResponse>, Status> {
|
||||
provider::handle_list_provider_profiles(&self.state, request).await
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
request: Request<GetProviderProfileRequest>,
|
||||
) -> Result<Response<ProviderProfileResponse>, Status> {
|
||||
provider::handle_get_provider_profile(&self.state, request).await
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
request: Request<UpdateProviderRequest>,
|
||||
|
||||
@@ -32,7 +32,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::{
|
||||
@@ -42,7 +42,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};
|
||||
@@ -312,6 +315,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
|
||||
@@ -406,6 +414,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);
|
||||
|
||||
@@ -420,6 +440,51 @@ pub(super) async fn handle_get_sandbox_config(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn profile_provider_policy_layers(
|
||||
store: &Store,
|
||||
provider_names: &[String],
|
||||
) -> Result<Vec<ProviderPolicyLayer>, Status> {
|
||||
let mut layers = Vec::new();
|
||||
|
||||
for name in provider_names {
|
||||
let provider = store
|
||||
.get_message_by_name::<Provider>(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<ServerState>,
|
||||
_request: Request<GetGatewayConfigRequest>,
|
||||
@@ -2616,6 +2681,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};
|
||||
|
||||
@@ -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<ServerState>,
|
||||
request: Request<ListProviderProfilesRequest>,
|
||||
) -> Result<Response<ListProviderProfilesResponse>, 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<ServerState>,
|
||||
request: Request<GetProviderProfileRequest>,
|
||||
) -> Result<Response<ProviderProfileResponse>, 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<ServerState>,
|
||||
request: Request<UpdateProviderRequest>,
|
||||
@@ -392,6 +431,8 @@ mod tests {
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
profile_id: String::new(),
|
||||
profile_policy_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +468,7 @@ mod tests {
|
||||
id: String::new(),
|
||||
name: "gitlab-local".to_string(),
|
||||
created_at_ms: 1000000,
|
||||
labels: std::collections::HashMap::new(),
|
||||
labels: HashMap::new(),
|
||||
}),
|
||||
r#type: "gitlab".to_string(),
|
||||
credentials: std::iter::once((
|
||||
@@ -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
|
||||
@@ -936,7 +1007,7 @@ mod tests {
|
||||
id: "sandbox-001".to_string(),
|
||||
name: "test-sandbox".to_string(),
|
||||
created_at_ms: 1000000,
|
||||
labels: std::collections::HashMap::new(),
|
||||
labels: HashMap::new(),
|
||||
}),
|
||||
spec: Some(SandboxSpec {
|
||||
providers: vec!["my-claude".to_string()],
|
||||
@@ -972,7 +1043,7 @@ mod tests {
|
||||
id: "sandbox-002".to_string(),
|
||||
name: "empty-sandbox".to_string(),
|
||||
created_at_ms: 1000000,
|
||||
labels: std::collections::HashMap::new(),
|
||||
labels: HashMap::new(),
|
||||
}),
|
||||
spec: Some(SandboxSpec::default()),
|
||||
status: None,
|
||||
|
||||
@@ -883,6 +883,8 @@ mod tests {
|
||||
r#type: provider_type.to_string(),
|
||||
credentials,
|
||||
config,
|
||||
profile_id: String::new(),
|
||||
profile_policy_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -506,6 +506,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -670,6 +672,8 @@ mod tests {
|
||||
"https://station.example.com/v1".to_string(),
|
||||
))
|
||||
.collect(),
|
||||
profile_id: String::new(),
|
||||
profile_policy_enabled: false,
|
||||
};
|
||||
store
|
||||
.put_message(&provider)
|
||||
@@ -744,6 +748,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)
|
||||
|
||||
@@ -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<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<tonic::Response<openshell_core::proto::ListProviderProfilesResponse>, tonic::Status>
|
||||
{
|
||||
Err(tonic::Status::unimplemented("test"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<tonic::Response<openshell_core::proto::ProviderProfileResponse>, tonic::Status>
|
||||
{
|
||||
Err(tonic::Status::unimplemented("test"))
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
_: tonic::Request<openshell_core::proto::UpdateProviderRequest>,
|
||||
|
||||
@@ -171,6 +171,20 @@ impl OpenShell for TestOpenShell {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn list_provider_profiles(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
_request: tonic::Request<UpdateProviderRequest>,
|
||||
|
||||
@@ -135,6 +135,20 @@ impl OpenShell for TestOpenShell {
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_provider_profiles(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
_request: tonic::Request<UpdateProviderRequest>,
|
||||
|
||||
@@ -148,6 +148,20 @@ impl OpenShell for TestOpenShell {
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_provider_profiles(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
_request: tonic::Request<UpdateProviderRequest>,
|
||||
|
||||
@@ -172,6 +172,21 @@ impl OpenShell for RelayGateway {
|
||||
) -> Result<Response<openshell_core::proto::ListProvidersResponse>, Status> {
|
||||
Err(Status::unimplemented("unused"))
|
||||
}
|
||||
|
||||
async fn list_provider_profiles(
|
||||
&self,
|
||||
_: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("unused"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("unused"))
|
||||
}
|
||||
|
||||
async fn delete_provider(
|
||||
&self,
|
||||
_: tonic::Request<openshell_core::proto::DeleteProviderRequest>,
|
||||
|
||||
@@ -165,6 +165,20 @@ impl OpenShell for TestOpenShell {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn list_provider_profiles(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::ListProviderProfilesRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ListProviderProfilesResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn get_provider_profile(
|
||||
&self,
|
||||
_request: tonic::Request<openshell_core::proto::GetProviderProfileRequest>,
|
||||
) -> Result<Response<openshell_core::proto::ProviderProfileResponse>, Status> {
|
||||
Err(Status::unimplemented("not implemented in test"))
|
||||
}
|
||||
|
||||
async fn update_provider(
|
||||
&self,
|
||||
_request: tonic::Request<UpdateProviderRequest>,
|
||||
|
||||
@@ -1573,6 +1573,8 @@ fn spawn_create_provider(app: &App, tx: mpsc::UnboundedSender<Event>) {
|
||||
r#type: ptype.clone(),
|
||||
credentials: credentials.clone(),
|
||||
config: Default::default(),
|
||||
profile_id: ptype.clone(),
|
||||
profile_policy_enabled: true,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1663,6 +1665,8 @@ fn spawn_update_provider(app: &App, tx: mpsc::UnboundedSender<Event>) {
|
||||
r#type: ptype,
|
||||
credentials,
|
||||
config: Default::default(),
|
||||
profile_id: String::new(),
|
||||
profile_policy_enabled: false,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Warning>
|
||||
Providers cannot be added to a running sandbox. If you need to attach an
|
||||
|
||||
@@ -34,4 +34,14 @@ message Provider {
|
||||
map<string, string> credentials = 3;
|
||||
// Non-secret provider configuration.
|
||||
map<string, string> 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -558,6 +566,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;
|
||||
|
||||
Reference in New Issue
Block a user