From 24a53a408f629309be1968edbee946497f10cbf6 Mon Sep 17 00:00:00 2001 From: Shiju Date: Tue, 15 Sep 2026 12:46:06 +0530 Subject: [PATCH] feat(cli): promote profile commands to top level Add profile discovery and management commands with shared handlers for the existing provider entry points. List a flat catalog across scopes and follow continuation tokens through full and short pages. Describe metadata, credentials, endpoints, TLS inspection, and MCP access settings while preserving complete JSON/YAML definitions. Cover parser equivalence, scope forwarding, pagination, and inspection settings with focused unit and compiled-CLI integration tests. Update docs, public skills, examples, and E2E command invocations. Refs #2588 Signed-off-by: Shiju --- architecture/gateway.md | 2 + crates/openshell-cli/src/commands/provider.rs | 705 ++++++++++++++++-- crates/openshell-cli/src/main.rs | 524 +++++++++---- crates/openshell-cli/src/run.rs | 3 +- .../tests/provider_commands_integration.rs | 323 +++++++- .../microsoft-graph-provider-refresh.mdx | 4 +- docs/providers/profiles.mdx | 62 +- docs/sandboxes/manage-providers.mdx | 13 +- docs/sandboxes/policies.mdx | 2 +- e2e/rust/tests/credential_gating.rs | 8 +- e2e/rust/tests/host_gateway_alias.rs | 7 +- e2e/rust/tests/provider_refresh_handles.rs | 4 +- e2e/rust/tests/provider_token_exchange.rs | 6 +- e2e/rust/tests/proxy_egress_pipeline.rs | 4 +- e2e/rust/tests/websocket_conformance.rs | 4 +- examples/governance-interceptor/README.md | 4 +- examples/governance-interceptor/smoke.sh | 22 +- examples/spiffe-token-exchange-demo/README.md | 2 +- examples/spiffe-token-exchange-demo/demo.sh | 6 +- .../spiffe-token-exchange-demo/podman/demo.sh | 6 +- examples/spiffe-token-grant-demo/README.md | 2 +- examples/spiffe-token-grant-demo/demo.sh | 6 +- scripts/agents/run.sh | 8 +- skills/debug-inference/SKILL.md | 6 +- skills/openshell-cli/SKILL.md | 28 +- .../keycloak/tests/provider_refresh.rs | 4 +- 26 files changed, 1454 insertions(+), 311 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index b277c32daa..b5104fb1a4 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -262,6 +262,8 @@ IDs fail instead of creating source precedence. The gateway treats configured interceptors as trusted sources and does not verify signature annotations in their profile payloads. +The CLI exposes reusable profile definitions through `openshell profile`, with `list` and `describe` reading the same effective catalog used by provider creation. Export, import, update, lint, and delete share that top-level command group. Workspace selection and explicit platform scope apply at the existing profile API boundary; `openshell provider` manages credential-bearing instances. + Each logical gateway request captures the selected sources into one validated, immutable effective catalog before deriving provider behavior. Policy layers, credential scope, injected environment material, dynamic token grants, and diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs index 05febb396d..6f29ab8443 100644 --- a/crates/openshell-cli/src/commands/provider.rs +++ b/crates/openshell-cli/src/commands/provider.rs @@ -28,6 +28,9 @@ use openshell_core::proto::{ }; use openshell_core::rpc_error::{ERROR_DOMAIN, decode_details}; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; +use openshell_policy::{ + network_access_preset_to_str, network_enforcement_mode_to_str, network_tls_mode_to_str, +}; use openshell_providers::{ ProviderTypeProfile, RealDiscoveryContext, discover_from_profile, parse_profile_json, parse_profile_yaml, profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, @@ -1558,54 +1561,81 @@ pub async fn provider_list( Ok(()) } +/// List the provider profiles visible in the requested workspace. pub async fn provider_list_profiles( server: &str, output: &str, workspace: &str, tls: &TlsOptions, ) -> Result<()> { + let rendered = provider_list_profiles_text(server, output, workspace, tls).await?; + println!("{}", rendered.trim_end()); + Ok(()) +} + +/// Fetch and render every page of the visible provider profile catalog. +pub async fn provider_list_profiles_text( + server: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result { let mut client = grpc_client(server, tls).await?; let mut dto_profiles = fetch_provider_profile_catalog(&mut client, workspace).await?; dto_profiles.sort_by(|left, right| { - left.category - .cmp(&right.category) - .then_with(|| left.id.cmp(&right.id)) + left.id + .cmp(&right.id) + .then_with(|| left.scope.cmp(&right.scope)) + .then_with(|| left.source.cmp(&right.source)) }); let profiles = dto_profiles .iter() .map(ProviderTypeProfile::to_proto) .collect::>(); - if crate::output::print_output_direct( - output, - || profiles_to_json(&dto_profiles).into_diagnostic(), - || profiles_to_yaml(&dto_profiles).into_diagnostic(), - )? { - return Ok(()); + match output { + "json" => profiles_to_json(&dto_profiles).into_diagnostic(), + "yaml" => profiles_to_yaml(&dto_profiles).into_diagnostic(), + "table" => Ok(format_provider_profile_table(&profiles)), + _ => Err(miette!("unsupported output format: {output}")), } +} - if profiles.is_empty() { - println!("No provider profiles found."); - return Ok(()); - } +/// Describe one resolved provider profile without retrieving provider credentials. +pub async fn provider_profile_describe( + server: &str, + id: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let rendered = provider_profile_describe_text(server, id, output, workspace, tls).await?; + println!("{}", rendered.trim_end()); + Ok(()) +} - println!("{}", "Available Provider Profiles:".cyan().bold()); - let id_width = provider_profile_id_width(&profiles); - let display_width = provider_profile_display_width(&profiles); - let source_width = provider_profile_source_width(&profiles); - let scope_width = provider_profile_scope_width(&profiles); - let mut current_category = i32::MIN; - for profile in &profiles { - if profile.category != current_category { - current_category = profile.category; - println!(); - println!(" {}", display_provider_category(current_category).bold()); - print_provider_type_header(id_width, scope_width, source_width, display_width); - } - print_provider_type_row(profile, id_width, scope_width, source_width, display_width); - } +/// Fetch and render a profile definition using workspace-aware resolution. +pub async fn provider_profile_describe_text( + server: &str, + id: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result { + let mut client = grpc_client(server, tls).await?; + let response = client + .get_provider_profile(GetProviderProfileRequest { + id: id.to_string(), + workspace_scope: provider_profile_workspace_scope(workspace), + }) + .await + .into_diagnostic()?; + let profile = response + .into_inner() + .profile + .ok_or_else(|| miette!("provider profile '{id}' not found"))?; - Ok(()) + format_provider_profile_description(&profile, output) } pub async fn provider_profile_export( @@ -2218,7 +2248,6 @@ fn display_provider_category(category: i32) -> &'static str { } const PROVIDER_PROFILE_ID_MAX_WIDTH: usize = 32; -const PROVIDER_PROFILE_DISPLAY_MAX_WIDTH: usize = 40; const PROVIDER_PROFILE_SOURCE_MAX_WIDTH: usize = 24; fn provider_profile_id_width(profiles: &[ProviderProfile]) -> usize { @@ -2232,21 +2261,6 @@ fn provider_profile_id_width(profiles: &[ProviderProfile]) -> usize { .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) } @@ -2275,40 +2289,226 @@ fn provider_profile_source_width(profiles: &[ProviderProfile]) -> usize { .max(6) } -fn print_provider_type_header( - id_width: usize, - scope_width: usize, - source_width: usize, - display_width: usize, -) { - let endpoints = "ENDPOINTS"; - println!( - " {: String { + use std::fmt::Write as _; + + if profiles.is_empty() { + return "No profiles found.\n".to_string(); + } + + let id_width = provider_profile_id_width(profiles); + let scope_width = provider_profile_scope_width(profiles); + let source_width = provider_profile_source_width(profiles); + let category_width = profiles + .iter() + .map(|profile| display_provider_category(profile.category).len()) + .max() + .unwrap_or(8) + .max(8); + let mut rendered = String::new(); + let _ = writeln!( + rendered, + "{: Result { + let dto = ProviderTypeProfile::from_proto(profile); + match output { + "json" => profile_to_json(&dto).into_diagnostic(), + "yaml" => profile_to_yaml(&dto).into_diagnostic(), + "table" => Ok(format_provider_profile_details(profile)), + _ => Err(miette!("unsupported output format: {output}")), + } +} + +fn format_provider_profile_details(profile: &ProviderProfile) -> String { + use std::fmt::Write as _; + + let mut rendered = format!("{} (provider)\n", profile.id); + for (label, value) in [ + ("Category", display_provider_category(profile.category)), + ("Display name", profile.display_name.as_str()), + ("Description", profile.description.as_str()), + ("Source", profile.source.as_str()), + ("Scope", profile.scope.as_str()), + ] { + let value = if value.is_empty() { + "not specified" + } else { + value + }; + let _ = writeln!(rendered, "{label}: {value}"); + } + let _ = writeln!(rendered, "Inference capable: {}", profile.inference_capable); + + rendered.push_str("\nCredentials:\n"); + if profile.credentials.is_empty() { + rendered.push_str(" None declared.\n"); + } + // Profiles declare credential names and injection metadata. Never resolve + // local discovery sources or provider instances while describing a profile. + for credential in &profile.credentials { + let requirement = if credential.required { + "required" + } else { + "optional" + }; + let _ = writeln!(rendered, " {} ({requirement})", credential.name); + if !credential.description.is_empty() { + let _ = writeln!(rendered, " Description: {}", credential.description); + } + let env_vars = if credential.env_vars.is_empty() { + "none declared".to_string() + } else { + credential.env_vars.join(", ") + }; + let _ = writeln!(rendered, " Environment variables: {env_vars}"); + let auth_style = if credential.auth_style.is_empty() { + "not specified" + } else { + &credential.auth_style + }; + let _ = writeln!(rendered, " Authentication: {auth_style}"); + if !credential.header_name.is_empty() { + let _ = writeln!(rendered, " Header: {}", credential.header_name); + } + if !credential.query_param.is_empty() { + let _ = writeln!(rendered, " Query parameter: {}", credential.query_param); + } + } + + rendered.push_str("\nEndpoints (declared policy):\n"); + if profile.endpoints.is_empty() { + rendered.push_str(" None declared.\n"); + } + for endpoint in &profile.endpoints { + let host = if endpoint.host.is_empty() { + "any host matching allowed IPs" + } else { + &endpoint.host + }; + let _ = writeln!(rendered, " {host}"); + // The endpoint contract gives the repeated ports field precedence over + // the single port; display the effective declared set in that order. + let ports = if !endpoint.ports.is_empty() { + endpoint + .ports + .iter() + .map(u32::to_string) + .collect::>() + .join(", ") + } else if endpoint.port != 0 { + endpoint.port.to_string() + } else { + "not specified".to_string() + }; + let _ = writeln!(rendered, " Ports: {ports}"); + if !endpoint.path.is_empty() { + let _ = writeln!(rendered, " Path: {}", endpoint.path); + } + let protocol = match endpoint.protocol.as_str() { + "" => "tcp (default; no L7 inspection)", + "tcp" => "tcp (no L7 inspection)", + protocol => protocol, + }; + let _ = writeln!(rendered, " Protocol: {protocol}"); + // A declared L7 protocol does not imply inspection when TLS handling + // selects a raw tunnel. Keep this separate from the declared rules. + // Unknown protobuf values must remain visible rather than appearing + // to select the valid default mode. + let tls = match network_tls_mode_to_str(endpoint.tls) { + Some("") => "auto (default)".to_string(), + Some("skip") => "skip (raw tunnel; no L7 inspection or credential rewrite)".to_string(), + Some("terminate") => "terminate (automatic TLS detection)".to_string(), + Some("passthrough") => "passthrough (automatic TLS detection)".to_string(), + Some(tls) => tls.to_string(), + None => format!("unknown({})", endpoint.tls), + }; + let _ = writeln!(rendered, " TLS: {tls}"); + let _ = writeln!( + rendered, + " Allow uninspected credentials: {}", + endpoint.allow_uninspected_credentials + ); + let mcp = endpoint.mcp.as_ref(); + let is_mcp = endpoint.protocol.eq_ignore_ascii_case("mcp"); + let allow_all_known_mcp_methods = + mcp.and_then(|options| options.allow_all_known_mcp_methods); + // An explicit rule set can include writes even when other endpoints + // use read-only presets. Report its shape without guessing its access. + let access = match network_access_preset_to_str(endpoint.access) { + Some("") if !endpoint.rules.is_empty() => "custom rules".to_string(), + Some("") if is_mcp && allow_all_known_mcp_methods == Some(true) => { + "all known MCP methods (subject to tool and deny rules)".to_string() + } + Some("") => "not specified".to_string(), + Some(access) => access.to_string(), + None => format!("unknown({})", endpoint.access), + }; + let _ = writeln!(rendered, " Access: {access}"); + if !endpoint.rules.is_empty() || !endpoint.deny_rules.is_empty() { + let _ = writeln!( + rendered, + " Rules: {} allow, {} deny", + endpoint.rules.len(), + endpoint.deny_rules.len() + ); + } + let enforcement = match network_enforcement_mode_to_str(endpoint.enforcement) { + Some("") => "audit (default)".to_string(), + Some(enforcement) => enforcement.to_string(), + None => format!("unknown({})", endpoint.enforcement), + }; + let _ = writeln!(rendered, " Enforcement: {enforcement}"); + if is_mcp { + // MCP method defaults and tool-name validation are independent of + // explicit allow/deny rules; show both even when rules are present. + let methods = allow_all_known_mcp_methods + .map_or_else(|| "false (default)".to_string(), |value| value.to_string()); + let strict_names = mcp + .and_then(|options| options.strict_tool_names) + .map_or_else(|| "true (default)".to_string(), |value| value.to_string()); + let versions = mcp + .filter(|options| !options.versions.is_empty()) + .map_or_else( + || "not specified".to_string(), + |options| options.versions.join(", "), + ); + let _ = writeln!(rendered, " Allow all known MCP methods: {methods}"); + let _ = writeln!(rendered, " Strict MCP tool names: {strict_names}"); + let _ = writeln!(rendered, " MCP versions (declared): {versions}"); + } + if !endpoint.allowed_ips.is_empty() { + let _ = writeln!( + rendered, + " Allowed IPs: {}", + endpoint.allowed_ips.join(", ") + ); + } + } + + rendered.push_str("\nBinaries:\n"); + if profile.binaries.is_empty() { + rendered.push_str(" None declared.\n"); + } + for binary in &profile.binaries { + let _ = writeln!(rendered, " {}", binary.path); + } + rendered } /// Credential update inputs and observation choices for all attached sandboxes. @@ -2562,6 +2762,359 @@ mod tests { )); } + #[test] + fn profile_list_table_includes_type_category_source_and_scope() { + let profiles = vec![ + ProviderProfile { + id: "github".to_string(), + category: ProviderProfileCategory::SourceControl as i32, + source: "builtin".to_string(), + scope: "platform".to_string(), + ..Default::default() + }, + ProviderProfile { + id: "github".to_string(), + category: ProviderProfileCategory::SourceControl as i32, + source: "user".to_string(), + scope: "workspace".to_string(), + ..Default::default() + }, + ProviderProfile { + id: "search".to_string(), + category: ProviderProfileCategory::Knowledge as i32, + source: "interceptor/search".to_string(), + ..Default::default() + }, + ]; + + let rendered = format_provider_profile_table(&profiles); + let rows = rendered.lines().collect::>(); + assert_eq!(rows.len(), 4); + assert_eq!( + rows[0].split_whitespace().collect::>(), + ["NAME", "TYPE", "CATEGORY", "SOURCE", "SCOPE"] + ); + assert_eq!( + rows[1].split_whitespace().collect::>(), + [ + "github", "provider", "SOURCE", "CONTROL", "builtin", "platform" + ] + ); + assert_eq!( + rows[2].split_whitespace().collect::>(), + [ + "github", + "provider", + "SOURCE", + "CONTROL", + "user", + "workspace" + ] + ); + assert_eq!( + rows[3].split_whitespace().collect::>(), + ["search", "provider", "KNOWLEDGE", "interceptor/search"] + ); + } + + #[test] + fn profile_list_table_reports_empty_catalog() { + assert_eq!(format_provider_profile_table(&[]), "No profiles found.\n"); + } + + #[test] + fn profile_description_keeps_credential_values_out_of_human_output() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _credential = EnvVarGuard::set("GITHUB_TOKEN", "test-private-token-value"); + let mut profile = openshell_providers::example_profiles::load("github").to_proto(); + profile.source = "user".to_string(); + profile.scope = "platform".to_string(); + + let rendered = + format_provider_profile_description(&profile, "table").expect("profile details render"); + assert!(rendered.starts_with("github (provider)\nCategory: SOURCE CONTROL\n")); + assert!( + rendered.contains("Display name: GitHub\nDescription: GitHub API and Git operations\n") + ); + assert!(rendered.contains("Source: user\nScope: platform\n")); + assert!(rendered.contains("Environment variables: GITHUB_TOKEN, GH_TOKEN\n")); + assert!(rendered.contains("Authentication: bearer\n")); + assert!(rendered.contains("Header: authorization\n")); + assert!(!rendered.contains("test-private-token-value")); + assert!(!rendered.contains("Default URL")); + assert!(rendered.contains("Protocol: graphql\n")); + assert!(rendered.contains("Path: /graphql\n")); + assert_eq!(rendered.matches("Access: read-only\n").count(), 2); + assert!(rendered.contains("Access: custom rules\n Rules: 4 allow, 0 deny\n")); + assert!(rendered.contains("Enforcement: enforce\n")); + assert!(rendered.contains(" /usr/bin/gh\n")); + assert!(rendered.contains(" /usr/local/bin/git\n")); + } + + #[test] + fn profile_description_distinguishes_defaults_and_explicit_policy() { + let profile = ProviderProfile { + id: "custom".to_string(), + endpoints: vec![ + openshell_core::proto::NetworkEndpoint { + host: "api.example.com".to_string(), + port: 443, + ports: vec![8443, 9443], + ..Default::default() + }, + openshell_core::proto::NetworkEndpoint { + host: "write.example.com".to_string(), + port: 443, + protocol: "rest".to_string(), + access: openshell_core::proto::NetworkAccessPreset::ReadWrite.into(), + enforcement: openshell_core::proto::NetworkEnforcementMode::Audit.into(), + ..Default::default() + }, + ], + ..Default::default() + }; + + let rendered = format_provider_profile_details(&profile); + assert!(rendered.contains("Ports: 8443, 9443\n")); + assert!(rendered.contains("Protocol: tcp (default; no L7 inspection)\n")); + assert!(rendered.contains("Access: not specified\n Enforcement: audit (default)\n")); + assert!(rendered.contains("Access: read-write\n Enforcement: audit\n")); + assert!(!rendered.contains("read-only")); + assert!(rendered.contains("Credentials:\n None declared.\n")); + assert!(rendered.contains("Binaries:\n None declared.\n")); + } + + #[test] + fn profile_description_distinguishes_tls_skip_and_credential_opt_in() { + let profile = parse_profile_yaml( + r" +id: custom +display_name: Custom API +credentials: + - name: token + env_vars: [CUSTOM_TOKEN] + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: api.example.com + port: 443 + protocol: rest + access: read-only + enforcement: enforce +binaries: [/usr/bin/curl] +", + ) + .expect("valid profile fixture"); + let mut descriptions = Vec::new(); + for (tls, allow_uninspected_credentials) in [ + ("", false), + ("", true), + ("skip", true), + ("terminate", false), + ("passthrough", false), + ] { + let mut variant = profile.clone(); + variant.endpoints[0].tls = tls.to_string(); + variant.endpoints[0].allow_uninspected_credentials = allow_uninspected_credentials; + let diagnostics = openshell_providers::validate_profile_set(&[( + "profile.yaml".to_string(), + variant.clone(), + )]); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic.severity != "error"), + "variant must be importable: {diagnostics:?}" + ); + descriptions.push(format_provider_profile_details(&variant.to_proto())); + } + + assert_ne!( + descriptions[0], descriptions[1], + "the credential inspection opt-in must be visible independently of TLS mode" + ); + assert_ne!( + descriptions[1], descriptions[2], + "a raw tunnel must not look identical to an inspected endpoint" + ); + assert!(descriptions[0].contains("TLS: auto (default)\n")); + assert!(descriptions[0].contains("Allow uninspected credentials: false\n")); + assert!( + descriptions[2] + .contains("TLS: skip (raw tunnel; no L7 inspection or credential rewrite)\n") + ); + assert!(descriptions[2].contains("Allow uninspected credentials: true\n")); + assert!(descriptions[3].contains("TLS: terminate (automatic TLS detection)\n")); + assert!(descriptions[4].contains("TLS: passthrough (automatic TLS detection)\n")); + } + + #[test] + fn profile_description_preserves_unknown_security_modes() { + // Exercise each independent wire field with other modes left valid. + // Unknown access must also take precedence over the inferred rule label. + for value in [-1, 99] { + for field in ["TLS", "Access", "Enforcement"] { + let mut endpoint = openshell_core::proto::NetworkEndpoint { + host: "api.example.com".to_string(), + rules: vec![openshell_core::proto::L7Rule::default()], + ..Default::default() + }; + match field { + "TLS" => endpoint.tls = value, + "Access" => endpoint.access = value, + "Enforcement" => endpoint.enforcement = value, + _ => unreachable!("test cases enumerate the security mode fields"), + } + let profile = ProviderProfile { + endpoints: vec![endpoint], + ..Default::default() + }; + let rendered = format_provider_profile_details(&profile); + assert!( + rendered.contains(&format!(" {field}: unknown({value})\n")), + "unknown {field} must not appear as a valid default: {rendered}" + ); + } + } + } + + #[test] + fn profile_description_displays_each_access_preset() { + use openshell_core::proto::NetworkAccessPreset; + + for (access, expected) in [ + (NetworkAccessPreset::Unspecified, "not specified"), + (NetworkAccessPreset::ReadOnly, "read-only"), + (NetworkAccessPreset::ReadWrite, "read-write"), + (NetworkAccessPreset::Full, "full"), + ] { + let profile = ProviderProfile { + endpoints: vec![openshell_core::proto::NetworkEndpoint { + access: access.into(), + ..Default::default() + }], + ..Default::default() + }; + let rendered = format_provider_profile_details(&profile); + assert!(rendered.contains(&format!(" Access: {expected}\n"))); + } + } + + #[test] + fn profile_description_exposes_mcp_method_and_tool_name_options() { + let profile = parse_profile_yaml( + r" +id: mcp-review +display_name: MCP Review +endpoints: + - host: mcp.example.com + port: 443 + protocol: mcp + enforcement: enforce + mcp: + versions: ['2025-11-25'] + allow_all_known_mcp_methods: true +binaries: [/usr/bin/curl] +", + ) + .expect("valid profile fixture"); + for protocol in ["mcp", "MCP", "McP"] { + let mut variant = profile.clone(); + variant.endpoints[0].protocol = protocol.to_string(); + let diagnostics = openshell_providers::validate_profile_set(&[( + "profile.yaml".to_string(), + variant.clone(), + )]); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic.severity != "error"), + "ruleless MCP opt-in must be importable: {diagnostics:?}" + ); + let mut proto = variant.to_proto(); + let rendered = format_provider_profile_details(&proto); + assert!( + rendered + .contains("Access: all known MCP methods (subject to tool and deny rules)\n") + ); + assert!(rendered.contains("Allow all known MCP methods: true\n")); + assert!(rendered.contains("Strict MCP tool names: true (default)\n")); + assert!(rendered.contains("MCP versions (declared): 2025-11-25\n")); + + // Explicit rules remain relevant when the method default is enabled, + // and independent tool-name validation must not disappear from view. + proto.endpoints[0].rules = vec![openshell_core::proto::L7Rule { + allow: Some(openshell_core::proto::L7Allow { + method: "tools/list".to_string(), + ..Default::default() + }), + }]; + for allow_all in [false, true] { + let options = proto.endpoints[0].mcp.as_mut().expect("MCP options"); + options.allow_all_known_mcp_methods = Some(allow_all); + options.strict_tool_names = Some(false); + let rendered = format_provider_profile_details(&proto); + assert!(rendered.contains("Access: custom rules\n Rules: 1 allow, 0 deny\n")); + assert!(rendered.contains(&format!("Allow all known MCP methods: {allow_all}\n"))); + assert!(rendered.contains("Strict MCP tool names: false\n")); + } + } + } + + #[test] + fn profile_description_structured_output_preserves_full_definition() { + let profile = parse_profile_yaml( + r" +id: custom +display_name: Custom API +category: data +source: user +scope: workspace +resource_version: 7 +annotations: { owner: example } +credentials: + - name: token + env_vars: [CUSTOM_TOKEN] + required: true + auth_style: bearer +endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + allow_encoded_slash: true + rules: + - allow: + method: GET + path: /records/** + query: { mode: safe } + deny_rules: + - method: GET + path: /records/private/** +binaries: [/usr/bin/curl] +", + ) + .expect("valid profile fixture"); + let proto = profile.to_proto(); + for output in ["json", "yaml"] { + let rendered = format_provider_profile_description(&proto, output) + .expect("structured description renders"); + let roundtrip = if output == "json" { + parse_profile_json(&rendered) + } else { + parse_profile_yaml(&rendered) + } + .expect("structured description is a full profile document"); + assert_eq!(roundtrip, ProviderTypeProfile::from_proto(&proto)); + assert!(rendered.contains("allow_encoded_slash")); + assert!(rendered.contains("/records/private/**")); + assert!(rendered.contains("CUSTOM_TOKEN")); + } + } + #[test] fn attached_provider_json_is_sorted_and_secret_safe() { let provider = Provider { diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index 139271ed0c..d646fa107a 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -267,6 +267,7 @@ const HELP_TEMPLATE: &str = "\ policy: Manage sandbox policy settings: Manage sandbox and global settings provider: Manage provider configuration + profile: Browse and manage profiles \x1b[1mGATEWAY COMMANDS\x1b[0m gateway: Manage gateways @@ -388,6 +389,14 @@ const PROVIDER_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m $ openshell provider delete openai "; +const PROFILE_EXAMPLES: &str = "\x1b[1mEXAMPLES\x1b[0m + $ openshell profile list + $ openshell profile list --type provider -o json + $ openshell profile describe openai + $ openshell profile export openai + $ openshell profile import -f custom-api.yaml +"; + const WORKSPACE_EXAMPLES: &str = "\x1b[1mALIAS\x1b[0m ws @@ -576,6 +585,13 @@ enum Commands { command: Option, }, + /// Browse and manage profiles. + #[command(after_help = PROFILE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + Profile { + #[command(subcommand)] + command: Option, + }, + /// Manage workspaces. #[command(alias = "ws", after_help = WORKSPACE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Workspace { @@ -898,7 +914,7 @@ enum ProviderCommands { all_workspaces: bool, }, - /// List available provider profiles. + /// List available provider profiles (alias for `profile list --type provider`). #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] ListProfiles { /// Output format. @@ -910,9 +926,9 @@ enum ProviderCommands { global: bool, }, - /// Manage provider profiles. + /// Manage provider profiles (alias for `profile`). #[command(subcommand, help_template = SUBCOMMAND_HELP_TEMPLATE)] - Profile(ProviderProfileCommands), + Profile(ProfileCommands), /// Update an existing provider's credentials or config. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] @@ -1034,8 +1050,46 @@ enum ProviderRefreshCommands { }, } -#[derive(Subcommand, Debug)] -enum ProviderProfileCommands { +/// Profile kinds exposed by the gateway's profile catalog. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +enum ProfileType { + /// Credential, endpoint, and policy definitions for providers. + Provider, +} + +#[derive(Subcommand, Debug, PartialEq, Eq)] +enum ProfileCommands { + /// List available profiles. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + List { + /// Filter profiles by type. + #[arg(long = "type", value_enum)] + profile_type: Option, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + + /// List platform-scoped profiles (ignores --workspace). + #[arg(long)] + global: bool, + }, + + /// Describe a profile's credentials, endpoints, and policy defaults. + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + Describe { + /// Profile id. + id: String, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, + + /// Describe a platform-scoped profile (ignores --workspace). + #[arg(long)] + global: bool, + }, + /// Export a provider profile. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Export { @@ -1111,6 +1165,83 @@ enum ProviderProfileCommands { }, } +impl ProfileCommands { + /// Execute either command spelling against the same authenticated gateway. + async fn run(self, endpoint: &str, workspace: &str, tls: &TlsOptions) -> Result<()> { + let profile_workspace = |global: bool| -> &str { if global { "" } else { workspace } }; + match self { + Self::List { + profile_type, + output, + global, + } => { + // The catalog currently contains only provider profiles. An exhaustive + // match requires a routing decision when another profile kind is added. + match profile_type { + None | Some(ProfileType::Provider) => { + run::provider_list_profiles( + endpoint, + output.as_str(), + profile_workspace(global), + tls, + ) + .await?; + } + } + } + Self::Describe { id, output, global } => { + run::provider_profile_describe( + endpoint, + &id, + output.as_str(), + profile_workspace(global), + tls, + ) + .await?; + } + Self::Export { id, output, global } => { + run::provider_profile_export( + endpoint, + &id, + output.as_str(), + profile_workspace(global), + tls, + ) + .await?; + } + Self::Import { file, from, global } => { + run::provider_profile_import( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + tls, + ) + .await?; + } + Self::Update { id, file, global } => { + run::provider_profile_update(endpoint, &id, &file, profile_workspace(global), tls) + .await?; + } + Self::Lint { file, from, global } => { + run::provider_profile_lint( + endpoint, + file.as_deref(), + from.as_deref(), + profile_workspace(global), + tls, + ) + .await?; + } + Self::Delete { ids, global } => { + run::provider_profile_delete(endpoint, &ids, profile_workspace(global), tls) + .await?; + } + } + Ok(()) + } +} + // ----------------------------------------------------------------------- // Gateway commands (replaces the old `cluster` / `cluster admin` groups) // ----------------------------------------------------------------------- @@ -3756,6 +3887,20 @@ async fn run_async() -> Result<()> { .await?; } }, + ProviderCommands::ListProfiles { output, global } => { + // Normalize the legacy list command before execution so output, + // pagination, and scope handling stay identical for both spellings. + ProfileCommands::List { + profile_type: Some(ProfileType::Provider), + output, + global, + } + .run(endpoint, &cli.workspace, &tls) + .await?; + } + ProviderCommands::Profile(command) => { + command.run(endpoint, &cli.workspace, &tls).await?; + } ProviderCommands::Get { name } => { run::provider_get(endpoint, &name, &cli.workspace, &tls).await?; } @@ -3778,65 +3923,6 @@ async fn run_async() -> Result<()> { ) .await?; } - ProviderCommands::ListProfiles { output, global } => { - let ws = if global { "" } else { &cli.workspace }; - run::provider_list_profiles(endpoint, output.as_str(), ws, &tls).await?; - } - ProviderCommands::Profile(command) => { - let profile_workspace = - |global: bool| -> &str { if global { "" } else { &cli.workspace } }; - match command { - ProviderProfileCommands::Export { id, output, global } => { - run::provider_profile_export( - endpoint, - &id, - output.as_str(), - profile_workspace(global), - &tls, - ) - .await?; - } - ProviderProfileCommands::Import { file, from, global } => { - run::provider_profile_import( - endpoint, - file.as_deref(), - from.as_deref(), - profile_workspace(global), - &tls, - ) - .await?; - } - ProviderProfileCommands::Update { id, file, global } => { - run::provider_profile_update( - endpoint, - &id, - &file, - profile_workspace(global), - &tls, - ) - .await?; - } - ProviderProfileCommands::Lint { file, from, global } => { - run::provider_profile_lint( - endpoint, - file.as_deref(), - from.as_deref(), - profile_workspace(global), - &tls, - ) - .await?; - } - ProviderProfileCommands::Delete { ids, global } => { - run::provider_profile_delete( - endpoint, - &ids, - profile_workspace(global), - &tls, - ) - .await?; - } - } - } ProviderCommands::Update { name, from_existing, @@ -3865,6 +3951,15 @@ async fn run_async() -> Result<()> { } } } + Some(Commands::Profile { + command: Some(command), + }) => { + let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; + let endpoint = &ctx.endpoint; + let mut tls = tls.with_gateway_name(&ctx.name); + apply_auth(&mut tls, &ctx.name)?; + command.run(endpoint, &cli.workspace, &tls).await?; + } Some(Commands::Term { theme }) => { let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?; let mut tls = tls.with_gateway_name(&ctx.name); @@ -3990,6 +4085,13 @@ async fn run_async() -> Result<()> { .print_help() .expect("Failed to print help"); } + Some(Commands::Profile { command: None }) => { + Cli::command() + .find_subcommand_mut("profile") + .ok_or_else(|| miette::miette!("profile command is unavailable"))? + .print_help() + .map_err(|error| miette::miette!("failed to print profile help: {error}"))?; + } Some(Commands::Provider { command: None }) => { Cli::command() .find_subcommand_mut("provider") @@ -4781,118 +4883,260 @@ mod tests { } #[test] - fn provider_list_profiles_parses() { - let cli = Cli::try_parse_from(["openshell", "provider", "list-profiles"]) - .expect("provider list-profiles should parse"); - + fn profile_list_defaults_and_type_filter_parse() { + let cli = Cli::try_parse_from(["openshell", "profile", "list"]).expect("profile list"); assert!(matches!( cli.command, - Some(Commands::Provider { - command: Some(ProviderCommands::ListProfiles { + Some(Commands::Profile { + command: Some(ProfileCommands::List { + profile_type: None, output: OutputFormat::Table, global: false, }) }) )); + for (format, expected) in [("json", OutputFormat::Json), ("yaml", OutputFormat::Yaml)] { + let cli = Cli::try_parse_from([ + "openshell", + "profile", + "list", + "--type", + "provider", + "-o", + format, + "--global", + ]) + .expect("profile list with type, output, and scope"); + assert!(matches!(cli.command, Some(Commands::Profile { + command: Some(ProfileCommands::List { + profile_type: Some(ProfileType::Provider), output, global: true, + }) + }) if output == expected)); + } + assert!( + Cli::try_parse_from(["openshell", "profile", "list", "--type", "unknown"]).is_err() + ); } #[test] - fn provider_list_profiles_accepts_output_format() { - let cli = Cli::try_parse_from(["openshell", "provider", "list-profiles", "-o", "json"]) - .expect("provider list-profiles -o json should parse"); - + fn profile_describe_parses_output_and_scope() { + for (format, expected) in [ + ("table", OutputFormat::Table), + ("json", OutputFormat::Json), + ("yaml", OutputFormat::Yaml), + ] { + let cli = Cli::try_parse_from([ + "openshell", + "--workspace", + "team", + "profile", + "describe", + "openai", + "-o", + format, + "--global", + ]) + .expect("profile describe"); + assert_eq!(cli.workspace, "team"); + assert!(matches!(cli.command, Some(Commands::Profile { + command: Some(ProfileCommands::Describe {id, output, global: true}) + }) if id == "openai" && output == expected)); + } + let cli = Cli::try_parse_from(["openshell", "profile", "describe", "openai"]) + .expect("profile describe defaults"); assert!(matches!( cli.command, - Some(Commands::Provider { - command: Some(ProviderCommands::ListProfiles { - output: OutputFormat::Json, + Some(Commands::Profile { + command: Some(ProfileCommands::Describe { + output: OutputFormat::Table, global: false, + .. }) }) )); + assert!(Cli::try_parse_from(["openshell", "profile", "describe"]).is_err()); } #[test] - fn provider_profile_commands_parse() { - let export = Cli::try_parse_from([ - "openshell", - "provider", - "profile", - "export", - "custom-api", - "-o", - "yaml", - ]) - .expect("provider profile export should parse"); - assert!(matches!( - export.command, - Some(Commands::Provider { - command: Some(ProviderCommands::Profile(ProviderProfileCommands::Export { - id, - output: OutputFormat::Yaml, - .. - })) - }) if id == "custom-api" - )); - - let import = Cli::try_parse_from([ - "openshell", - "provider", - "profile", - "import", - "--from", - "./profiles", - ]) - .expect("provider profile import should parse"); - assert!(matches!( - import.command, - Some(Commands::Provider { - command: Some(ProviderCommands::Profile(ProviderProfileCommands::Import { - from: Some(_), - .. - })) - }) - )); - + fn profile_crud_subcommands_parse() { + let export = Cli::try_parse_from(["openshell", "profile", "export", "custom-api"]) + .expect("profile export"); + assert!(matches!(export.command, Some(Commands::Profile { + command: Some(ProfileCommands::Export {id, output: OutputFormat::Yaml, global: false}) + }) if id == "custom-api")); let update = Cli::try_parse_from([ "openshell", - "provider", "profile", "update", "custom-api", "-f", - "./profiles/custom-api.yaml", + "custom-api.yaml", + "--global", ]) - .expect("provider profile update should parse"); - assert!(matches!( - update.command, - Some(Commands::Provider { - command: Some(ProviderCommands::Profile(ProviderProfileCommands::Update { - id, - file: _, - .. - })) - }) if id == "custom-api" - )); - + .expect("profile update"); + assert!(matches!(update.command, Some(Commands::Profile { + command: Some(ProfileCommands::Update {id, file, global: true}) + }) if id == "custom-api" && file == std::path::Path::new("custom-api.yaml"))); let delete = Cli::try_parse_from([ "openshell", - "provider", "profile", "delete", "custom-api", "custom-alt", + "--global", ]) - .expect("provider profile delete should parse"); + .expect("profile delete"); + assert!(matches!(delete.command, Some(Commands::Profile { + command: Some(ProfileCommands::Delete {ids, global: true}) + }) if ids == ["custom-api", "custom-alt"])); + } + + #[test] + fn profile_import_and_lint_require_exactly_one_source() { + for verb in ["import", "lint"] { + for source in ["-f", "--from"] { + let cli = Cli::try_parse_from([ + "openshell", + "profile", + verb, + source, + "./profiles", + "--global", + ]) + .expect("profile source should parse"); + let (file, from, global) = match cli.command { + Some(Commands::Profile { + command: + Some( + ProfileCommands::Import { file, from, global } + | ProfileCommands::Lint { file, from, global }, + ), + }) => (file, from, global), + other => panic!("unexpected profile command: {other:?}"), + }; + assert!(global); + assert_eq!(file.is_some(), source == "-f"); + assert_eq!(from.is_some(), source == "--from"); + } + assert!(Cli::try_parse_from(["openshell", "profile", verb]).is_err()); + assert!( + Cli::try_parse_from([ + "openshell", + "profile", + verb, + "-f", + "file.yaml", + "--from", + "./profiles" + ]) + .is_err() + ); + } + } + + #[test] + fn profile_help_exposes_all_commands_without_gateway() { + let cli = Cli::try_parse_from(["openshell", "profile"]).expect("profile help"); assert!(matches!( - delete.command, + cli.command, + Some(Commands::Profile { command: None }) + )); + let mut root = Cli::command(); + assert!(root.render_help().to_string().contains("profile:")); + let profile = root + .find_subcommand_mut("profile") + .expect("profile command"); + assert!(!profile.is_hide_set()); + for verb in [ + "list", "describe", "export", "import", "update", "lint", "delete", + ] { + assert!(profile.find_subcommand(verb).is_some(), "missing {verb}"); + } + } + + #[test] + fn profile_legacy_list_preserves_output_and_scope_flags() { + let default = Cli::try_parse_from(["openshell", "provider", "list-profiles"]) + .expect("legacy profile list"); + assert!(matches!( + default.command, Some(Commands::Provider { - command: Some(ProviderCommands::Profile(ProviderProfileCommands::Delete { - ids, - .. - })) - }) if ids == vec!["custom-api".to_string(), "custom-alt".to_string()] + command: Some(ProviderCommands::ListProfiles { + output: OutputFormat::Table, + global: false + }) + }) )); + for (format, expected) in [("json", OutputFormat::Json), ("yaml", OutputFormat::Yaml)] { + let cli = Cli::try_parse_from([ + "openshell", + "provider", + "list-profiles", + "--workspace", + "team", + "--global", + "-o", + format, + ]) + .expect("legacy list with output and scope"); + assert_eq!(cli.workspace, "team"); + assert!(matches!(cli.command, Some(Commands::Provider { + command: Some(ProviderCommands::ListProfiles {output, global: true}) + }) if output == expected)); + } + } + + #[test] + fn profile_legacy_subcommands_match_top_level_arguments() { + for args in [ + vec!["export", "openai"], + vec!["export", "openai", "-o", "json", "--global"], + vec!["import", "-f", "profile.yaml"], + vec!["import", "--from", "profiles", "--global"], + vec!["update", "custom-api", "--file", "profile.yaml", "--global"], + vec!["lint", "--file", "profile.yaml", "--global"], + vec!["lint", "--from", "profiles"], + vec!["delete", "first", "second", "--global"], + ] { + let top = Cli::try_parse_from( + ["openshell", "--workspace", "team", "profile"] + .into_iter() + .chain(args.iter().copied()), + ) + .expect("top-level profile command"); + let legacy = Cli::try_parse_from( + ["openshell", "--workspace", "team", "provider", "profile"] + .into_iter() + .chain(args.iter().copied()), + ) + .expect("nested profile command"); + assert_eq!(top.workspace, legacy.workspace); + match (top.command, legacy.command) { + ( + Some(Commands::Profile { command: Some(top) }), + Some(Commands::Provider { + command: Some(ProviderCommands::Profile(legacy)), + }), + ) => assert_eq!(top, legacy), + other => panic!("unexpected command pair: {other:?}"), + } + } + for verb in ["import", "lint"] { + assert!(Cli::try_parse_from(["openshell", "provider", "profile", verb]).is_err()); + assert!( + Cli::try_parse_from([ + "openshell", + "provider", + "profile", + verb, + "-f", + "profile.yaml", + "--from", + "profiles" + ]) + .is_err() + ); + } } #[test] diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 5ac8d4b1a0..fb0bc1d61d 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -26,7 +26,8 @@ pub use crate::commands::provider::{ ProviderCreateCredentialSource, ProviderCreateOptions, ProviderRefreshConfigInput, ProviderUpdateOptions, ensure_required_providers, provider_create, provider_create_with_options, provider_delete, provider_get, provider_list, - provider_list_profiles, provider_profile_delete, provider_profile_export, + provider_list_profiles, provider_list_profiles_text, provider_profile_delete, + provider_profile_describe, provider_profile_describe_text, provider_profile_export, provider_profile_export_text, provider_profile_import, provider_profile_lint, provider_profile_update, provider_refresh_config, provider_refresh_delete, provider_refresh_status, provider_rotate, provider_update, sandbox_provider_attach, diff --git a/crates/openshell-cli/tests/provider_commands_integration.rs b/crates/openshell-cli/tests/provider_commands_integration.rs index 4a26334c02..bffcf886f9 100644 --- a/crates/openshell-cli/tests/provider_commands_integration.rs +++ b/crates/openshell-cli/tests/provider_commands_integration.rs @@ -29,13 +29,13 @@ use openshell_core::proto::{ ProviderReadinessReason, ProviderReadinessState, ProviderReadinessStatus, ProviderResponse, RevokeSshSessionRequest, RevokeSshSessionResponse, RotateProviderCredentialRequest, RotateProviderCredentialResponse, Sandbox, SandboxResponse, SandboxStreamEvent, ServiceStatus, - SettingValue, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, + SettingValue, SupervisorMessage, UpdateProviderRequest, WatchSandboxRequest, WorkspaceSelector, }; use openshell_core::rpc_error::{ERROR_DOMAIN, ErrorDetails, StatusExt}; use openshell_core::{ObjectId, ObjectName}; use std::collections::{HashMap, VecDeque}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::time::Duration; use tempfile::TempDir; use tokio::net::TcpListener; @@ -46,6 +46,8 @@ use tonic::{Code, Response, Status}; type ReadinessScript = HashMap>; type ReceiptCorruption = fn(&mut ProviderMutationReceipt); +type ProfileListRequestLog = (Option, i32, String); +type ProfileGetRequestLog = (Option, String); #[derive(Clone)] enum ReadinessReply { @@ -87,6 +89,12 @@ struct ProviderState { fail_sandbox_reads: Arc, profile_read_errors: Arc>>, profile_read_requests: Arc>>, + // Preserve selector presence so platform requests cannot pass with an + // explicitly empty workspace instead of omitting the selector. + profile_list_requests: Arc>>, + profile_page_size_cap: Arc, + profile_get_requests: Arc>>, + omit_profile_response: Arc, delete_provider_requests: Arc>>, delete_provider_profile_requests: Arc>>, fail_configure_refresh_message: Arc>>, @@ -750,17 +758,66 @@ impl OpenShell for TestOpenShell { async fn list_provider_profiles( &self, - _request: tonic::Request, + request: tonic::Request, ) -> Result, Status> { + let request = request.into_inner(); + self.state.profile_list_requests.lock().await.push(( + request.workspace_scope.clone(), + request.page_size, + request.page_token.clone(), + )); let mut profiles = helpers::example_profiles() .iter() .map(openshell_providers::ProviderTypeProfile::to_proto) .collect::>(); profiles.extend(self.state.profiles.lock().await.values().cloned()); + profiles.extend( + self.state + .scoped_profiles + .lock() + .await + .iter() + .filter(|((workspace, _), _)| { + workspace == selected_workspace(&request.workspace_scope).unwrap_or_default() + }) + .map(|(_, profile)| profile.clone()), + ); + // The fixture owns token interpretation. The CLI must forward opaque + // tokens unchanged and follow them even when the server caps page size. + profiles.sort_by(|left, right| { + left.id + .cmp(&right.id) + .then_with(|| left.scope.cmp(&right.scope)) + }); + let offset = if request.page_token.is_empty() { + 0 + } else { + request + .page_token + .strip_prefix("profile-page:") + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| Status::invalid_argument("unknown profile page token"))? + }; + let mut page_size = usize::try_from(request.page_size) + .map_err(|_| Status::invalid_argument("negative profile page size"))?; + if page_size == 0 { + page_size = 100; + } + let cap = self.state.profile_page_size_cap.load(Ordering::SeqCst); + if cap > 0 { + page_size = page_size.min(cap); + } + let next_offset = offset.saturating_add(page_size); + let next_page_token = if next_offset < profiles.len() { + format!("profile-page:{next_offset:04}") + } else { + String::new() + }; + let profiles = profiles.into_iter().skip(offset).take(page_size).collect(); Ok(Response::new( openshell_core::proto::ListProviderProfilesResponse { profiles, - next_page_token: String::new(), + next_page_token, }, )) } @@ -770,6 +827,16 @@ impl OpenShell for TestOpenShell { request: tonic::Request, ) -> Result, Status> { let request = request.into_inner(); + self.state + .profile_get_requests + .lock() + .await + .push((request.workspace_scope.clone(), request.id.clone())); + if self.state.omit_profile_response.load(Ordering::SeqCst) { + return Ok(Response::new( + openshell_core::proto::ProviderProfileResponse { profile: None }, + )); + } let id = request.id; self.state .profile_read_requests @@ -3347,12 +3414,252 @@ async fn provider_cli_run_functions_support_full_crud_flow() { } #[tokio::test] -async fn provider_list_profiles_cli_uses_profile_browsing_rpc() { +async fn profile_list_reads_all_pages_in_the_requested_workspace() { + let ts = run_server().await; + ts.state.deny_provider_reads.store(true, Ordering::SeqCst); + let example_count = helpers::example_profiles().len(); + + // A full terminal page stops immediately. A short nonterminal page must + // continue, including across more than two server-issued tokens. + for (count, page_size) in [ + (100_usize.saturating_sub(example_count), 100), + (105, 100), + (105, 37), + ] { + ts.state + .profile_page_size_cap + .store(page_size, Ordering::SeqCst); + ts.state.profiles.lock().await.clear(); + ts.state.profile_list_requests.lock().await.clear(); + for i in 0..count { + install_test_profile(&ts, &format!("custom-{i:03}"), "CUSTOM_TOKEN").await; + } + let rendered = run::provider_list_profiles_text(&ts.endpoint, "json", "team", &ts.tls) + .await + .expect("profile catalog"); + let profiles: Vec = + serde_json::from_str(&rendered).expect("profile JSON"); + assert_eq!(profiles.len(), example_count + count); + for i in 0..count { + assert!( + profiles + .iter() + .any(|profile| profile["id"] == format!("custom-{i:03}")) + ); + } + let requests = ts.state.profile_list_requests.lock().await; + assert_eq!(requests.len(), (example_count + count).div_ceil(page_size)); + for (page, (workspace, requested_size, token)) in requests.iter().enumerate() { + assert_eq!(selected_workspace(workspace), Some("team")); + assert_eq!(*requested_size, 100); + let expected_token = if page == 0 { + String::new() + } else { + format!("profile-page:{:04}", page * page_size) + }; + assert_eq!(token, &expected_token); + } + } +} + +#[tokio::test] +async fn profile_describe_preserves_scope_and_structured_definition() { let ts = run_server().await; + ts.state.deny_provider_reads.store(true, Ordering::SeqCst); + let mut scoped = helpers::example_profiles() + .iter() + .find(|profile| profile.id == "openai") + .expect("openai profile") + .to_proto(); + scoped.display_name = "Team-only OpenAI".to_string(); + scoped.scope = "workspace".to_string(); + scoped.source = "custom".to_string(); + ts.state + .scoped_profiles + .lock() + .await + .insert(("team".to_string(), "openai".to_string()), scoped); + + let human = + run::provider_profile_describe_text(&ts.endpoint, "openai", "table", "team", &ts.tls) + .await + .expect("scoped description"); + assert!(human.contains("Team-only OpenAI")); + assert!(human.contains("OPENAI_API_KEY")); + assert!(!human.contains('\u{1b}')); + let global = run::provider_profile_describe_text(&ts.endpoint, "openai", "table", "", &ts.tls) + .await + .expect("platform description"); + assert!(!global.contains("Team-only OpenAI")); + for format in ["json", "yaml"] { + let description = + run::provider_profile_describe_text(&ts.endpoint, "openai", format, "team", &ts.tls) + .await + .expect("structured description"); + let exported = + run::provider_profile_export_text(&ts.endpoint, "openai", format, "team", &ts.tls) + .await + .expect("profile export"); + assert_eq!(description, exported); + } + let missing = + run::provider_profile_describe_text(&ts.endpoint, "missing", "table", "team", &ts.tls) + .await + .expect_err("unknown profile must fail"); + assert!(missing.to_string().contains("not found")); + ts.state.omit_profile_response.store(true, Ordering::SeqCst); + let absent = + run::provider_profile_describe_text(&ts.endpoint, "openai", "json", "team", &ts.tls) + .await + .expect_err("empty successful response must fail"); + assert!(absent.to_string().contains("openai")); +} - run::provider_list_profiles(&ts.endpoint, "table", "default", &ts.tls) +#[tokio::test] +async fn profile_commands_dispatch_scope_and_output_to_gateway() { + let listener = TcpListener::bind("127.0.0.1:0") .await - .expect("provider list-profiles"); + .expect("test listener"); + let endpoint = format!("http://{}", listener.local_addr().expect("test address")); + let state = ProviderState::default(); + state.deny_provider_reads.store(true, Ordering::SeqCst); + let service = TestOpenShell { + state: state.clone(), + }; + let server = tokio::spawn(async move { + Server::builder() + .add_service(OpenShellServer::new(service)) + .serve_with_incoming(TcpListenerStream::new(listener)) + .await + .expect("test gateway"); + }); + let config = tempfile::tempdir().expect("isolated CLI config"); + // The subprocess uses an explicit local endpoint and isolated config so + // profile dispatch cannot accidentally consult the developer's gateway. + for (verb, global, format) in [ + ("list", false, "json"), + ("list", true, "yaml"), + ("describe", false, "json"), + ("describe", true, "table"), + ("export", false, "yaml"), + ("export", true, "json"), + ] { + let mut outputs = Vec::new(); + for legacy in [false, true] { + let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_openshell")); + command + .env("XDG_CONFIG_HOME", config.path()) + .env("OPENSHELL_TELEMETRY_ENABLED", "false") + .env_remove("OPENSHELL_GATEWAY") + .env_remove("COMPLETE") + .args([ + "--gateway-endpoint", + &endpoint, + "--workspace", + "team", + "--color", + "never", + ]); + if legacy && verb == "list" { + command.args(["provider", "list-profiles"]); + } else { + if legacy { + command.arg("provider"); + } + command.args(["profile", verb]); + if verb == "list" { + command.args(["--type", "provider"]); + } else { + command.arg("openai"); + } + } + if global { + command.arg("--global"); + } + let output = command + .args(["-o", format]) + .output() + .await + .expect("profile subprocess"); + assert!( + output.status.success(), + "profile {verb} (legacy={legacy}): {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 output"); + match (verb, format) { + ("list", "json") => assert!( + serde_json::from_str::>(&stdout) + .expect("JSON list") + .iter() + .any(|p| p["id"] == "openai") + ), + ("list", "yaml") => assert!( + serde_yml::from_str::>(&stdout) + .expect("YAML list") + .iter() + .any(|p| p["id"] == "openai") + ), + (_, "json") => assert_eq!( + serde_json::from_str::(&stdout).expect("JSON profile")["id"], + "openai" + ), + (_, "yaml") => assert_eq!( + serde_yml::from_str::(&stdout).expect("YAML profile")["id"], + "openai" + ), + _ => assert!(stdout.contains("openai (provider)")), + } + outputs.push(stdout); + } + // Both entry points must produce identical output, including structure + // and human-readable formatting, rather than only parsing successfully. + assert_eq!(outputs[0], outputs[1], "profile {verb} output differs"); + } + assert_eq!( + *state.profile_list_requests.lock().await, + [ + ( + Some(openshell_core::proto::workspace_selector("team")), + 100, + String::new() + ), + ( + Some(openshell_core::proto::workspace_selector("team")), + 100, + String::new() + ), + (None, 100, String::new()), + (None, 100, String::new()), + ] + ); + assert_eq!( + *state.profile_get_requests.lock().await, + [ + ( + Some(openshell_core::proto::workspace_selector("team")), + "openai".to_string() + ), + ( + Some(openshell_core::proto::workspace_selector("team")), + "openai".to_string() + ), + (None, "openai".to_string()), + (None, "openai".to_string()), + ( + Some(openshell_core::proto::workspace_selector("team")), + "openai".to_string() + ), + ( + Some(openshell_core::proto::workspace_selector("team")), + "openai".to_string() + ), + (None, "openai".to_string()), + (None, "openai".to_string()), + ] + ); + // The listener belongs to this fixture and must not outlive the test. + server.abort(); } #[tokio::test] @@ -3967,7 +4274,7 @@ binaries: [/usr/bin/custom] .expect("profile export"); run::provider_list_profiles(&ts.endpoint, "json", "default", &ts.tls) .await - .expect("provider list-profiles json"); + .expect("profile list json"); run::provider_create( &ts.endpoint, "custom-provider", diff --git a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx index a07bfabdbd..4204e7ebfb 100644 --- a/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx +++ b/docs/get-started/tutorials/microsoft-graph-provider-refresh.mdx @@ -93,8 +93,8 @@ binaries: Lint and import the profile: ```shell -openshell provider profile lint -f microsoft-graph-mail.yaml -openshell provider profile import -f microsoft-graph-mail.yaml +openshell profile lint -f microsoft-graph-mail.yaml +openshell profile import -f microsoft-graph-mail.yaml ``` The profile defines the refresh strategy and Graph network policy. The `tenant_id` refresh material selects the Microsoft token endpoint during gateway-managed refresh. diff --git a/docs/providers/profiles.mdx b/docs/providers/profiles.mdx index d892997202..8b574fb341 100644 --- a/docs/providers/profiles.mdx +++ b/docs/providers/profiles.mdx @@ -34,8 +34,8 @@ Provider profiles include these user-facing features: - An import-only catalog: the gateway serves the profiles you imported and nothing else. A gateway with none imported serves an empty catalog. - Gateway configuration can compose user-managed and interceptor-vended profile sources. Selecting only an interceptor makes its catalog authoritative by omission. - Platform-scoped and workspace-scoped profiles. A workspace profile takes precedence over a platform profile with the same ID inside that workspace, and the platform profile applies outside it. An interceptor-vended profile cannot share an ID with an imported one; that collision fails closed. -- `openshell provider list-profiles` with table, YAML, and JSON output. -- `openshell provider profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. +- `openshell profile list` and `openshell profile describe` with table, YAML, and JSON output. +- `openshell profile export`, `import`, `update`, `lint`, and `delete` for custom profiles. - Provider instances created from imported profile IDs with `openshell provider create --type `. - Provider instances whose submitted credentials can be stored by a configured gateway credential driver. - Profile-backed credential discovery for explicit `openshell provider create --from-existing` and `openshell provider update --from-existing` flows. The `google-vertex-ai` profile also supplements discovery with Vertex config env vars such as `VERTEX_AI_PROJECT_ID` and `VERTEX_AI_REGION`. @@ -216,18 +216,38 @@ inference provider and call its native endpoint. A provider profile defines a provider type. It contains metadata, credential declarations, endpoint policy, binary policy, an informational provider category, and optional credential refresh metadata. +Use `openshell profile` to discover, inspect, and manage reusable profile definitions. Use `openshell provider` to manage provider instances and their credential values. Provider profiles are the supported profile type. + +Existing command names remain supported. `openshell provider list-profiles` is equivalent to `openshell profile list --type provider`, and `openshell provider profile export/import/update/lint/delete` invokes the same handlers as the corresponding `openshell profile` commands. The nested commands retain their existing arguments, output options, and workspace/global flags. New examples use the top-level form. + List available profiles: ```shell -openshell provider list-profiles +openshell profile list +openshell profile list --type provider -o json ``` -A gateway serves exactly the profiles you imported. OpenShell ships no profiles -inside the gateway binary, so a new gateway lists an empty catalog until you -import one. When a configured gateway interceptor vends an authoritative -provider profile catalog, that catalog becomes the visible source of truth: -list, export, provider creation, policy composition, and sandbox provider -environment resolution use the interceptor-vended profiles. +The default table shows `NAME`, `TYPE`, `CATEGORY`, `SOURCE`, and `SCOPE`. `NAME` is the profile ID used by the other profile commands and by `provider create --type`. The optional `--type provider` filter selects provider profiles. Use `-o json` or `-o yaml` for structured output. + +Inspect one profile before creating a provider: + +```shell +openshell profile describe github +openshell profile describe github -o yaml +``` + +The description shows metadata, credential names and authentication settings, endpoints with their protocols and policy rule counts, allowed binaries, source, and scope. Endpoint details include TLS handling, permission for uninspected credential traffic, and MCP method and tool-name settings. A `tls: skip` endpoint is shown as a raw tunnel without L7 inspection or credential rewrite, even when it declares an L7 protocol and rules. Use JSON or YAML output to inspect the complete rule definitions. The description reads the reusable definition without reading credential values from provider instances. A missing profile ID returns an error. + +List and describe use the selected workspace's effective catalog. Pass the inherited `--workspace` flag to select another workspace, or `--global` to target platform scope. Platform operations require Platform Admin access. + +```shell +openshell profile list --workspace team-ml +openshell profile describe github --workspace team-ml +openshell profile list --global +openshell profile describe github --global +``` + +A gateway serves exactly the profiles you imported. OpenShell ships no profiles inside the gateway binary, so a new gateway lists an empty catalog until you import one. When a configured gateway interceptor vends an authoritative provider profile catalog, that catalog becomes the visible source of truth: list, describe, export, provider creation, policy composition, and sandbox provider environment resolution use the interceptor-vended profiles. ### Import the Example Profiles @@ -248,19 +268,19 @@ injected and the traffic is denied. Copy the file, edit `binaries` and Lint a profile before importing it: ```shell -openshell provider profile lint -f providers/github.yaml +openshell profile lint -f providers/github.yaml ``` Import one profile file at platform scope: ```shell -openshell provider profile import -f providers/github.yaml --global +openshell profile import -f providers/github.yaml --global ``` Import all non-recursive `*.yaml`, `*.yml`, and `*.json` files from a directory: ```shell -openshell provider profile import --from ./providers --global +openshell profile import --from ./providers --global ``` Omit `--global` to import into the current workspace instead. @@ -268,7 +288,7 @@ Omit `--global` to import into the current workspace instead. Export a profile as YAML, to edit or to keep before an upgrade: ```shell -openshell provider profile export github -o yaml --global > github-profile.yaml +openshell profile export github -o yaml --global > github-profile.yaml ``` Import is create-only. It fails if a custom profile with the same ID already exists. @@ -276,17 +296,25 @@ Import is create-only. It fails if a custom profile with the same ID already exi Update an existing custom profile by exporting the current custom profile, editing the file, and submitting the edited file back: ```shell -openshell provider profile export github-profile -o yaml > github-profile.yaml -openshell provider profile update github-profile -f github-profile.yaml +openshell profile export github-profile -o yaml > github-profile.yaml +openshell profile update github-profile -f github-profile.yaml ``` Exported custom profiles include `resource_version`. OpenShell requires that version during update so stale files cannot silently overwrite newer profile definitions. The target ID in the command must match the profile ID in the file. Update accepts one file at a time. If an update would make dynamic token grants ambiguous for an attached sandbox, OpenShell rejects it before changing the profile. +Delete custom profiles by ID: + +```shell +openshell profile delete custom-api custom-alt +``` + +Export accepts `-o yaml` or `-o json` and defaults to YAML. Lint also accepts `--from ` and asks the gateway to validate the supplied definitions without storing them. Export, import, update, lint, and delete use the selected workspace unless you pass `--global`. + Profile IDs must use lowercase kebab-case with `a-z`, `0-9`, and `-`. No IDs are reserved: `github`, `openai`, and every other example profile imports at its own ID, and the imported profile is the only definition for it. Interceptor-managed profiles are read-only through the profile APIs. OpenShell rejects deleting a profile while a sandbox-attached provider uses it. ### Category Enum -The `category` field controls how `openshell provider list-profiles` groups profiles. Use one of these canonical YAML values: +The `category` field supplies the `CATEGORY` column in `openshell profile list`. Use one of these canonical YAML values: | Value | Use for | |---|---| @@ -434,7 +462,7 @@ import for compatibility. `id`, `display_name`, and `description` identify the profile. `id` is the value passed to `openshell provider create --type`. -`category` groups profiles in `openshell provider list-profiles`. Use one of the values in the category enum. +`category` supplies the `CATEGORY` column in `openshell profile list`. Use one of the values in the category enum. `credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at their binding endpoints. Every static credential environment key receives the full profile endpoint set when the profile defines endpoints. An endpointless profile requires explicit sandbox policy bindings for each attached provider instance. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 03260b6cce..5a281a0d02 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -22,7 +22,8 @@ Provider profiles include metadata for known endpoints and binaries. View the available profiles before creating a provider: ```shell -openshell provider list-profiles +openshell profile list +openshell profile describe github ``` ## Create a Provider @@ -112,12 +113,12 @@ Update a custom provider profile after exporting it, editing its endpoints, binaries, or credential metadata, and preserving the exported `resource_version`: ```shell -openshell provider profile export my-api -o yaml > my-api-profile.yaml -openshell provider profile update my-api -f my-api-profile.yaml +openshell profile export my-api -o yaml > my-api-profile.yaml +openshell profile update my-api -f my-api-profile.yaml ``` Import remains create-only and fails if the profile ID already exists. Use -`provider profile update ` for existing profiles. Interceptor-managed +`profile update ` for existing profiles. Interceptor-managed profiles are read-only. The target ID must match the profile ID in the file. Update accepts one file at a time and rejects stale resource versions. Updated profile policy applies to all provider instances of that type on the next sandbox config sync. @@ -125,7 +126,7 @@ applies to all provider instances of that type on the next sandbox config sync. Delete one or more custom provider profiles by ID: ```shell -openshell provider profile delete custom-api custom-alt +openshell profile delete custom-api custom-alt ``` When a multi-profile delete fails for one entry, the CLI reports that profile's @@ -423,7 +424,7 @@ closed instead of being forwarded to the upstream service. Export the profile used by a provider to inspect its credential boundary: ```shell -openshell provider profile export github -o yaml +openshell profile export github -o yaml ``` For the GitHub example profile, `GITHUB_TOKEN` and `GH_TOKEN` can resolve for diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index a8b743b5cd..0fd92f966d 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -698,7 +698,7 @@ When triaging denied requests, check: Then push the updated policy as described above. Do not fix `credential_endpoint_mismatch` by widening sandbox policy. Export the -provider profile with `openshell provider profile export -o yaml`. +provider profile with `openshell profile export -o yaml`. Update the custom provider profile only when the destination is an intended credential recipient. Refer to [Static Credential Endpoint Binding](/providers/profiles#understand-static-credential-endpoint-binding) diff --git a/e2e/rust/tests/credential_gating.rs b/e2e/rust/tests/credential_gating.rs index 7281885255..bf0d6ae9b5 100644 --- a/e2e/rust/tests/credential_gating.rs +++ b/e2e/rust/tests/credential_gating.rs @@ -79,7 +79,7 @@ async fn delete_until_gone(args: &[&str]) -> Result<(), String> { /// before it is recreated, otherwise creation fails with "already exists". async fn ensure_provider_resources_absent() -> Result<(), String> { delete_until_gone(&["provider", "delete", PROVIDER_NAME]).await?; - delete_until_gone(&["provider", "profile", "delete", PROFILE_ID]).await + delete_until_gone(&["profile", "delete", PROFILE_ID]).await } /// Best-effort teardown. Never fails the test: it also runs on the failure @@ -134,8 +134,7 @@ async fn install_provider(rest_port: u16, websocket_port: u16) -> Result<(), Str .path() .to_str() .ok_or_else(|| "profile path is not UTF-8".to_string())?; - let (imported, output) = - run_cli(&["provider", "profile", "import", "--file", profile_path]).await; + let (imported, output) = run_cli(&["profile", "import", "--file", profile_path]).await; if !imported { return Err(format!("profile import failed:\n{output}")); } @@ -192,8 +191,7 @@ async fn install_endpointless_provider() -> Result<(), String> { .path() .to_str() .ok_or_else(|| "endpointless profile path is not UTF-8".to_string())?; - let (imported, output) = - run_cli(&["provider", "profile", "import", "--file", profile_path]).await; + let (imported, output) = run_cli(&["profile", "import", "--file", profile_path]).await; if !imported { return Err(format!("endpointless profile import failed:\n{output}")); } diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs index cf7b9e657e..bc26068afc 100644 --- a/e2e/rust/tests/host_gateway_alias.rs +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -246,8 +246,7 @@ async fn delete_provider(name: &str) { async fn delete_provider_profile(id: &str) { let mut cmd = openshell_cmd(); - cmd.arg("provider") - .arg("profile") + cmd.arg("profile") .arg("delete") .arg(id) .stdout(Stdio::null()) @@ -369,10 +368,10 @@ async fn static_provider_credentials_are_bound_to_profile_endpoints() { delete_provider(BINDING_PROVIDER_B_NAME).await; delete_provider_profile(BINDING_PROFILE_A_ID).await; delete_provider_profile(BINDING_PROFILE_B_ID).await; - run_cli(&["provider", "profile", "import", "--file", &profile_a_path]) + run_cli(&["profile", "import", "--file", &profile_a_path]) .await .expect("import provider A endpoint-binding profile"); - run_cli(&["provider", "profile", "import", "--file", &profile_b_path]) + run_cli(&["profile", "import", "--file", &profile_b_path]) .await .expect("import provider B endpoint-binding profile"); run_cli(&[ diff --git a/e2e/rust/tests/provider_refresh_handles.rs b/e2e/rust/tests/provider_refresh_handles.rs index a12281f3e4..78e1adfe7d 100644 --- a/e2e/rust/tests/provider_refresh_handles.rs +++ b/e2e/rust/tests/provider_refresh_handles.rs @@ -97,7 +97,7 @@ async fn run_cli_with_env(args: &[&str], env: &[(&str, &str)]) -> Result Result { @@ -193,7 +193,7 @@ network_policies: async fn configure_refresh(profile: &NamedTempFile) -> Result<(), String> { let profile_path = profile.path().to_string_lossy().into_owned(); - run_cli(&["provider", "profile", "import", "--file", &profile_path]).await?; + run_cli(&["profile", "import", "--file", &profile_path]).await?; run_cli_with_env( &[ "provider", diff --git a/e2e/rust/tests/provider_token_exchange.rs b/e2e/rust/tests/provider_token_exchange.rs index c4e06f029f..b6340d6465 100644 --- a/e2e/rust/tests/provider_token_exchange.rs +++ b/e2e/rust/tests/provider_token_exchange.rs @@ -836,14 +836,14 @@ async fn podman_provider_token_exchange_injects_bearer_header() { let _target = start_protected_target(target_port).await; run_cli_ignore_error(&["provider", "delete", &provider_name, "--yes"]).await; - run_cli_ignore_error(&["provider", "profile", "delete", &profile_type, "--yes"]).await; + run_cli_ignore_error(&["profile", "delete", &profile_type, "--yes"]).await; let profile = write_profile(&profile_type, token_port, target_port); let profile_path = profile .path() .to_str() .expect("profile path should be UTF-8"); - run_cli(&["provider", "profile", "import", "-f", profile_path]) + run_cli(&["profile", "import", "-f", profile_path]) .await .expect("import provider profile"); run_cli(&[ @@ -883,7 +883,7 @@ async fn podman_provider_token_exchange_injects_bearer_header() { }; run_cli_ignore_error(&["provider", "delete", &provider_name, "--yes"]).await; - run_cli_ignore_error(&["provider", "profile", "delete", &profile_type, "--yes"]).await; + run_cli_ignore_error(&["profile", "delete", &profile_type, "--yes"]).await; sandbox.cleanup().await; assert!( diff --git a/e2e/rust/tests/proxy_egress_pipeline.rs b/e2e/rust/tests/proxy_egress_pipeline.rs index d4c0094d2a..9c5c83a969 100644 --- a/e2e/rust/tests/proxy_egress_pipeline.rs +++ b/e2e/rust/tests/proxy_egress_pipeline.rs @@ -107,7 +107,7 @@ async fn delete_provider(name: &str) { async fn delete_provider_profile(id: &str) { let mut cmd = openshell_cmd(); - cmd.args(["provider", "profile", "delete", id]) + cmd.args(["profile", "delete", id]) .stdout(Stdio::null()) .stderr(Stdio::null()); let _ = cmd.status().await; @@ -1512,7 +1512,7 @@ async fn http_credentials_are_rewritten_in_transparent_headers_and_bodies() { let server = CredentialProbeServer::start().await?; let profile = write_credential_profile(server.port)?; let profile_path = profile.path().to_string_lossy().into_owned(); - run_cli(&["provider", "profile", "import", "--file", &profile_path]).await?; + run_cli(&["profile", "import", "--file", &profile_path]).await?; create_bound_provider(PROVIDER_NAME).await?; let endpoint_options = r#" path: /probe protocol: rest diff --git a/e2e/rust/tests/websocket_conformance.rs b/e2e/rust/tests/websocket_conformance.rs index d95841d07f..1150e020bc 100644 --- a/e2e/rust/tests/websocket_conformance.rs +++ b/e2e/rust/tests/websocket_conformance.rs @@ -69,7 +69,7 @@ async fn delete_provider(name: &str) { async fn delete_provider_profile(id: &str) { let mut cmd = openshell_cmd(); - cmd.args(["provider", "profile", "delete", id]) + cmd.args(["profile", "delete", id]) .stdout(Stdio::null()) .stderr(Stdio::null()); let _ = cmd.status().await; @@ -469,7 +469,7 @@ async fn websocket_text_placeholder_is_rewritten_transparently() { let server = WebSocketProbeServer::start().await?; let profile = write_credential_profile(server.port)?; let profile_path = profile.path().to_string_lossy().into_owned(); - run_cli(&["provider", "profile", "import", "--file", &profile_path]).await?; + run_cli(&["profile", "import", "--file", &profile_path]).await?; create_bound_provider(PROVIDER_NAME).await?; let policy = write_websocket_policy(TEST_SERVER_HOST, server.port)?; let policy_path = policy diff --git a/examples/governance-interceptor/README.md b/examples/governance-interceptor/README.md index bf528f6c57..8a4b061b8f 100644 --- a/examples/governance-interceptor/README.md +++ b/examples/governance-interceptor/README.md @@ -6,7 +6,7 @@ how an interceptor can vend provider profiles and make them the gateway's authoritative profile source. - provider profile YAML lives in `profiles/*.yaml` -- `provider list-profiles` shows only the profiles vended by this interceptor +- `profile list` shows only the profiles vended by this interceptor - providers can only be created with a `type` that matches one of those vended profile IDs - every vended provider profile gets governance annotations for its hash, @@ -78,7 +78,7 @@ YAML files do not need an `id` field; if one is present, the filename still wins The interceptor advertises `provider_profiles = true` in its manifest and vends the current profile set through `SnapshotProviderProfiles`. The gateway config selects the interceptor as its only provider profile source, so -`provider list-profiles` shows only `github` and `slack`; the user source is +`profile list` shows only `github` and `slack`; the user source is omitted, so imported profiles do not appear beside them. The example signs each profile's canonical protobuf payload and exposes the JWT under `annotations["openshell.nvidia.com/profile-signature"]`; the signed hash and key diff --git a/examples/governance-interceptor/smoke.sh b/examples/governance-interceptor/smoke.sh index e4ddcbca80..d91aed79b0 100755 --- a/examples/governance-interceptor/smoke.sh +++ b/examples/governance-interceptor/smoke.sh @@ -286,7 +286,7 @@ policy_signature_for_sandbox() { profile_signature_for_profile() { local profile_id="$1" - "${CLI[@]}" provider profile export "$profile_id" -o json \ + "${CLI[@]}" profile export "$profile_id" -o json \ | awk -F'"' '/"openshell.nvidia.com\/profile-signature":/ { print $4; exit }' } @@ -300,7 +300,7 @@ wait_for_profile() { } >>"$SETUP_LOG" for _ in {1..60}; do - if "${CLI[@]}" provider profile export "$profile_id" -o yaml >>"$SETUP_LOG" 2>&1; then + if "${CLI[@]}" profile export "$profile_id" -o yaml >>"$SETUP_LOG" 2>&1; then printf 'INFO %s\n' "$label" return fi @@ -438,8 +438,8 @@ configure_gateway() { } run_suite() { - expect_output_contains "lists github profile" "github" "${CLI[@]}" provider list-profiles - expect_output_contains "lists slack profile" "slack" "${CLI[@]}" provider list-profiles + expect_output_contains "lists github profile" "github" "${CLI[@]}" profile list + expect_output_contains "lists slack profile" "slack" "${CLI[@]}" profile list # The interceptor is the only configured source, so a profile imported into # the user source stays out of the catalog. cat >"$TMPDIR/unvended-profile.yaml" <<'EOF' @@ -451,10 +451,10 @@ endpoints: port: 443 binaries: [/usr/bin/curl] EOF - "${CLI[@]}" provider profile import -f "$TMPDIR/unvended-profile.yaml" --global >/dev/null 2>&1 || true - expect_output_not_contains "hides profiles the interceptor does not vend" "unvended-api" "${CLI[@]}" provider list-profiles - expect_output_contains "github profile has governance profile signature" "openshell.nvidia.com/profile-signature" "${CLI[@]}" provider profile export github -o json - expect_output_contains "github profile has governance profile hash" "openshell.nvidia.com/profile-hash" "${CLI[@]}" provider profile export github -o json + "${CLI[@]}" profile import -f "$TMPDIR/unvended-profile.yaml" --global >/dev/null 2>&1 || true + expect_output_not_contains "hides profiles the interceptor does not vend" "unvended-api" "${CLI[@]}" profile list + expect_output_contains "github profile has governance profile signature" "openshell.nvidia.com/profile-signature" "${CLI[@]}" profile export github -o json + expect_output_contains "github profile has governance profile hash" "openshell.nvidia.com/profile-hash" "${CLI[@]}" profile export github -o json cat >"$TMPDIR/disallowed-profile.yaml" <<'EOF' id: custom-slack @@ -466,8 +466,8 @@ endpoints: [] binaries: [] EOF - expect_failure "denies provider profile delete" "${CLI[@]}" provider profile delete slack - expect_failure "denies disallowed provider profile import" "${CLI[@]}" provider profile import -f "$TMPDIR/disallowed-profile.yaml" + expect_failure "denies provider profile delete" "${CLI[@]}" profile delete slack + expect_failure "denies disallowed provider profile import" "${CLI[@]}" profile import -f "$TMPDIR/disallowed-profile.yaml" run_step "allows github provider create" "${CLI[@]}" provider create --name github --type github --credential GITHUB_TOKEN=dummy run_step "allows slack provider create" "${CLI[@]}" provider create --name slack --type slack --credential SLACK_BOT_TOKEN=dummy @@ -593,7 +593,7 @@ endpoints: enforcement: enforce binaries: [/usr/bin/gh, /usr/local/bin/gh, /usr/bin/git, /usr/local/bin/git] EOF - wait_for_output_contains "gateway sees github profile reload" "profile-reload.example" "${CLI[@]}" provider profile export github -o yaml + wait_for_output_contains "gateway sees github profile reload" "profile-reload.example" "${CLI[@]}" profile export github -o yaml wait_for_output_contains "effective policy has reloaded github profile" "profile-reload.example" "${CLI[@]}" policy get "$SANDBOX_NAME" --full -o json local reloaded_github_profile_signature="" { diff --git a/examples/spiffe-token-exchange-demo/README.md b/examples/spiffe-token-exchange-demo/README.md index 89ddc09e69..12294fd80d 100644 --- a/examples/spiffe-token-exchange-demo/README.md +++ b/examples/spiffe-token-exchange-demo/README.md @@ -128,7 +128,7 @@ Then run: ```bash export GATEWAY=https://127.0.0.1:8097 -openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" provider profile import \ +openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" profile import \ -f "$OPENSHELL_REPO/examples/spiffe-token-exchange-demo/provider-profile.yaml" openshell --gateway "$OPENSHELL_GATEWAY" --gateway-endpoint "$GATEWAY" provider create \ diff --git a/examples/spiffe-token-exchange-demo/demo.sh b/examples/spiffe-token-exchange-demo/demo.sh index a123f554ab..651688cb48 100755 --- a/examples/spiffe-token-exchange-demo/demo.sh +++ b/examples/spiffe-token-exchange-demo/demo.sh @@ -202,10 +202,10 @@ SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-t "${OS[@]}" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true -"${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true +"${OS[@]}" profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" provider profile lint -f "$PROFILE_FILE" -run "${OS[@]}" provider profile import -f "$PROFILE_FILE" +run "${OS[@]}" profile lint -f "$PROFILE_FILE" +run "${OS[@]}" profile import -f "$PROFILE_FILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" run "${OS[@]}" sandbox create --name "$SANDBOX_NAME" --provider "$PROVIDER_NAME" --keep --no-tty -- echo "sandbox ready" diff --git a/examples/spiffe-token-exchange-demo/podman/demo.sh b/examples/spiffe-token-exchange-demo/podman/demo.sh index b12176c307..228cfe910f 100755 --- a/examples/spiffe-token-exchange-demo/podman/demo.sh +++ b/examples/spiffe-token-exchange-demo/podman/demo.sh @@ -640,10 +640,10 @@ SUBJECT_TOKEN="$(curl -fsS "http://127.0.0.1:${TOKEN_ISSUER_PORT}/demo-subject-t "${OS[@]}" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true -"${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true +"${OS[@]}" profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" provider profile lint -f "$RENDERED_PROFILE" -run "${OS[@]}" provider profile import -f "$RENDERED_PROFILE" +run "${OS[@]}" profile lint -f "$RENDERED_PROFILE" +run "${OS[@]}" profile import -f "$RENDERED_PROFILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --credential "subject_token=${SUBJECT_TOKEN}" run "${OS[@]}" sandbox create --name "$SANDBOX_NAME" --provider "$PROVIDER_NAME" --keep --no-tty -- echo "sandbox ready" diff --git a/examples/spiffe-token-grant-demo/README.md b/examples/spiffe-token-grant-demo/README.md index df4597c666..4e10a1f67a 100644 --- a/examples/spiffe-token-grant-demo/README.md +++ b/examples/spiffe-token-grant-demo/README.md @@ -59,7 +59,7 @@ Then run: export XDG_CONFIG_HOME="$(mktemp -d)" export GATEWAY=http://127.0.0.1:8097 -openshell --gateway-endpoint "$GATEWAY" provider profile import \ +openshell --gateway-endpoint "$GATEWAY" profile import \ -f examples/spiffe-token-grant-demo/provider-profile.yaml openshell --gateway-endpoint "$GATEWAY" provider create \ diff --git a/examples/spiffe-token-grant-demo/demo.sh b/examples/spiffe-token-grant-demo/demo.sh index 8013755a7d..29df965fee 100755 --- a/examples/spiffe-token-grant-demo/demo.sh +++ b/examples/spiffe-token-grant-demo/demo.sh @@ -109,10 +109,10 @@ wait_for_port_forward "${OS[@]}" sandbox delete "$SANDBOX_NAME" >/dev/null 2>&1 || true "${OS[@]}" provider delete "$PROVIDER_NAME" >/dev/null 2>&1 || true -"${OS[@]}" provider profile delete "$PROFILE_ID" >/dev/null 2>&1 || true +"${OS[@]}" profile delete "$PROFILE_ID" >/dev/null 2>&1 || true -run "${OS[@]}" provider profile lint -f "$PROFILE_FILE" -run "${OS[@]}" provider profile import -f "$PROFILE_FILE" +run "${OS[@]}" profile lint -f "$PROFILE_FILE" +run "${OS[@]}" profile import -f "$PROFILE_FILE" run "${OS[@]}" provider create --name "$PROVIDER_NAME" --type "$PROFILE_ID" --runtime-credentials run "${OS[@]}" sandbox create --name "$SANDBOX_NAME" --provider "$PROVIDER_NAME" --no-tty -- echo "sandbox ready" diff --git a/scripts/agents/run.sh b/scripts/agents/run.sh index d3792b6c13..75f96565b5 100755 --- a/scripts/agents/run.sh +++ b/scripts/agents/run.sh @@ -326,12 +326,12 @@ import_provider_profile() { local profile_file="$2" local import_output current_profile resource_version update_dir update_file - openshell_cmd provider profile delete "$profile_id" >/dev/null 2>&1 || true - if import_output="$(openshell_cmd provider profile import --file "$profile_file" 2>&1)"; then + openshell_cmd profile delete "$profile_id" >/dev/null 2>&1 || true + if import_output="$(openshell_cmd profile import --file "$profile_file" 2>&1)"; then return 0 fi if [[ "$import_output" == *"already exists"* ]]; then - if ! current_profile="$(openshell_cmd provider profile export \ + if ! current_profile="$(openshell_cmd profile export \ --output json "$profile_id")"; then echo "failed to export existing provider profile: $profile_id" >&2 return 1 @@ -351,7 +351,7 @@ profile = YAML.load_file(profile_file) || {} profile["resource_version"] = Integer(resource_version, 10) File.write(update_file, YAML.dump(profile)) RUBY - if openshell_cmd provider profile update "$profile_id" \ + if openshell_cmd profile update "$profile_id" \ --file "$update_file" >/dev/null; then rm -f "$update_file" rmdir "$update_dir" diff --git a/skills/debug-inference/SKILL.md b/skills/debug-inference/SKILL.md index e8df8cd10c..dfeb0ca8c5 100644 --- a/skills/debug-inference/SKILL.md +++ b/skills/debug-inference/SKILL.md @@ -34,7 +34,7 @@ container; bind it to an address reachable from the gateway runtime. ```bash openshell provider get -openshell provider profile export -o yaml +openshell profile export -o yaml ``` Check that the profile: @@ -49,8 +49,8 @@ endpoint-bearing profile. A base URL stored only in provider configuration does not authorize a new endpoint. ```bash -openshell provider profile lint -f ./provider-profile.yaml -openshell provider profile import -f ./provider-profile.yaml +openshell profile lint -f ./provider-profile.yaml +openshell profile import -f ./provider-profile.yaml openshell provider create --name --type ``` diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 708c856087..f2e07423b7 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: openshell-cli -description: Guide agents through using the OpenShell CLI (openshell) for sandbox management, gateway registration, provider configuration and refresh, policy iteration, settings, service exposure, BYOC workflows, and attached-provider inference. Covers basic through advanced multi-step workflows. Trigger keywords - openshell, sandbox create, sandbox exec, sandbox connect, logs, provider create, provider profile, provider refresh, policy set, policy get, settings, service expose, forward, port forward, BYOC, bring your own container, inference, use openshell, run openshell, CLI usage, manage sandbox, manage provider, gateway add, gateway select. +description: Guide agents through using the OpenShell CLI (openshell) for sandbox management, gateway registration, provider configuration and refresh, profile management, policy iteration, settings, service exposure, BYOC workflows, and attached-provider inference. Covers basic through advanced multi-step workflows. Trigger keywords - openshell, sandbox create, sandbox exec, sandbox connect, logs, provider create, profile list, profile describe, provider refresh, policy set, policy get, settings, service expose, forward, port forward, BYOC, bring your own container, inference, use openshell, run openshell, CLI usage, manage sandbox, manage provider, gateway add, gateway select. --- # OpenShell CLI @@ -37,6 +37,7 @@ Use `openshell --help` and nested `--help` output as the authority for the insta - [Manage gateways](https://docs.nvidia.com/openshell/latest/sandboxes/manage-gateways.md) - [Manage sandboxes](https://docs.nvidia.com/openshell/latest/sandboxes/manage-sandboxes.md) - [Manage providers](https://docs.nvidia.com/openshell/latest/sandboxes/manage-providers.md) +- [Profiles](https://docs.nvidia.com/openshell/latest/providers/profiles.md) - [Sandbox policies](https://docs.nvidia.com/openshell/latest/sandboxes/policies.md) - [Inference routing](https://docs.nvidia.com/openshell/latest/sandboxes/inference-routing.md) @@ -116,9 +117,9 @@ openshell sandbox delete Providers supply credentials and provider-specific configuration to sandboxes. Provider profiles are import-only: a gateway serves exactly what an operator imported, and a new gateway serves an empty catalog. Never rely on a hard-coded type list or on a legacy alias such as `gh` or `claude` — `--type` matches a profile ID exactly. Discover the profiles available on the selected gateway: -```bash -openshell provider list-profiles -openshell provider list-profiles --output json +```shell +openshell profile list +openshell profile list --type provider --output json ``` ### Create a provider from local credentials @@ -141,7 +142,7 @@ Bare `KEY` reads the value from the environment variable of that name and avoids Other credential sources are `--from-gcloud-adc` for compatible profiles and `--runtime-credentials` when the gateway or sandbox resolves the required credentials at runtime. Static provider credentials resolve only for hosts, ports, and paths declared by -the provider profile. Use `provider profile export` to inspect that boundary +the provider profile. Use `profile export` to inspect that boundary when a placeholder is present but requests receive `credential_endpoint_mismatch`. A profileless static provider fails closed because the gateway cannot construct a binding. @@ -159,12 +160,17 @@ enforced. ### Inspect and manage provider profiles -```bash -openshell provider profile export github --output yaml -openshell provider profile lint --file ./my-profile.yaml -openshell provider profile import --file ./my-profile.yaml +```shell +openshell profile describe github +openshell profile export github --output yaml +openshell profile lint --file ./my-profile.yaml +openshell profile import --file ./my-profile.yaml ``` +Use `profile describe` to inspect a definition's credential metadata, endpoints, TLS handling, MCP access settings, rule counts, binaries, source, and scope before creating a provider. Check for `tls: skip` and the uninspected-credential opt-in before relying on displayed L7 rules. List and describe accept table, JSON, and YAML output; use structured output for complete rule definitions, `--workspace` for a workspace catalog, or `--global` for platform scope. Use `profile export` when preparing an editable definition, `profile update --file ` to replace an existing custom profile with its current resource version, and `profile delete ...` to remove custom profiles. Provider instances remain under `provider`. + +Existing scripts can continue using `provider list-profiles` and `provider profile export/import/update/lint/delete`. These commands share the top-level handlers and preserve their arguments, output options, and workspace/global flags. Prefer `profile` when writing new commands. + ### List, inspect, update, delete Use `openshell sandbox provider status --help` and the attach, detach, and update help to find the installed version's wait options. Add `--wait` when the next step depends on a provider change taking effect. Without it, a successful command only confirms that the gateway saved the change. Save the returned `receipt_id` to check that same change later, and inspect the result for every selected sandbox. Credential refresh status confirms that OpenShell obtained credentials; provider status confirms that the sandbox applied them, activated the policy, and updated the environment for new processes. If the status is `superseded`, explain that a later change replaced the request and inspect that change separately. @@ -252,6 +258,7 @@ openshell sandbox create \ ``` Key flags: + - `--provider`: Attach configured credential providers for API keys, tokens, and other secrets (repeatable) - `--policy`: Custom policy YAML (otherwise uses built-in default or `OPENSHELL_SANDBOX_POLICY` env var) - `--gpu [COUNT]`: Request the driver's default GPU selection or a specific GPU count @@ -508,6 +515,7 @@ openshell logs dev --tail --source sandbox ``` Look for log lines with `action: deny` -- these indicate blocked network requests. The logs include: + - **Destination host and port** (what was blocked) - **Binary path** (which process attempted the connection) - **Deny reason** (why it was blocked) @@ -523,6 +531,7 @@ The `--full` flag includes the effective policy, including provider-composed ent ### Step 4: Modify the policy Edit `current-policy.yaml` to allow the blocked actions. **For policy content authoring, delegate to the `generate-sandbox-policy` skill.** That skill handles: + - Network endpoint rule structure - L4 vs REST, WebSocket, JSON-RPC, MCP, and SQL L7 policy decisions - Access presets (`read-only`, `read-write`, `full`) @@ -556,6 +565,7 @@ endpoint, or an explicit binding to an endpointless AWS profile. Fix the conflicting endpoint selectors or credential source and submit again. The `--wait` flag blocks until the sandbox confirms the policy is loaded (polls every second). Exit codes: + - **0**: Policy loaded successfully - **1**: Policy load failed - **124**: Timeout (default 60 seconds) diff --git a/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs b/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs index cddc5b7bb3..5bc12cf8bb 100644 --- a/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs +++ b/tests/suites/features/provider-refresh/keycloak/tests/provider_refresh.rs @@ -197,7 +197,7 @@ binaries: async fn delete_provider_resources() { let _ = run_cli(&["provider", "delete", PROVIDER_NAME], &[]).await; - let _ = run_cli(&["provider", "profile", "delete", PROFILE_ID], &[]).await; + let _ = run_cli(&["profile", "delete", PROFILE_ID], &[]).await; } #[tokio::test] @@ -216,7 +216,7 @@ async fn revoked_refresh_grant_requires_user_reauthorization() -> Result<(), Str delete_provider_resources().await; let result = async { run_cli_success( - &["provider", "profile", "import", "--file", &profile_path], + &["profile", "import", "--file", &profile_path], &[], ) .await?;