diff --git a/architecture/security-policy.md b/architecture/security-policy.md index bc7b0c7a8..9f1a6a71b 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -74,9 +74,12 @@ protocols remain raw passthrough. ## Live Updates -The gateway stores policy revisions and exposes effective sandbox configuration. -The supervisor polls for config revisions and attempts to load new dynamic -policy into the in-process OPA engine. +The gateway stores sandbox-authored policy revisions separately from derived +effective sandbox configuration. Effective configuration can include +gateway-global policy overrides and provider-profile policy layers. The +supervisor polls for config revisions and attempts to load new dynamic policy +into the in-process OPA engine; CLI reads of the latest sandbox policy use the +same effective configuration path. If a new policy fails validation or loading, the supervisor reports the failure and keeps the last-known-good policy. Static controls, such as filesystem diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 917c8faa1..0852c85b8 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -1598,14 +1598,14 @@ enum PolicyCommands { timeout: u64, }, - /// Show current active policy for a sandbox or the global policy. + /// Show current effective policy for a sandbox or a stored global policy. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Get { /// Sandbox name (defaults to last-used sandbox). Ignored with --global. #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, - /// Show a specific policy revision (default: latest). + /// Show a specific stored policy revision. Default shows the current effective policy. #[arg(long = "rev", default_value_t = 0)] rev: u32, diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index b92be199e..b88ead268 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -4481,6 +4481,8 @@ pub async fn provider_list_profiles(server: &str, output: &str, tls: &TlsOptions } println!("{}", "Available Provider Profiles:".cyan().bold()); + let id_width = provider_profile_id_width(&profiles); + let display_width = provider_profile_display_width(&profiles); let mut current_category = i32::MIN; for profile in profiles { if profile.category != current_category { @@ -4488,7 +4490,7 @@ pub async fn provider_list_profiles(server: &str, output: &str, tls: &TlsOptions println!(); println!(" {}", display_provider_category(current_category).bold()); } - print_provider_type_row(&profile); + print_provider_type_row(&profile, id_width, display_width); } Ok(()) @@ -4959,21 +4961,65 @@ fn display_provider_category(category: i32) -> &'static str { } } -fn print_provider_type_row(profile: &ProviderProfile) { +const PROVIDER_PROFILE_ID_MAX_WIDTH: usize = 32; +const PROVIDER_PROFILE_DISPLAY_MAX_WIDTH: usize = 40; + +fn provider_profile_id_width(profiles: &[ProviderProfile]) -> usize { + profiles + .iter() + .map(|profile| { + profile + .id + .chars() + .count() + .min(PROVIDER_PROFILE_ID_MAX_WIDTH) + }) + .max() + .unwrap_or(2) + .max(2) +} + +fn provider_profile_display_width(profiles: &[ProviderProfile]) -> usize { + profiles + .iter() + .map(|profile| { + profile + .display_name + .chars() + .count() + .min(PROVIDER_PROFILE_DISPLAY_MAX_WIDTH) + }) + .max() + .unwrap_or(4) + .max(4) +} + +fn print_provider_type_row(profile: &ProviderProfile, id_width: usize, display_width: usize) { let inference = if profile.inference_capable { " inference" } else { "" }; + let id = truncate_display(&profile.id, PROVIDER_PROFILE_ID_MAX_WIDTH); + let display_name = truncate_display(&profile.display_name, PROVIDER_PROFILE_DISPLAY_MAX_WIDTH); println!( - " {:<12} {:<42} endpoints: {:<2}{}", - profile.id, - profile.display_name, + " {id: String { + if value.chars().count() <= max_width { + return value.to_string(); + } + + let keep = max_width.saturating_sub(3); + let mut truncated = value.chars().take(keep).collect::(); + truncated.push_str("..."); + truncated +} + pub async fn provider_update( server: &str, name: &str, @@ -6269,6 +6315,11 @@ where W: Write + Send, E: Write + Send, { + if version == 0 { + return sandbox_policy_get_effective_to_writer(server, name, full, output, tls, writers) + .await; + } + let (stdout, stderr) = writers; let mut client = grpc_client(server, tls).await?; @@ -6338,6 +6389,118 @@ where Ok(()) } +async fn sandbox_policy_get_effective_to_writer( + server: &str, + name: &str, + full: bool, + output: &str, + tls: &TlsOptions, + writers: (&mut W, &mut E), +) -> Result<()> +where + W: Write + Send, + E: Write + Send, +{ + let (stdout, _stderr) = writers; + let mut client = grpc_client(server, tls).await?; + + let sandbox = client + .get_sandbox(GetSandboxRequest { + name: name.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .sandbox + .ok_or_else(|| miette!("sandbox missing from response"))?; + let sandbox_id = sandbox.object_id(); + if sandbox_id.is_empty() { + return Err(miette!("sandbox missing metadata")); + } + + let config = client + .get_sandbox_config(GetSandboxConfigRequest { + sandbox_id: sandbox_id.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + let policy = config + .policy + .as_ref() + .ok_or_else(|| miette!("no active policy configured for sandbox '{name}'"))?; + let policy_source = + PolicySource::try_from(config.policy_source).unwrap_or(PolicySource::Sandbox); + let policy_source_label = match policy_source { + PolicySource::Global => "global", + PolicySource::Sandbox => "sandbox", + PolicySource::Unspecified => "unspecified", + }; + let version = if policy_source == PolicySource::Global && config.global_policy_version > 0 { + config.global_policy_version + } else { + config.version + }; + + match output { + "json" => { + let mut obj = serde_json::Map::new(); + obj.insert("scope".to_string(), serde_json::json!("sandbox")); + obj.insert("sandbox".to_string(), serde_json::json!(name)); + obj.insert("version".to_string(), serde_json::json!(version)); + obj.insert("active_version".to_string(), serde_json::json!(version)); + obj.insert("hash".to_string(), serde_json::json!(config.policy_hash)); + obj.insert("status".to_string(), serde_json::json!("effective")); + obj.insert( + "config_revision".to_string(), + serde_json::json!(config.config_revision), + ); + obj.insert( + "policy_source".to_string(), + serde_json::json!(policy_source_label), + ); + if config.global_policy_version > 0 { + obj.insert( + "global_policy_version".to_string(), + serde_json::json!(config.global_policy_version), + ); + } + if full { + obj.insert( + "policy".to_string(), + openshell_policy::sandbox_policy_to_json_value(policy)?, + ); + } + writeln!( + stdout, + "{}", + serde_json::to_string_pretty(&serde_json::Value::Object(obj)).into_diagnostic()? + ) + .into_diagnostic()?; + } + "table" => { + writeln!(stdout, "Version: {version}").into_diagnostic()?; + writeln!(stdout, "Hash: {}", config.policy_hash).into_diagnostic()?; + writeln!(stdout, "Status: Effective").into_diagnostic()?; + writeln!(stdout, "Source: {policy_source_label}").into_diagnostic()?; + writeln!(stdout, "Config rev: {}", config.config_revision).into_diagnostic()?; + if config.global_policy_version > 0 { + writeln!(stdout, "Global: {}", config.global_policy_version) + .into_diagnostic()?; + } + if full { + writeln!(stdout, "---").into_diagnostic()?; + let yaml_str = openshell_policy::serialize_sandbox_policy(policy) + .wrap_err("failed to serialize policy to YAML")?; + write!(stdout, "{yaml_str}").into_diagnostic()?; + } + } + _ => return Err(miette!("unsupported output format: {output}")), + } + + Ok(()) +} + pub async fn sandbox_policy_get_global( server: &str, version: u32, diff --git a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs index f49ca71db..5e753eff9 100644 --- a/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs +++ b/crates/openshell-cli/tests/sandbox_name_fallback_integration.rs @@ -127,13 +127,33 @@ impl OpenShell for TestOpenShell { let req = request.into_inner(); assert_eq!( req.sandbox_id, "test-id", - "sandbox_get --policy-only should pass the id from GetSandbox" + "GetSandboxConfig should pass the id from GetSandbox" ); Ok(Response::new(GetSandboxConfigResponse { policy: Some(SandboxPolicy { - version: 1, + version: 9, + network_policies: std::iter::once(( + "_provider_api".to_string(), + NetworkPolicyRule { + name: "_provider_api".to_string(), + endpoints: vec![NetworkEndpoint { + host: "api.provider.example.com".to_string(), + port: 443, + protocol: "rest".to_string(), + enforcement: "enforce".to_string(), + access: "read-only".to_string(), + ..Default::default() + }], + ..Default::default() + }, + )) + .collect(), ..Default::default() }), + version: 9, + policy_hash: "sha256:effective-policy".to_string(), + config_revision: 42, + policy_source: openshell_core::proto::PolicySource::Sandbox.into(), ..Default::default() })) } @@ -346,7 +366,7 @@ impl OpenShell for TestOpenShell { ) -> Result, Status> { let req = request.into_inner(); assert_eq!(req.name, "my-sandbox"); - assert_eq!(req.version, 0); + assert_eq!(req.version, 3); assert!(!req.global); let policy = SandboxPolicy { @@ -662,6 +682,50 @@ async fn policy_get_full_json_cli_prints_policy_payload() { String::from_utf8_lossy(&stderr) ); + let json: serde_json::Value = + serde_json::from_slice(&stdout).expect("stdout should be valid JSON"); + assert_eq!(json["scope"], "sandbox"); + assert_eq!(json["sandbox"], "my-sandbox"); + assert_eq!(json["version"], 9); + assert_eq!(json["active_version"], 9); + assert_eq!(json["hash"], "sha256:effective-policy"); + assert_eq!(json["status"], "effective"); + assert_eq!(json["config_revision"], 42); + assert_eq!(json["policy_source"], "sandbox"); + assert_eq!( + json["policy"]["network_policies"]["_provider_api"]["name"], + "_provider_api" + ); + assert_eq!( + json["policy"]["network_policies"]["_provider_api"]["endpoints"][0]["host"], + "api.provider.example.com" + ); +} + +#[tokio::test] +async fn policy_get_explicit_revision_uses_stored_policy_status() { + let ts = run_server().await; + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + + run::sandbox_policy_get_to_writer( + &ts.endpoint, + "my-sandbox", + 3, + true, + "json", + &ts.tls, + (&mut stdout, &mut stderr), + ) + .await + .expect("policy get --rev should succeed"); + + assert!( + stderr.is_empty(), + "policy get --rev should not print stderr: {}", + String::from_utf8_lossy(&stderr) + ); + let json: serde_json::Value = serde_json::from_slice(&stdout).expect("stdout should be valid JSON"); assert_eq!(json["scope"], "sandbox"); diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 25c750e63..8329f6e11 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -18,8 +18,12 @@ use std::sync::OnceLock; const BUILT_IN_PROFILE_YAMLS: &[&str] = &[ include_str!("../../../providers/claude-code.yaml"), + include_str!("../../../providers/codex.yaml"), + include_str!("../../../providers/copilot.yaml"), + include_str!("../../../providers/cursor.yaml"), include_str!("../../../providers/github.yaml"), include_str!("../../../providers/nvidia.yaml"), + include_str!("../../../providers/pypi.yaml"), ]; #[derive(Debug, thiserror::Error)] diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs index 0fc657007..76786a84d 100644 --- a/crates/openshell-sandbox/src/process.rs +++ b/crates/openshell-sandbox/src/process.rs @@ -20,7 +20,7 @@ use std::os::unix::io::RawFd; use std::path::PathBuf; use std::process::Stdio; use tokio::process::{Child, Command}; -use tracing::{debug, warn}; +use tracing::debug; fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap) { for (key, value) in provider_env { @@ -89,7 +89,7 @@ fn check_runtime_pid_limit_status( if matches!(mode, RuntimePidLimitMode::Require) { Err(miette::miette!(message)) } else { - warn!("{message}"); + tracing::warn!("{message}"); Ok(()) } } @@ -100,7 +100,7 @@ fn check_runtime_pid_limit_status( if matches!(mode, RuntimePidLimitMode::Require) { Err(miette::miette!(message)) } else { - warn!("{message}"); + tracing::warn!("{message}"); Ok(()) } } diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 3ddaae037..6ce8e75a5 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -1617,7 +1617,18 @@ mod tests { .iter() .map(|profile| profile.id.as_str()) .collect::>(); - assert_eq!(ids, vec!["claude-code", "github", "nvidia"]); + assert_eq!( + ids, + vec![ + "claude-code", + "codex", + "copilot", + "cursor", + "github", + "nvidia", + "pypi" + ] + ); let github = response .profiles @@ -1743,8 +1754,8 @@ mod tests { &state, Request::new(ImportProviderProfilesRequest { profiles: vec![ProviderProfileImportItem { - profile: Some(custom_profile("codex")), - source: "codex.yaml".to_string(), + profile: Some(custom_profile("opencode")), + source: "opencode.yaml".to_string(), }], }), ) @@ -1758,15 +1769,15 @@ mod tests { let imported = handle_get_provider_profile( &state, Request::new(GetProviderProfileRequest { - id: "codex".to_string(), + id: "opencode".to_string(), }), ) .await .unwrap() .into_inner() .profile - .expect("codex profile should be returned"); - assert_eq!(imported.id, "codex"); + .expect("opencode profile should be returned"); + assert_eq!(imported.id, "opencode"); } #[tokio::test] diff --git a/crates/openshell-tui/src/app.rs b/crates/openshell-tui/src/app.rs index ba817bcf8..4d601a97b 100644 --- a/crates/openshell-tui/src/app.rs +++ b/crates/openshell-tui/src/app.rs @@ -377,9 +377,103 @@ pub struct CreateProviderForm { pub struct ProviderDetailView { pub name: String, + pub provider_id: String, pub provider_type: String, - pub credential_key: String, - pub masked_value: String, + pub resource_version: u64, + pub summary_scroll: usize, + pub show_raw_profile: bool, + pub show_raw_provider: bool, + pub raw_profile_scroll: usize, + pub raw_provider_scroll: usize, + pub raw_profile_yaml: Option, + pub raw_provider_yaml: String, + pub profile_name: Option, + pub profile_category: Option, + pub profile_description: Option, + pub credential_lines: Vec, + pub config_lines: Vec, + pub policy_lines: Vec, + pub discovery_lines: Vec, + pub refresh_lines: Vec, +} + +#[derive(Clone)] +pub struct ProviderV2Entry { + pub provider: openshell_core::proto::Provider, + pub profile: Option, +} + +impl ProviderV2Entry { + pub fn name(&self) -> &str { + provider_name(&self.provider) + } + + pub fn profile_label(&self) -> String { + self.profile.as_ref().map_or_else( + || format!("{} (unprofiled)", self.provider.r#type), + |profile| { + if profile.display_name.is_empty() { + profile.id.clone() + } else { + profile.display_name.clone() + } + }, + ) + } + + pub fn category_label(&self) -> &'static str { + self.profile.as_ref().map_or("legacy", |profile| { + provider_category_label(profile.category) + }) + } + + pub fn credential_summary(&self) -> String { + let stored = self.provider.credentials.len(); + self.profile.as_ref().map_or_else( + || format!("{stored} key{}", plural(stored)), + |profile| { + let required = profile + .credentials + .iter() + .filter(|credential| credential.required) + .count(); + let required_present = profile + .credentials + .iter() + .filter(|credential| credential.required) + .filter(|credential| { + credential + .env_vars + .iter() + .any(|key| self.provider.credentials.contains_key(key)) + }) + .count(); + format!( + "{required_present}/{required} req, {stored} key{}", + plural(stored) + ) + }, + ) + } + + pub fn policy_summary(&self) -> String { + self.profile.as_ref().map_or_else( + || "no profile".to_string(), + |profile| { + let endpoints = profile.endpoints.len(); + let binaries = profile.binaries.len(); + let mut summary = format!( + "{endpoints} endpoint{}, {binaries} bin{}", + plural(endpoints), + plural(binaries) + ); + if profile.inference_capable { + summary.push_str(", inference"); + } + summary + }, + ) + } } // --------------------------------------------------------------------------- @@ -424,6 +518,8 @@ pub struct App { pub pending_gateway_switch: Option, // Provider list + pub providers_v2_enabled: bool, + pub provider_entries: Vec, pub provider_names: Vec, pub provider_types: Vec, pub provider_cred_keys: Vec, @@ -579,6 +675,163 @@ pub fn format_labels(labels: &HashMap) -> String { .join(",") } +pub fn provider_name(provider: &openshell_core::proto::Provider) -> &str { + provider + .metadata + .as_ref() + .map_or("", |metadata| metadata.name.as_str()) +} + +fn provider_id(provider: &openshell_core::proto::Provider) -> &str { + provider + .metadata + .as_ref() + .map_or("", |metadata| metadata.id.as_str()) +} + +fn provider_resource_version(provider: &openshell_core::proto::Provider) -> u64 { + provider + .metadata + .as_ref() + .map_or(0, |metadata| metadata.resource_version) +} + +pub fn provider_category_label(category: i32) -> &'static str { + match openshell_core::proto::ProviderProfileCategory::try_from(category) + .unwrap_or(openshell_core::proto::ProviderProfileCategory::Other) + { + openshell_core::proto::ProviderProfileCategory::Inference => "inference", + openshell_core::proto::ProviderProfileCategory::Agent => "agent", + openshell_core::proto::ProviderProfileCategory::SourceControl => "source_control", + openshell_core::proto::ProviderProfileCategory::Messaging => "messaging", + openshell_core::proto::ProviderProfileCategory::Data => "data", + openshell_core::proto::ProviderProfileCategory::Knowledge => "knowledge", + openshell_core::proto::ProviderProfileCategory::Other + | openshell_core::proto::ProviderProfileCategory::Unspecified => "other", + } +} + +fn plural(count: usize) -> &'static str { + if count == 1 { "" } else { "s" } +} + +fn refresh_strategy_label(strategy: i32) -> &'static str { + match openshell_core::proto::ProviderCredentialRefreshStrategy::try_from(strategy) + .unwrap_or(openshell_core::proto::ProviderCredentialRefreshStrategy::Unspecified) + { + openshell_core::proto::ProviderCredentialRefreshStrategy::Static => "static", + openshell_core::proto::ProviderCredentialRefreshStrategy::External => "external", + openshell_core::proto::ProviderCredentialRefreshStrategy::Oauth2RefreshToken => { + "oauth2_refresh_token" + } + openshell_core::proto::ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => { + "oauth2_client_credentials" + } + openshell_core::proto::ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => { + "google_service_account_jwt" + } + openshell_core::proto::ProviderCredentialRefreshStrategy::Unspecified => "unspecified", + } +} + +fn mask_secret(value: &str) -> String { + let len = value.chars().count(); + if len <= 4 { + "****".to_string() + } else { + let start: String = value.chars().take(2).collect(); + let end: String = value.chars().skip(len - 2).collect(); + format!("{start}{}…{end}", "*".repeat(len.saturating_sub(4).min(20))) + } +} + +fn provider_to_redacted_yaml(provider: &openshell_core::proto::Provider) -> String { + let mut out = String::new(); + out.push_str("name: "); + out.push_str(&yaml_scalar(provider_name(provider))); + out.push('\n'); + out.push_str("type: "); + out.push_str(&yaml_scalar(&provider.r#type)); + out.push('\n'); + + out.push_str("credentials:"); + if provider.credentials.is_empty() { + out.push_str(" {}\n"); + } else { + out.push('\n'); + let mut keys = provider.credentials.keys().collect::>(); + keys.sort(); + for key in keys { + out.push_str(" "); + out.push_str(key); + out.push_str(": \"\"\n"); + } + } + + out.push_str("config:"); + if provider.config.is_empty() { + out.push_str(" {}\n"); + } else { + out.push('\n'); + let mut entries = provider.config.iter().collect::>(); + entries.sort_by_key(|(key, _)| *key); + for (key, value) in entries { + out.push_str(" "); + out.push_str(key); + out.push_str(": "); + out.push_str(&yaml_scalar(value)); + out.push('\n'); + } + } + + if !provider.credential_expires_at_ms.is_empty() { + out.push_str("credential_expires_at_ms:\n"); + let mut entries = provider.credential_expires_at_ms.iter().collect::>(); + entries.sort_by_key(|(key, _)| *key); + for (key, value) in entries { + out.push_str(" "); + out.push_str(key); + out.push_str(": "); + out.push_str(&value.to_string()); + out.push('\n'); + } + } + + if let Some(metadata) = &provider.metadata { + out.push_str("metadata:\n"); + out.push_str(" id: "); + out.push_str(&yaml_scalar(&metadata.id)); + out.push('\n'); + out.push_str(" resource_version: "); + out.push_str(&metadata.resource_version.to_string()); + out.push('\n'); + if !metadata.labels.is_empty() { + out.push_str(" labels:\n"); + let mut labels = metadata.labels.iter().collect::>(); + labels.sort_by_key(|(key, _)| *key); + for (key, value) in labels { + out.push_str(" "); + out.push_str(key); + out.push_str(": "); + out.push_str(&yaml_scalar(value)); + out.push('\n'); + } + } + } + + out +} + +fn yaml_scalar(value: &str) -> String { + let escaped = value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t"); + format!("\"{escaped}\"") +} + impl App { #[allow(clippy::large_types_passed_by_value)] // Theme is Copy; one-shot ctor pub fn new( @@ -613,6 +866,8 @@ impl App { confirm_setting_delete: None, pending_setting_set: false, pending_setting_delete: false, + providers_v2_enabled: false, + provider_entries: Vec::new(), provider_names: Vec::new(), provider_types: Vec::new(), provider_cred_keys: Vec::new(), @@ -696,6 +951,16 @@ impl App { revision: u64, ) { self.global_settings_revision = revision; + self.providers_v2_enabled = settings + .get(settings::PROVIDERS_V2_ENABLED_KEY) + .and_then(|value| value.value.as_ref()) + .and_then(|value| match value { + setting_value::Value::BoolValue(value) => Some(*value), + setting_value::Value::StringValue(value) => settings::parse_bool_like(value), + setting_value::Value::IntValue(value) => Some(*value != 0), + setting_value::Value::BytesValue(_) => None, + }) + .unwrap_or(false); self.global_settings = settings::REGISTERED_SETTINGS .iter() .map(|reg| { @@ -908,7 +1173,7 @@ impl App { KeyCode::Char('k') | KeyCode::Up => { self.provider_selected = self.provider_selected.saturating_sub(1); } - KeyCode::Char('c') => { + KeyCode::Char('c') if !self.providers_v2_enabled => { self.open_create_provider_form(); } // Fetch and show provider detail. @@ -916,10 +1181,10 @@ impl App { self.pending_provider_get = true; } // Open update form for the selected provider. - KeyCode::Char('u') if self.provider_count > 0 => { + KeyCode::Char('u') if self.provider_count > 0 && !self.providers_v2_enabled => { self.open_update_provider_form(); } - KeyCode::Char('d') if self.provider_count > 0 => { + KeyCode::Char('d') if self.provider_count > 0 && !self.providers_v2_enabled => { self.confirm_provider_delete = true; } KeyCode::Char('h' | 'l') | KeyCode::Left | KeyCode::Right => { @@ -2014,10 +2279,50 @@ impl App { // ------------------------------------------------------------------ fn handle_provider_detail_key(&mut self, key: KeyEvent) { + let Some(detail) = self.provider_detail.as_mut() else { + return; + }; match key.code { + KeyCode::Esc if detail.show_raw_profile || detail.show_raw_provider => { + detail.show_raw_profile = false; + detail.show_raw_provider = false; + } KeyCode::Esc | KeyCode::Enter => { self.provider_detail = None; } + KeyCode::Char('y') if detail.raw_profile_yaml.is_some() => { + detail.show_raw_profile = !detail.show_raw_profile; + detail.show_raw_provider = false; + detail.raw_profile_scroll = 0; + } + KeyCode::Char('o') => { + detail.show_raw_provider = !detail.show_raw_provider; + detail.show_raw_profile = false; + detail.raw_provider_scroll = 0; + } + KeyCode::Char('j') | KeyCode::Down if detail.show_raw_profile => { + let max_scroll = detail + .raw_profile_yaml + .as_ref() + .map_or(0, |raw| raw.lines().count().saturating_sub(1)); + detail.raw_profile_scroll = (detail.raw_profile_scroll + 1).min(max_scroll); + } + KeyCode::Char('k') | KeyCode::Up if detail.show_raw_profile => { + detail.raw_profile_scroll = detail.raw_profile_scroll.saturating_sub(1); + } + KeyCode::Char('j') | KeyCode::Down if detail.show_raw_provider => { + let max_scroll = detail.raw_provider_yaml.lines().count().saturating_sub(1); + detail.raw_provider_scroll = (detail.raw_provider_scroll + 1).min(max_scroll); + } + KeyCode::Char('k') | KeyCode::Up if detail.show_raw_provider => { + detail.raw_provider_scroll = detail.raw_provider_scroll.saturating_sub(1); + } + KeyCode::Char('j') | KeyCode::Down => { + detail.summary_scroll = detail.summary_scroll.saturating_add(1); + } + KeyCode::Char('k') | KeyCode::Up => { + detail.summary_scroll = detail.summary_scroll.saturating_sub(1); + } _ => {} } } @@ -2152,6 +2457,209 @@ impl App { .map(String::as_str) } + pub fn provider_detail_from_provider( + &self, + provider: &openshell_core::proto::Provider, + ) -> ProviderDetailView { + let profile = self + .provider_entries + .iter() + .find(|entry| provider_name(&entry.provider) == provider_name(provider)) + .and_then(|entry| entry.profile.as_ref()); + + let mut credential_keys = provider.credentials.keys().cloned().collect::>(); + credential_keys.sort(); + let credential_lines = profile.map_or_else( + || { + if credential_keys.is_empty() { + return vec!["".to_string()]; + } + credential_keys + .iter() + .map(|key| { + let masked = provider + .credentials + .get(key) + .map_or_else(|| "-".to_string(), |value| mask_secret(value)); + let expiry = provider + .credential_expires_at_ms + .get(key) + .copied() + .filter(|value| *value > 0) + .map_or_else(String::new, |value| format!(" expires={value}")); + format!("{key}: {masked}{expiry}") + }) + .collect() + }, + |profile| { + profile + .credentials + .iter() + .map(|credential| { + let present_key = credential + .env_vars + .iter() + .find(|key| provider.credentials.contains_key(*key)); + let status = present_key.map_or("missing", |_| "present"); + let required = if credential.required { + "required" + } else { + "optional" + }; + let env_vars = if credential.env_vars.is_empty() { + "".to_string() + } else { + credential.env_vars.join(", ") + }; + let expiry = present_key + .and_then(|key| provider.credential_expires_at_ms.get(key)) + .copied() + .filter(|value| *value > 0) + .map_or_else(String::new, |value| format!(" expires={value}")); + format!( + "{} ({required}) env=[{env_vars}] {status}{expiry}", + credential.name + ) + }) + .collect() + }, + ); + + let mut config_lines = provider.config.keys().cloned().collect::>(); + config_lines.sort(); + if config_lines.is_empty() { + config_lines.push("".to_string()); + } + + let policy_lines = profile.map_or_else( + || vec!["No provider profile found; no v2 policy metadata.".to_string()], + |profile| { + let mut lines = profile + .endpoints + .iter() + .map(|endpoint| { + let protocol = if endpoint.protocol.is_empty() { + "l4" + } else { + endpoint.protocol.as_str() + }; + let access = if endpoint.access.is_empty() { + if endpoint.rules.is_empty() { + "custom" + } else { + "rules" + } + } else { + endpoint.access.as_str() + }; + let path = if endpoint.path.is_empty() { + String::new() + } else { + format!(" path={}", endpoint.path) + }; + format!( + "{}:{} {protocol} {access}{path}", + endpoint.host, endpoint.port + ) + }) + .collect::>(); + if lines.is_empty() { + lines.push("No profile endpoints.".to_string()); + } + if !profile.binaries.is_empty() { + lines.push(format!("Binaries: {}", profile.binaries.len())); + lines.extend( + profile + .binaries + .iter() + .take(4) + .map(|binary| format!(" {}", binary.path)), + ); + if profile.binaries.len() > 4 { + lines.push(format!(" ... {} more", profile.binaries.len() - 4)); + } + } + lines + }, + ); + + let discovery_lines = profile.map_or_else( + || vec!["".to_string()], + |profile| { + if let Some(discovery) = &profile.discovery + && !discovery.credentials.is_empty() + { + discovery.credentials.clone() + } else { + vec!["".to_string()] + } + }, + ); + + let refresh_lines = profile.map_or_else( + || vec!["No profile refresh metadata.".to_string()], + |profile| { + let lines = profile + .credentials + .iter() + .filter_map(|credential| { + credential.refresh.as_ref().map(|refresh| { + format!( + "{}: {} scopes=[{}] material={} key{}", + credential.name, + refresh_strategy_label(refresh.strategy), + refresh.scopes.join(", "), + refresh.material.len(), + plural(refresh.material.len()) + ) + }) + }) + .collect::>(); + if lines.is_empty() { + vec!["No refresh metadata in profile.".to_string()] + } else { + lines + } + }, + ); + + let raw_profile_yaml = profile.and_then(|profile| { + let dto = openshell_providers::ProviderTypeProfile::from_proto(profile); + openshell_providers::profile_to_yaml(&dto).ok() + }); + + ProviderDetailView { + name: provider_name(provider).to_string(), + provider_id: provider_id(provider).to_string(), + provider_type: provider.r#type.clone(), + resource_version: provider_resource_version(provider), + summary_scroll: 0, + show_raw_profile: false, + show_raw_provider: false, + raw_profile_scroll: 0, + raw_provider_scroll: 0, + raw_profile_yaml, + raw_provider_yaml: provider_to_redacted_yaml(provider), + profile_name: profile.map(|profile| { + if profile.display_name.is_empty() { + profile.id.clone() + } else { + profile.display_name.clone() + } + }), + profile_category: profile + .map(|profile| provider_category_label(profile.category).to_string()), + profile_description: profile.and_then(|profile| { + (!profile.description.is_empty()).then(|| profile.description.clone()) + }), + credential_lines, + config_lines, + policy_lines, + discovery_lines, + refresh_lines, + } + } + pub fn log_autoscroll_offset(&self) -> usize { const BOTTOM_PAD: usize = 3; let filtered_len = self.filtered_log_lines().len(); @@ -2204,6 +2712,8 @@ impl App { self.policy_lines.clear(); self.policy_scroll = 0; // Reset provider state too. + self.providers_v2_enabled = false; + self.provider_entries.clear(); self.provider_names.clear(); self.provider_types.clear(); self.provider_cred_keys.clear(); diff --git a/crates/openshell-tui/src/lib.rs b/crates/openshell-tui/src/lib.rs index 1969715ce..80db3b376 100644 --- a/crates/openshell-tui/src/lib.rs +++ b/crates/openshell-tui/src/lib.rs @@ -185,23 +185,7 @@ pub async fn run( } Some(Event::ProviderDetailFetched(result)) => match result { Ok(provider) => { - let cred_key = provider - .credentials - .keys() - .next() - .cloned() - .unwrap_or_default(); - let masked = provider - .credentials - .values() - .next() - .map_or_else(|| "-".to_string(), |val| mask_secret(val)); - app.provider_detail = Some(app::ProviderDetailView { - name: provider.object_name().to_string(), - provider_type: provider.r#type.clone(), - credential_key: cred_key, - masked_value: masked, - }); + app.provider_detail = Some(app.provider_detail_from_provider(&provider)); } Err(msg) => { app.status_text = format!("get provider failed: {msg}"); @@ -1910,30 +1894,48 @@ fn spawn_draft_approve_all( }); } -/// Mask a secret value, showing only the first and last 2 chars. -fn mask_secret(value: &str) -> String { - let len = value.len(); - if len <= 6 { - "*".repeat(len) - } else { - let start: String = value.chars().take(2).collect(); - let end: String = value.chars().skip(len - 2).collect(); - format!("{start}{}…{end}", "*".repeat(len.saturating_sub(4).min(20))) - } -} - // --------------------------------------------------------------------------- // Data refresh // --------------------------------------------------------------------------- async fn refresh_data(app: &mut App) { refresh_health(app).await; - refresh_providers(app).await; refresh_global_settings(app).await; + refresh_providers(app).await; refresh_sandboxes(app).await; } async fn refresh_providers(app: &mut App) { + let profiles = if app.providers_v2_enabled { + let req = openshell_core::proto::ListProviderProfilesRequest { + limit: 100, + offset: 0, + }; + match tokio::time::timeout( + Duration::from_secs(5), + app.client.list_provider_profiles(req), + ) + .await + { + Ok(Ok(resp)) => resp + .into_inner() + .profiles + .into_iter() + .map(|profile| (profile.id.clone(), profile)) + .collect::>(), + Ok(Err(e)) => { + tracing::warn!("failed to list provider profiles: {}", e.message()); + HashMap::new() + } + Err(_) => { + tracing::warn!("list provider profiles timed out"); + HashMap::new() + } + } + } else { + HashMap::new() + }; + let req = openshell_core::proto::ListProvidersRequest { limit: 100, offset: 0, @@ -1949,9 +1951,21 @@ async fn refresh_providers(app: &mut App) { Ok(Ok(resp)) => { let providers = resp.into_inner().providers; app.provider_count = providers.len(); + app.provider_entries = if app.providers_v2_enabled { + providers + .iter() + .cloned() + .map(|provider| app::ProviderV2Entry { + profile: profiles.get(&provider.r#type).cloned(), + provider, + }) + .collect() + } else { + Vec::new() + }; app.provider_names = providers .iter() - .map(|p| p.object_name().to_string()) + .map(|p| app::provider_name(p).to_string()) .collect(); app.provider_types = providers.iter().map(|p| p.r#type.clone()).collect(); app.provider_cred_keys = providers diff --git a/crates/openshell-tui/src/ui/create_provider.rs b/crates/openshell-tui/src/ui/create_provider.rs index 3df8b818f..94e545ad2 100644 --- a/crates/openshell-tui/src/ui/create_provider.rs +++ b/crates/openshell-tui/src/ui/create_provider.rs @@ -483,16 +483,22 @@ pub fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) { return; }; - let modal_width = 55u16.min(area.width.saturating_sub(4)); - // name(1) + type(1) + spacer(1) + cred_key(1) + masked(1) + spacer(1) + hint(1) - let content_height = 7; + let modal_width = 84u16.min(area.width.saturating_sub(4)); + let content_height = 24u16; let modal_height = (content_height + 4).min(area.height.saturating_sub(2)); let popup_area = centered_rect(modal_width, modal_height, area); frame.render_widget(Clear, popup_area); + let title = if detail.show_raw_provider { + " Provider Object YAML " + } else if detail.show_raw_profile { + " Provider Profile YAML " + } else { + " Provider Detail " + }; let block = Block::default() - .title(Span::styled(" Provider Detail ", t.heading)) + .title(Span::styled(title, t.heading)) .borders(Borders::ALL) .border_style(t.accent) .padding(Padding::new(2, 2, 1, 1)); @@ -500,57 +506,153 @@ pub fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) { let inner = block.inner(popup_area); frame.render_widget(block, popup_area); + if detail.show_raw_provider { + draw_raw_yaml( + frame, + &detail.raw_provider_yaml, + detail.raw_provider_scroll, + "Summary", + "o", + inner, + t, + ); + return; + } + + if detail.show_raw_profile { + draw_raw_yaml( + frame, + detail + .raw_profile_yaml + .as_deref() + .unwrap_or("No provider profile is available for this provider."), + detail.raw_profile_scroll, + "Summary", + "y", + inner, + t, + ); + return; + } + + let mut lines = Vec::new(); + lines.push(Line::from(vec![ + Span::styled("Name: ", t.muted), + Span::styled(&detail.name, t.heading), + Span::styled(" Type: ", t.muted), + Span::styled(&detail.provider_type, t.text), + ])); + if !detail.provider_id.is_empty() { + lines.push(Line::from(vec![ + Span::styled("Id: ", t.muted), + Span::styled(&detail.provider_id, t.muted), + Span::styled(" Resource version: ", t.muted), + Span::styled(detail.resource_version.to_string(), t.muted), + ])); + } + if let Some(profile_name) = &detail.profile_name { + lines.push(Line::from(vec![ + Span::styled("Profile: ", t.muted), + Span::styled(profile_name, t.text), + Span::styled(" Category: ", t.muted), + Span::styled( + detail.profile_category.as_deref().unwrap_or("other"), + t.muted, + ), + ])); + } else { + lines.push(Line::from(Span::styled( + "Profile: (legacy/unprofiled provider)", + t.status_warn, + ))); + } + if let Some(description) = &detail.profile_description { + lines.push(Line::from(vec![ + Span::styled("Description: ", t.muted), + Span::styled(description, t.text), + ])); + } + push_section(&mut lines, "Credentials", &detail.credential_lines, t); + push_section(&mut lines, "Config Keys", &detail.config_lines, t); + push_section(&mut lines, "Policy", &detail.policy_lines, t); + push_section(&mut lines, "Discovery", &detail.discovery_lines, t); + push_section(&mut lines, "Refresh", &detail.refresh_lines, t); let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), // name - Constraint::Length(1), // type - Constraint::Length(1), // spacer - Constraint::Length(1), // cred key - Constraint::Length(1), // masked value - Constraint::Length(1), // spacer - Constraint::Length(1), // hint - Constraint::Min(0), - ]) + .constraints([Constraint::Min(0), Constraint::Length(1)]) .split(inner); - + let total = lines.len(); + let scroll = detail.summary_scroll.min(total.saturating_sub(1)); frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled("Name: ", t.muted), - Span::styled(&detail.name, t.heading), - ])), + Paragraph::new(lines).scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)), chunks[0], ); + let position = (scroll + 1).min(total.max(1)); + let mut hint_spans = vec![ + Span::styled("[j/k]", t.key_hint), + Span::styled(" Scroll ", t.muted), + Span::styled("[o]", t.key_hint), + Span::styled(" Object YAML ", t.muted), + ]; + if detail.raw_profile_yaml.is_some() { + hint_spans.extend([ + Span::styled("[y]", t.key_hint), + Span::styled(" Profile YAML ", t.muted), + ]); + } + hint_spans.extend([ + Span::styled("[Esc]", t.key_hint), + Span::styled(" Close ", t.muted), + Span::styled(format!("[{position}/{total}]"), t.muted), + ]); + frame.render_widget(Paragraph::new(Line::from(hint_spans)), chunks[1]); +} +fn draw_raw_yaml( + frame: &mut Frame<'_>, + raw: &str, + scroll: usize, + toggle_label: &'static str, + toggle_key: &'static str, + area: Rect, + t: &crate::theme::Theme, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(area); frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled("Type: ", t.muted), - Span::styled(&detail.provider_type, t.text), - ])), - chunks[1], - ); - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled("Credential: ", t.muted), - Span::styled(&detail.credential_key, t.text), - ])), - chunks[3], - ); - - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled("Value: ", t.muted), - Span::styled(&detail.masked_value, t.muted), - ])), - chunks[4], + Paragraph::new(raw).scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)), + chunks[0], ); - + let total = raw.lines().count(); + let position = (scroll + 1).min(total.max(1)); let hint = Line::from(vec![ + Span::styled("[j/k]", t.key_hint), + Span::styled(" Scroll ", t.muted), + Span::styled(format!("[{toggle_key}]"), t.key_hint), + Span::styled(format!(" {toggle_label} "), t.muted), Span::styled("[Esc]", t.key_hint), - Span::styled(" Close", t.muted), + Span::styled(" Close ", t.muted), + Span::styled(format!("[{position}/{total}]"), t.muted), ]); - frame.render_widget(Paragraph::new(hint), chunks[6]); + frame.render_widget(Paragraph::new(hint), chunks[1]); +} + +fn push_section( + lines: &mut Vec>, + title: &'static str, + values: &[String], + t: &crate::theme::Theme, +) { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled(title, t.heading))); + for value in values { + lines.push(Line::from(vec![ + Span::styled(" - ", t.muted), + Span::styled(value.clone(), t.text), + ])); + } } // --------------------------------------------------------------------------- diff --git a/crates/openshell-tui/src/ui/mod.rs b/crates/openshell-tui/src/ui/mod.rs index 98c8badb5..b0d0037ef 100644 --- a/crates/openshell-tui/src/ui/mod.rs +++ b/crates/openshell-tui/src/ui/mod.rs @@ -188,6 +188,27 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { Span::styled("[q]", t.muted), Span::styled(" Quit", t.muted), ], + Focus::Providers if app.providers_v2_enabled => vec![ + Span::styled(" ", t.text), + Span::styled("[Tab]", t.key_hint), + Span::styled(" Switch Panel", t.text), + Span::styled(" ", t.text), + Span::styled("[h/l]", t.key_hint), + Span::styled(" Switch Tab", t.text), + Span::styled(" ", t.text), + Span::styled("[j/k]", t.key_hint), + Span::styled(" Navigate", t.text), + Span::styled(" ", t.text), + Span::styled("[Enter]", t.key_hint), + Span::styled(" Detail", t.text), + Span::styled(" ", t.text), + Span::styled("read-only", t.muted), + Span::styled(" | ", t.border), + Span::styled("[:]", t.muted), + Span::styled(" Command ", t.muted), + Span::styled("[q]", t.muted), + Span::styled(" Quit", t.muted), + ], Focus::Providers => vec![ Span::styled(" ", t.text), Span::styled("[Tab]", t.key_hint), diff --git a/crates/openshell-tui/src/ui/providers.rs b/crates/openshell-tui/src/ui/providers.rs index 4cd277af8..a7bfb7d1e 100644 --- a/crates/openshell-tui/src/ui/providers.rs +++ b/crates/openshell-tui/src/ui/providers.rs @@ -10,6 +10,11 @@ use crate::app::App; pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { let t = &app.theme; + if app.providers_v2_enabled { + draw_v2(frame, app, area, focused); + return; + } + let header = Row::new(vec![ Cell::from(Span::styled(" NAME", t.muted)), Cell::from(Span::styled("TYPE", t.muted)), @@ -88,3 +93,92 @@ pub fn draw(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { frame.render_widget(msg, inner); } } + +fn draw_v2(frame: &mut Frame<'_>, app: &App, area: Rect, focused: bool) { + let t = &app.theme; + let header = Row::new(vec![ + Cell::from(Span::styled(" NAME", t.muted)), + Cell::from(Span::styled("PROFILE", t.muted)), + Cell::from(Span::styled("CATEGORY", t.muted)), + Cell::from(Span::styled("CREDS", t.muted)), + Cell::from(Span::styled("POLICY", t.muted)), + ]) + .bottom_margin(1); + + let rows: Vec> = app + .provider_entries + .iter() + .enumerate() + .map(|(i, entry)| { + let selected = focused && i == app.provider_selected; + let name_cell = if selected { + Cell::from(Line::from(vec![ + Span::styled("> ", t.accent), + Span::styled(entry.name(), t.text), + ])) + } else { + Cell::from(Line::from(vec![ + Span::raw(" "), + Span::styled(entry.name(), t.text), + ])) + }; + + let profile_style = if entry.profile.is_some() { + t.text + } else { + t.status_warn + }; + + Row::new(vec![ + name_cell, + Cell::from(Span::styled(entry.profile_label(), profile_style)), + Cell::from(Span::styled(entry.category_label(), t.muted)), + Cell::from(Span::styled(entry.credential_summary(), t.muted)), + Cell::from(Span::styled(entry.policy_summary(), t.muted)), + ]) + }) + .collect(); + + let widths = [ + Constraint::Percentage(22), + Constraint::Percentage(24), + Constraint::Percentage(16), + Constraint::Percentage(18), + Constraint::Percentage(20), + ]; + + let border_style = if focused { t.border_focused } else { t.border }; + let title = if focused && app.confirm_provider_delete { + let name = app + .provider_names + .get(app.provider_selected) + .map_or("-", String::as_str); + Line::from(vec![ + Span::styled(" Delete '", t.status_err), + Span::styled(name, t.status_err), + Span::styled("'? [y/n] ", t.status_err), + ]) + } else { + super::global_settings::draw_tab_title(app, focused) + }; + + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style) + .padding(Padding::horizontal(1)); + + let table = Table::new(rows, widths).header(header).block(block); + frame.render_widget(table, area); + + if app.provider_count == 0 { + let inner = Rect { + x: area.x + 2, + y: area.y + 2, + width: area.width.saturating_sub(4), + height: area.height.saturating_sub(3), + }; + let msg = Paragraph::new(Span::styled(" No providers found.", t.muted)); + frame.render_widget(msg, inner); + } +} diff --git a/crates/openshell-tui/src/ui/sandbox_draft.rs b/crates/openshell-tui/src/ui/sandbox_draft.rs index 38214d6da..9785f150c 100644 --- a/crates/openshell-tui/src/ui/sandbox_draft.rs +++ b/crates/openshell-tui/src/ui/sandbox_draft.rs @@ -4,7 +4,7 @@ //! Network rules panel for the sandbox screen. use crate::app::App; -use openshell_core::proto::PolicyChunk; +use openshell_core::proto::{L7Allow, L7DenyRule, L7QueryMatcher, NetworkEndpoint, PolicyChunk}; use ratatui::Frame; use ratatui::layout::Rect; use ratatui::style::Modifier; @@ -113,12 +113,12 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { spans.push(Span::raw(" ")); } - // Endpoint summary (host:port). + // Endpoint summary with L4/L7 detail. let endpoint_str = chunk .proposed_rule .as_ref() .and_then(|r| r.endpoints.first()) - .map(|ep| format!("{}:{}", ep.host, ep.port)) + .map(format_endpoint_summary) .unwrap_or_default(); spans.push(Span::styled(&chunk.rule_name, name_style)); @@ -237,8 +237,15 @@ pub fn draw_detail_popup( lines.push(Line::from(vec![ Span::raw(" "), Span::styled("-> ", t.muted), - Span::styled(format!("{}:{}", ep.host, ep.port), t.accent), + Span::styled(format_endpoint_summary(ep), t.accent), ])); + + for detail in format_endpoint_details(ep) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(detail, t.text), + ])); + } } // Binaries. @@ -375,7 +382,7 @@ pub fn draw_approve_all_popup( .proposed_rule .as_ref() .and_then(|r| r.endpoints.first()) - .map(|ep| format!("{}:{}", ep.host, ep.port)) + .map(format_endpoint_summary) .unwrap_or_default(); // Truncate to fit within the popup width. @@ -432,6 +439,156 @@ fn truncate_str(s: &str, max_len: usize) -> String { } } +fn format_endpoint_summary(endpoint: &NetworkEndpoint) -> String { + let host_port = if endpoint.port > 0 { + format!("{}:{}", endpoint.host, endpoint.port) + } else { + endpoint.host.clone() + }; + + let mut tags = vec![endpoint_layer_label(endpoint).to_string()]; + if !endpoint.access.is_empty() { + tags.push(format!("access={}", endpoint.access)); + } + for rule in &endpoint.rules { + if let Some(allow) = &rule.allow { + tags.push(format!("allow {}", format_allow_rule(allow))); + } + } + for deny in &endpoint.deny_rules { + tags.push(format!("deny {}", format_deny_rule(deny))); + } + + format!("{host_port} [{}]", tags.join(", ")) +} + +fn format_endpoint_details(endpoint: &NetworkEndpoint) -> Vec { + let mut details = Vec::new(); + + if !endpoint.path.is_empty() { + details.push(format!("Path scope: {}", endpoint.path)); + } + if !endpoint.tls.is_empty() { + details.push(format!("TLS: {}", endpoint.tls)); + } + if !endpoint.enforcement.is_empty() { + details.push(format!("Enforcement: {}", endpoint.enforcement)); + } + if endpoint.request_body_credential_rewrite { + details.push("Request body credential rewrite".to_string()); + } + if endpoint.websocket_credential_rewrite { + details.push("WebSocket credential rewrite".to_string()); + } + for rule in &endpoint.rules { + if let Some(allow) = &rule.allow { + details.push(format!("Allow: {}", format_allow_rule(allow))); + } + } + for deny in &endpoint.deny_rules { + details.push(format!("Deny: {}", format_deny_rule(deny))); + } + + details +} + +fn endpoint_layer_label(endpoint: &NetworkEndpoint) -> &str { + if endpoint.protocol.eq_ignore_ascii_case("rest") { + "L7 rest" + } else if endpoint.protocol.is_empty() { + "L4" + } else { + endpoint.protocol.as_str() + } +} + +fn format_allow_rule(allow: &L7Allow) -> String { + let mut parts = Vec::new(); + if !allow.method.is_empty() || !allow.path.is_empty() { + parts.push(format!( + "{} {}", + non_empty_or(&allow.method, "*"), + non_empty_or(&allow.path, "*") + )); + } + if !allow.command.is_empty() { + parts.push(format!("command {}", allow.command)); + } + if !allow.operation_type.is_empty() || !allow.operation_name.is_empty() { + parts.push(format!( + "graphql {} {}", + non_empty_or(&allow.operation_type, "*"), + non_empty_or(&allow.operation_name, "*") + )); + } + if !allow.fields.is_empty() { + parts.push(format!("fields {}", allow.fields.join(","))); + } + append_query_matchers(&mut parts, &allow.query); + if parts.is_empty() { + "*".to_string() + } else { + parts.join("; ") + } +} + +fn format_deny_rule(deny: &L7DenyRule) -> String { + let mut parts = Vec::new(); + if !deny.method.is_empty() || !deny.path.is_empty() { + parts.push(format!( + "{} {}", + non_empty_or(&deny.method, "*"), + non_empty_or(&deny.path, "*") + )); + } + if !deny.command.is_empty() { + parts.push(format!("command {}", deny.command)); + } + if !deny.operation_type.is_empty() || !deny.operation_name.is_empty() { + parts.push(format!( + "graphql {} {}", + non_empty_or(&deny.operation_type, "*"), + non_empty_or(&deny.operation_name, "*") + )); + } + if !deny.fields.is_empty() { + parts.push(format!("fields {}", deny.fields.join(","))); + } + append_query_matchers(&mut parts, &deny.query); + if parts.is_empty() { + "*".to_string() + } else { + parts.join("; ") + } +} + +fn append_query_matchers( + parts: &mut Vec, + query: &std::collections::HashMap, +) { + if query.is_empty() { + return; + } + let mut entries: Vec<_> = query.iter().collect(); + entries.sort_by_key(|(key, _)| *key); + let formatted = entries + .into_iter() + .map(|(key, matcher)| { + if matcher.any.is_empty() { + format!("{key}={}", non_empty_or(&matcher.glob, "*")) + } else { + format!("{key} in [{}]", matcher.any.join(",")) + } + }) + .collect::>() + .join(", "); + parts.push(format!("query {formatted}")); +} + +fn non_empty_or<'a>(value: &'a str, fallback: &'a str) -> &'a str { + if value.is_empty() { fallback } else { value } +} + fn format_short_time(epoch_ms: i64) -> String { if epoch_ms <= 0 { return String::from("--:--:--"); diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index d7e762445..759987727 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -138,12 +138,14 @@ The following steps outline the hot-reload policy update workflow. `--add-allow` and `--add-deny` target existing `protocol: rest` or `protocol: websocket` endpoints. If you pass multiple update flags in one command, OpenShell applies them as one atomic merge batch and persists at most one new revision. -4. For larger edits, pull the current policy and edit the YAML directly. Strip the metadata header (Version, Hash, Status) before reusing the file. +4. For larger edits, pull the current effective policy and edit the YAML directly. Before reusing the file, strip the metadata header above the `---` line. If the sandbox has attached Providers v2 providers, remove generated `_provider_*` entries before reapplying the policy; those entries are derived from provider profiles. ```shell openshell policy get --full > current-policy.yaml ``` + To inspect a stored sandbox-authored revision instead of the current effective policy, pass `--rev `. + 5. Edit the YAML: add or adjust `network_policies` entries, binaries, `access`, or `rules`. 6. Push the updated policy when you need a full replacement. Exit codes: 0 = loaded, 1 = validation failed, 124 = timeout. diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 9a4a9dc35..282bd8e78 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -93,8 +93,12 @@ Built-in Providers v2 profiles currently include: | Profile ID | Category | Credential environment variables | |---|---|---| | `claude-code` | `agent` | `ANTHROPIC_API_KEY`, `CLAUDE_API_KEY` | +| `codex` | `agent` | `CODEX_AUTH_ACCESS_TOKEN`, `CODEX_AUTH_REFRESH_TOKEN`, `CODEX_AUTH_ACCOUNT_ID`, `CODEX_AUTH_ID_TOKEN` | +| `copilot` | `agent` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | +| `cursor` | `agent` | None | | `github` | `source_control` | `GITHUB_TOKEN`, `GH_TOKEN` | | `nvidia` | `inference` | `NVIDIA_API_KEY` | +| `pypi` | `data` | None | Export a built-in profile as YAML: diff --git a/providers/codex.yaml b/providers/codex.yaml new file mode 100644 index 000000000..7edd86a97 --- /dev/null +++ b/providers/codex.yaml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: codex +display_name: Codex +description: OpenAI Codex CLI +category: agent +inference_capable: true +credentials: + - name: access_token + description: Codex OAuth access token + env_vars: [CODEX_AUTH_ACCESS_TOKEN] + required: true + - name: refresh_token + description: Codex OAuth refresh token + env_vars: [CODEX_AUTH_REFRESH_TOKEN] + required: true + - name: account_id + description: Codex account identifier + env_vars: [CODEX_AUTH_ACCOUNT_ID] + required: true + - name: id_token + description: Codex OAuth ID token + env_vars: [CODEX_AUTH_ID_TOKEN] +discovery: + credentials: [access_token, refresh_token, account_id, id_token] +endpoints: + - host: api.openai.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: auth.openai.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: chatgpt.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: ab.chatgpt.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/codex, /usr/local/bin/codex, /usr/lib/node_modules/@openai/**] diff --git a/providers/copilot.yaml b/providers/copilot.yaml new file mode 100644 index 000000000..1b219fd22 --- /dev/null +++ b/providers/copilot.yaml @@ -0,0 +1,49 @@ +# 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 CLI and Copilot service APIs +category: agent +credentials: + - name: api_token + description: GitHub token used by Copilot + env_vars: [COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_token] +endpoints: + - host: api.githubcopilot.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: api.individual.githubcopilot.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: api.business.githubcopilot.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: api.enterprise.githubcopilot.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: copilot-proxy.githubusercontent.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce + - host: telemetry.enterprise.githubcopilot.com + port: 443 + - host: default.exp-tas.com + port: 443 +binaries: + - /usr/bin/copilot + - /usr/lib/node_modules/@github/copilot/node_modules/@github/**/copilot diff --git a/providers/cursor.yaml b/providers/cursor.yaml new file mode 100644 index 000000000..a8899bb16 --- /dev/null +++ b/providers/cursor.yaml @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: cursor +display_name: Cursor +description: Cursor editor server bootstrap and update endpoints +category: agent +endpoints: + - host: cursor.blob.core.windows.net + port: 443 + - host: api2.cursor.sh + port: 443 + - host: repo.cursor.sh + port: 443 + - host: download.cursor.sh + port: 443 + - host: cursor.download.prss.microsoft.com + port: 443 +binaries: + - /usr/bin/curl + - /usr/bin/wget + - /sandbox/.cursor-server/** diff --git a/providers/pypi.yaml b/providers/pypi.yaml new file mode 100644 index 000000000..c129b45d5 --- /dev/null +++ b/providers/pypi.yaml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +id: pypi +display_name: PyPI +description: Python package installation from PyPI and related package sources +category: data +endpoints: + - host: pypi.org + port: 443 + - host: files.pythonhosted.org + port: 443 + - host: github.com + port: 443 + - host: objects.githubusercontent.com + port: 443 + - host: api.github.com + port: 443 + - host: downloads.python.org + port: 443 +binaries: + - /sandbox/.venv/bin/python + - /sandbox/.venv/bin/python3 + - /sandbox/.venv/bin/pip + - /app/.venv/bin/python + - /app/.venv/bin/python3 + - /app/.venv/bin/pip + - /usr/local/bin/uv + - /sandbox/.uv/python/**