diff --git a/crates/openshell-cli/src/commands/mod.rs b/crates/openshell-cli/src/commands/mod.rs index 09ea2941a4..8d75cf5ae3 100644 --- a/crates/openshell-cli/src/commands/mod.rs +++ b/crates/openshell-cli/src/commands/mod.rs @@ -3,3 +3,4 @@ pub mod common; pub mod gateway; +pub mod provider; diff --git a/crates/openshell-cli/src/commands/provider.rs b/crates/openshell-cli/src/commands/provider.rs new file mode 100644 index 0000000000..4d25ca26a9 --- /dev/null +++ b/crates/openshell-cli/src/commands/provider.rs @@ -0,0 +1,2696 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use crate::commands::common::{ + format_epoch_ms, format_optional_epoch_ms, parse_credential_expiry_pairs, + parse_credential_pairs, parse_key_value_pairs, parse_secret_material_env_pairs, + truncate_display, truncate_status_field, +}; +use crate::tls::{TlsOptions, grpc_client}; +use dialoguer::Confirm; +use miette::{IntoDiagnostic, Result, WrapErr, miette}; +use openshell_core::proto::ProviderProfileCategory; +use openshell_core::proto::{ + AttachSandboxProviderRequest, ConfigureProviderRefreshRequest, CreateProviderRequest, + DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, + DetachSandboxProviderRequest, GetGatewayConfigRequest, GetProviderProfileRequest, + GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxRequest, + ImportProviderProfilesRequest, LintProviderProfilesRequest, ListProviderProfilesRequest, + ListProvidersRequest, ListSandboxProvidersRequest, Provider, ProviderCredentialRefreshStatus, + ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, + ProviderProfileImportItem, RotateProviderCredentialRequest, UpdateProviderProfilesRequest, + UpdateProviderRequest, setting_value, +}; +use openshell_core::settings; +use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; +use openshell_providers::{ + ProviderRegistry, ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, + discover_from_profile, normalize_provider_type, parse_profile_json, parse_profile_yaml, + profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, +}; +use owo_colors::OwoColorize; +use std::collections::{HashMap, HashSet}; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use tonic::{Code, Status}; + +pub async fn sandbox_provider_list( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .list_sandbox_providers(ListSandboxProvidersRequest { + sandbox_name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + let providers = response.into_inner().providers; + + if providers.is_empty() { + println!("No providers attached to sandbox {name}."); + return Ok(()); + } + + print_provider_attachment_table(&providers); + Ok(()) +} + +pub async fn sandbox_provider_attach( + server: &str, + name: &str, + provider: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + + // Fetch current sandbox to get resource_version for CAS + let sandbox = client + .get_sandbox(GetSandboxRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("sandbox not found"))?; + + let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); + + let response = match client + .attach_sandbox_provider(AttachSandboxProviderRequest { + sandbox_name: name.to_string(), + provider_name: provider.to_string(), + expected_resource_version: resource_version, + workspace: workspace.to_string(), + }) + .await + { + Ok(response) => response.into_inner(), + Err(status) if status.code() == Code::Aborted => { + return Err(miette::miette!( + "Failed to attach provider: sandbox was modified by another operation.\n\ + Please retry the command." + ) + .with_source_code(status.message().to_string())); + } + Err(e) => return Err(e).into_diagnostic(), + }; + + if response.attached { + println!( + "{} Attached provider {} to sandbox {}", + "✓".green().bold(), + provider, + name + ); + } else { + println!("Provider {provider} is already attached to sandbox {name}."); + } + Ok(()) +} + +pub async fn sandbox_provider_detach( + server: &str, + name: &str, + provider: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + + // Fetch current sandbox to get resource_version for CAS + let sandbox = client + .get_sandbox(GetSandboxRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .sandbox + .ok_or_else(|| miette::miette!("sandbox not found"))?; + + let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); + + let response = match client + .detach_sandbox_provider(DetachSandboxProviderRequest { + sandbox_name: name.to_string(), + provider_name: provider.to_string(), + expected_resource_version: resource_version, + workspace: workspace.to_string(), + }) + .await + { + Ok(response) => response.into_inner(), + Err(status) if status.code() == Code::Aborted => { + return Err(miette::miette!( + "Failed to detach provider: sandbox was modified by another operation.\n\ + Please retry the command." + ) + .with_source_code(status.message().to_string())); + } + Err(e) => return Err(e).into_diagnostic(), + }; + + if response.detached { + println!( + "{} Detached provider {} from sandbox {}", + "✓".green().bold(), + provider, + name + ); + } else { + println!("Provider {provider} was not attached to sandbox {name}."); + } + Ok(()) +} + +fn print_provider_attachment_table(providers: &[Provider]) { + print!("{}", format_provider_attachment_table(providers, true)); +} + +fn format_provider_attachment_table(providers: &[Provider], color: bool) -> String { + use std::fmt::Write as _; + + let name_width = providers + .iter() + .map(|provider| provider.object_name().len()) + .max() + .unwrap_or(4) + .max(4); + let type_width = providers + .iter() + .map(|provider| provider.r#type.len()) + .max() + .unwrap_or(4) + .max(4); + + let name_header = if color { + "NAME".bold().to_string() + } else { + "NAME".to_string() + }; + let type_header = if color { + "TYPE".bold().to_string() + } else { + "TYPE".to_string() + }; + let credential_keys_header = if color { + "CREDENTIAL_KEYS".bold().to_string() + } else { + "CREDENTIAL_KEYS".to_string() + }; + let config_keys_header = if color { + "CONFIG_KEYS".bold().to_string() + } else { + "CONFIG_KEYS".to_string() + }; + + let mut output = String::new(); + let _ = writeln!( + output, + "{name_header: Option { + detect_provider_from_command(command).map(str::to_string) +} + +/// Ensure all required providers exist. +/// +/// `explicit_names` are provider **names** supplied via `--provider`. They are +/// passed through directly; the server validates they exist at sandbox creation. +/// +/// `inferred_types` are provider **types** inferred from the trailing command +/// (e.g. `claude` -> type `"claude-code"`). These are resolved to provider names via +/// a type→name lookup, and missing types may be auto-created interactively. +/// +/// Returns a deduplicated list of provider **names** suitable for +/// `SandboxSpec.providers`. +pub async fn ensure_required_providers( + client: &mut crate::tls::GrpcClient, + explicit_names: &[String], + inferred_types: &[String], + auto_providers_override: Option, + workspace: &str, +) -> Result> { + if explicit_names.is_empty() && inferred_types.is_empty() { + return Ok(Vec::new()); + } + + let mut configured_names: Vec = Vec::new(); + let mut seen_names: HashSet = HashSet::new(); + + // ── Fetch all existing providers ───────────────────────────────────── + // Build both a name set (for explicit --provider lookups) and a + // type-to-name map (for inferred provider resolution). + let mut known_names: HashSet = HashSet::new(); + let mut type_to_name: HashMap = HashMap::new(); + { + let mut offset = 0_u32; + let limit = 100_u32; + loop { + let response = client + .list_providers(ListProvidersRequest { + limit, + offset, + workspace: workspace.to_string(), + all_workspaces: false, + }) + .await + .into_diagnostic()?; + let providers = response.into_inner().providers; + for provider in &providers { + known_names.insert(provider.object_name().to_string()); + if !provider.r#type.is_empty() { + let type_lower = provider.r#type.to_ascii_lowercase(); + type_to_name + .entry(type_lower) + .or_insert_with(|| provider.object_name().to_string()); + } + } + if providers.len() < limit as usize { + break; + } + offset = offset.saturating_add(limit); + } + } + + // ── Explicit provider names ────────────────────────────────────────── + // If the name exists on the server, use it directly. Otherwise, if the + // name matches a known provider type, auto-create a provider of that + // type with the requested name. + for name in explicit_names { + if known_names.contains(name) { + if seen_names.insert(name.clone()) { + configured_names.push(name.clone()); + } + } else if let Some(provider_type) = normalize_provider_type(name) { + auto_create_provider( + client, + provider_type, + Some(name), + auto_providers_override, + &mut seen_names, + &mut configured_names, + workspace, + ) + .await?; + // Record the type mapping so the inferred-types pass below + // doesn't attempt to create a duplicate provider. + type_to_name + .entry(provider_type.to_ascii_lowercase()) + .or_insert_with(|| name.clone()); + } else { + return Err(miette::miette!( + "provider '{name}' not found and '{name}' is not a recognized provider type. \ + Create it first with `openshell provider create --type --name {name}`" + )); + } + } + + // ── Resolve inferred provider types ────────────────────────────────── + if !inferred_types.is_empty() { + // Collect resolved names for types that already have a provider. + for t in inferred_types { + if let Some(name) = type_to_name.get(&t.to_ascii_lowercase()) + && seen_names.insert(name.clone()) + { + configured_names.push(name.clone()); + } + } + + let missing = inferred_types + .iter() + .filter(|t| !type_to_name.contains_key(&t.to_ascii_lowercase())) + .cloned() + .collect::>(); + + for provider_type in missing { + auto_create_provider( + client, + &provider_type, + None, + auto_providers_override, + &mut seen_names, + &mut configured_names, + workspace, + ) + .await?; + } + } + + Ok(configured_names) +} + +/// Prompt for (or auto-confirm) creation of a provider from local credentials. +/// +/// When `preferred_name` is `Some`, the provider is created with that exact +/// name (used for explicit `--provider ` values). When `None`, the name +/// defaults to the type and retries with suffixes on conflict (used for +/// inferred provider types). +async fn auto_create_provider( + client: &mut crate::tls::GrpcClient, + provider_type: &str, + preferred_name: Option<&str>, + auto_providers_override: Option, + seen_names: &mut HashSet, + configured_names: &mut Vec, + workspace: &str, +) -> Result<()> { + eprintln!("Missing provider: {provider_type}"); + + // --no-auto-providers: skip silently. + if auto_providers_override == Some(false) { + eprintln!( + "{} Skipping provider '{provider_type}' (--no-auto-providers)", + "!".yellow(), + ); + eprintln!(); + return Ok(()); + } + + // No override and non-interactive: error. + if auto_providers_override.is_none() && !std::io::stdin().is_terminal() { + return Err(miette::miette!( + "missing required provider '{provider_type}'. Create it first with \ + `openshell provider create --type {provider_type} --name {provider_type} --from-existing`, \ + pass --auto-providers to auto-create, or set it up manually from inside the sandbox" + )); + } + + // --auto-providers: auto-confirm; otherwise prompt. + let should_create = if auto_providers_override == Some(true) { + true + } else { + Confirm::new() + .with_prompt("Create from local credentials?") + .default(true) + .interact() + .into_diagnostic()? + }; + + if !should_create { + eprintln!("{} Skipping provider '{provider_type}'", "!".yellow()); + eprintln!(); + return Ok(()); + } + + let discovered = discover_existing_provider_data(client, provider_type, workspace) + .await + .map_err(|err| miette::miette!("failed to discover provider '{provider_type}': {err}"))?; + let Some(discovered) = discovered else { + eprintln!( + "{} No existing local credentials/config found for '{}'. You can configure it from inside the sandbox.", + "!".yellow(), + provider_type + ); + eprintln!(); + return Ok(()); + }; + + if let Some(exact_name) = preferred_name { + // Explicit name: create with exactly that name, no retries. + let request = CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: exact_name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + r#type: provider_type.to_string(), + credentials: discovered.credentials.clone(), + config: discovered.config.clone(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: workspace.to_string(), + credential_handles: HashMap::new(), + }), + workspace: workspace.to_string(), + }; + + let response = client.create_provider(request).await.map_err(|status| { + miette::miette!("failed to create provider '{exact_name}': {status}") + })?; + let provider = response + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider missing from response"))?; + eprintln!( + "{} Created provider {} ({}) from existing local state", + "✓".green().bold(), + provider.object_name(), + provider.r#type + ); + if seen_names.insert(provider.object_name().to_string()) { + configured_names.push(provider.object_name().to_string()); + } + } else { + // Inferred type: try type as name, then suffixed variants. + let mut created = false; + for attempt in 0..5 { + let name = if attempt == 0 { + provider_type.to_string() + } else { + format!("{provider_type}-{attempt}") + }; + + let request = CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: name.clone(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + r#type: provider_type.to_string(), + credentials: discovered.credentials.clone(), + config: discovered.config.clone(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: workspace.to_string(), + credential_handles: HashMap::new(), + }), + workspace: workspace.to_string(), + }; + + match client.create_provider(request).await { + Ok(response) => { + let provider = response + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider missing from response"))?; + eprintln!( + "{} Created provider {} ({}) from existing local state", + "✓".green().bold(), + provider.object_name(), + provider.r#type + ); + if seen_names.insert(provider.object_name().to_string()) { + configured_names.push(provider.object_name().to_string()); + } + created = true; + break; + } + Err(status) if status.code() == Code::AlreadyExists => {} + Err(status) => { + return Err(miette::miette!( + "failed to create provider for type '{provider_type}': {status}" + )); + } + } + } + + if !created { + return Err(miette::miette!( + "failed to create provider for type '{provider_type}' after name retries" + )); + } + } + + eprintln!(); + Ok(()) +} + +/// Read gcloud Application Default Credentials from disk. +/// +/// Returns `(client_id, client_secret, refresh_token)`. +/// +/// Checks `GOOGLE_APPLICATION_CREDENTIALS` first; falls back to +/// `$CLOUDSDK_CONFIG/application_default_credentials.json` when set, then to +/// `~/.config/gcloud/application_default_credentials.json`. +fn read_gcloud_adc() -> Result<(String, String, String)> { + let path = if let Some(env_path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") + .ok() + .filter(|v| !v.is_empty()) + { + PathBuf::from(env_path) + } else if let Some(config_dir) = std::env::var("CLOUDSDK_CONFIG") + .ok() + .filter(|v| !v.is_empty()) + { + PathBuf::from(config_dir).join("application_default_credentials.json") + } else { + let home = std::env::var("HOME") + .map_err(|_| miette::miette!("HOME is not set; cannot locate gcloud ADC file"))?; + PathBuf::from(home) + .join(".config") + .join("gcloud") + .join("application_default_credentials.json") + }; + + let content = std::fs::read_to_string(&path).map_err(|err| { + miette::miette!( + "failed to read gcloud ADC file at {}: {}. \ + Run: gcloud auth application-default login", + path.display(), + err + ) + })?; + + let json: serde_json::Value = serde_json::from_str(&content) + .map_err(|err| miette::miette!("failed to parse gcloud ADC file: {err}"))?; + + let cred_type = json.get("type").and_then(|v| v.as_str()); + match cred_type { + Some("service_account") => { + return Err(miette::miette!( + "Application Default Credentials are a service account key, not user credentials. \ + To use a service account, create the provider with the service account JSON key \ + and configure gateway-managed refresh for 'GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN'. \ + See: openshell provider create --help" + )); + } + Some("authorized_user") => {} + Some(other) => { + return Err(miette::miette!( + "Application Default Credentials have unsupported type '{other}' \ + (expected 'authorized_user'). \ + Run: gcloud auth application-default login" + )); + } + None => { + return Err(miette::miette!( + "gcloud ADC file is missing the 'type' field. \ + The file may be malformed. \ + Run: gcloud auth application-default login" + )); + } + } + + let client_id = json + .get("client_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'client_id'"))? + .to_string(); + + let client_secret = json + .get("client_secret") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'client_secret'"))? + .to_string(); + + let refresh_token = json + .get("refresh_token") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'refresh_token'"))? + .to_string(); + + Ok((client_id, client_secret, refresh_token)) +} + +async fn rollback_provider_create_after_gcloud_adc_failure( + client: &mut crate::tls::GrpcClient, + provider_name: &str, + stage: &str, + source: &Status, + workspace: &str, +) -> Result<()> { + match client + .delete_provider(DeleteProviderRequest { + name: provider_name.to_string(), + workspace: workspace.to_string(), + }) + .await + { + Ok(_) => Err(miette!( + "failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \ + The provider was rolled back successfully." + )), + Err(cleanup_err) => { + eprintln!( + "{} Failed to clean up provider '{}' after {} failed: {}. \ + Run 'openshell provider delete {}' to remove it manually.", + "⚠".yellow(), + provider_name, + stage, + cleanup_err, + provider_name + ); + Err(miette!( + "failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \ + Cleanup also failed, so the provider may still exist. \ + Run 'openshell provider delete {provider_name}' to remove it manually." + )) + } + } +} + +pub async fn gateway_providers_v2_enabled(client: &mut crate::tls::GrpcClient) -> Result { + let response = client + .get_gateway_config(GetGatewayConfigRequest {}) + .await + .into_diagnostic()? + .into_inner(); + let Some(setting) = response.settings.get(settings::PROVIDERS_V2_ENABLED_KEY) else { + return Ok(false); + }; + match setting.value.as_ref() { + Some(setting_value::Value::BoolValue(enabled)) => Ok(*enabled), + None => Ok(false), + Some(_) => Err(miette::miette!( + "gateway setting '{}' has invalid value type; expected bool", + settings::PROVIDERS_V2_ENABLED_KEY + )), + } +} + +async fn fetch_provider_profile( + client: &mut crate::tls::GrpcClient, + provider_type: &str, + workspace: &str, +) -> Result { + let response = client + .get_provider_profile(GetProviderProfileRequest { + id: provider_type.to_string(), + workspace: workspace.to_string(), + }) + .await + .map_err(|status| { + if status.code() == Code::NotFound { + miette::miette!( + "provider profile '{provider_type}' not found; providers v2 discovery requires a provider profile" + ) + } else { + miette::miette!(status.to_string()) + } + })?; + + response + .into_inner() + .profile + .ok_or_else(|| miette::miette!("provider profile '{provider_type}' missing from response")) +} + +async fn discover_existing_provider_data( + client: &mut crate::tls::GrpcClient, + provider_type: &str, + workspace: &str, +) -> Result> { + if gateway_providers_v2_enabled(client).await? { + let profile = fetch_provider_profile(client, provider_type, workspace).await?; + let profile = ProviderTypeProfile::from_proto(&profile); + let mut discovered = + discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { + miette::miette!("failed to discover existing provider data from profile: {err}") + })?; + + // Vertex AI config keys (project ID, region, base URL, publisher) are not + // declared in the profile's discovery.credentials list, so discover_from_profile + // does not scan them. Scan them directly here so --from-existing captures them. + if provider_type == VERTEX_AI_PROVIDER_TYPE { + let discovered = discovered.get_or_insert_with(Default::default); + for key in openshell_core::inference::VERTEX_AI_CONFIG_KEY_NAMES { + if let Ok(val) = std::env::var(key) { + let val = val.trim().to_string(); + if !val.is_empty() { + discovered.config.entry(key.to_string()).or_insert(val); + } + } + } + } + + Ok(discovered) + } else { + let registry = ProviderRegistry::new(); + registry + .discover_existing(provider_type) + .map_err(|err| miette::miette!("failed to discover existing provider data: {err}")) + } +} + +/// Canonical provider type string for Google Vertex AI. +const VERTEX_AI_PROVIDER_TYPE: &str = "google-vertex-ai"; + +/// Canonical provider type string for Google Cloud (GCP APIs). +const GOOGLE_CLOUD_PROVIDER_TYPE: &str = "google-cloud"; + +fn missing_credentials_error(provider_type: &str) -> miette::Report { + if provider_type == VERTEX_AI_PROVIDER_TYPE { + return miette::miette!( + "no credentials resolved for provider type '{provider_type}'. \ + Set GOOGLE_VERTEX_AI_TOKEN, VERTEX_AI_TOKEN, \ + GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN, or VERTEX_AI_SERVICE_ACCOUNT_TOKEN; \ + or use --from-gcloud-adc or --from-existing with those env vars set." + ); + } + + if provider_type == GOOGLE_CLOUD_PROVIDER_TYPE { + return miette::miette!( + "no credentials resolved for provider type '{provider_type}'. \ + Set GCP_ADC_ACCESS_TOKEN or GCP_SA_ACCESS_TOKEN; \ + or use --from-gcloud-adc or --from-existing with those env vars set." + ); + } + + miette::miette!( + "no credentials resolved for provider type '{provider_type}'. \ + Use --credential KEY[=VALUE], --runtime-credentials for runtime-resolved profile credentials, or --from-existing \ + with the appropriate env vars set." + ) +} + +#[allow(clippy::too_many_arguments)] +pub async fn provider_create( + server: &str, + name: &str, + provider_type: &str, + from_existing: bool, + credentials: &[String], + from_gcloud_adc: bool, + config: &[String], + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + provider_create_with_options( + server, + name, + provider_type, + from_existing, + credentials, + from_gcloud_adc, + false, + config, + workspace, + workspace, + tls, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn provider_create_with_options( + server: &str, + name: &str, + provider_type: &str, + from_existing: bool, + credentials: &[String], + from_gcloud_adc: bool, + runtime_credentials: bool, + config: &[String], + workspace: &str, + profile_workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + if from_gcloud_adc && (from_existing || !credentials.is_empty() || runtime_credentials) { + return Err(miette::miette!( + "--from-gcloud-adc cannot be combined with --from-existing, --credential, or --runtime-credentials" + )); + } + if from_existing && (!credentials.is_empty() || runtime_credentials) { + return Err(miette::miette!( + "--from-existing cannot be combined with --credential or --runtime-credentials" + )); + } + if runtime_credentials && !credentials.is_empty() { + return Err(miette::miette!( + "--runtime-credentials cannot be combined with --credential" + )); + } + + let mut client = grpc_client(server, tls).await?; + + let provider_type = if let Some(provider_type) = normalize_provider_type(provider_type) { + provider_type.to_string() + } else { + let profile_id = provider_type.trim(); + if profile_id.is_empty() { + return Err(miette::miette!("provider type is required")); + } + let response = client + .get_provider_profile(GetProviderProfileRequest { + id: profile_id.to_string(), + workspace: profile_workspace.to_string(), + }) + .await; + match response { + Ok(response) => response + .into_inner() + .profile + .map(|profile| profile.id) + .filter(|id| !id.trim().is_empty()) + .unwrap_or_else(|| profile_id.to_string()), + Err(status) if status.code() == Code::NotFound => { + return Err(miette::miette!( + "unsupported provider type or profile: {provider_type}" + )); + } + Err(status) => return Err(status).into_diagnostic(), + } + }; + + let adc_credential_key = if from_gcloud_adc { + let profile = fetch_provider_profile(&mut client, &provider_type, profile_workspace) + .await + .map_err(|err| { + miette::miette!( + "--from-gcloud-adc is not supported for '{provider_type}' providers ({err})" + ) + })?; + let profile = ProviderTypeProfile::from_proto(&profile); + let adc_cred = profile.adc_credential().ok_or_else(|| { + miette::miette!( + "--from-gcloud-adc is not supported for '{provider_type}' providers \ + (no ADC-compatible credential in the provider profile)" + ) + })?; + Some( + adc_cred + .env_vars + .first() + .ok_or_else(|| { + miette::miette!( + "ADC credential in '{provider_type}' profile has no env_vars declared" + ) + })? + .clone(), + ) + } else { + None + }; + + let mut credential_map = parse_credential_pairs(credentials)?; + let mut config_map = parse_key_value_pairs(config, "--config")?; + + if from_existing { + let discovered = + discover_existing_provider_data(&mut client, &provider_type, profile_workspace).await?; + let Some(discovered) = discovered else { + return Err(miette::miette!( + "no existing local credentials/config found for provider type '{provider_type}'" + )); + }; + + for (key, value) in discovered.credentials { + credential_map.entry(key).or_insert(value); + } + for (key, value) in discovered.config { + config_map.entry(key).or_insert(value); + } + } + + if credential_map.is_empty() { + if from_existing { + return Err(missing_credentials_error(&provider_type)); + } + if !from_gcloud_adc && !runtime_credentials { + return Err(missing_credentials_error(&provider_type)); + } + let allows_empty_credentials = if runtime_credentials { + provider_profile_allows_empty_credentials( + &fetch_provider_profile(&mut client, &provider_type, profile_workspace).await?, + ) + } else { + fetch_provider_profile(&mut client, &provider_type, profile_workspace) + .await + .ok() + .is_some_and(|profile| provider_profile_allows_empty_credentials(&profile)) + }; + if !allows_empty_credentials { + if runtime_credentials { + return Err(miette::miette!( + "--runtime-credentials is only valid for provider profiles whose required credentials are resolved at runtime" + )); + } + return Err(missing_credentials_error(&provider_type)); + } + } + + // Validate and read the ADC file BEFORE creating the provider so that + // a bad/missing ADC does not leave an orphan provider behind. Bundle the + // credential key with the material so they stay coupled. + let gcloud_adc_bootstrap = if from_gcloud_adc { + let (client_id, client_secret, refresh_token) = read_gcloud_adc()?; + let key = adc_credential_key.expect("set when from_gcloud_adc is true"); + Some((key, client_id, client_secret, refresh_token)) + } else { + None + }; + + let response = client + .create_provider(CreateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + r#type: provider_type.clone(), + credentials: credential_map, + config: config_map, + credential_expires_at_ms: HashMap::new(), + profile_workspace: profile_workspace.to_string(), + credential_handles: HashMap::new(), + }), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + + let provider = response + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider missing from response"))?; + let provider_name = provider.object_name().to_string(); + + if let Some((adc_credential_key, client_id, client_secret, refresh_token)) = + gcloud_adc_bootstrap + { + let mut material = HashMap::new(); + material.insert("client_id".to_string(), client_id); + material.insert("client_secret".to_string(), client_secret); + material.insert("refresh_token".to_string(), refresh_token); + + if let Err(configure_err) = client + .configure_provider_refresh(ConfigureProviderRefreshRequest { + provider: provider_name.clone(), + credential_key: adc_credential_key.clone(), + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, + material, + secret_material_keys: vec![ + "client_secret".to_string(), + "refresh_token".to_string(), + ], + expires_at_ms: None, + workspace: workspace.to_string(), + }) + .await + { + return rollback_provider_create_after_gcloud_adc_failure( + &mut client, + &provider_name, + "configure", + &configure_err, + workspace, + ) + .await; + } + + if let Err(rotate_err) = client + .rotate_provider_credential(RotateProviderCredentialRequest { + provider: provider_name.clone(), + credential_key: adc_credential_key, + workspace: workspace.to_string(), + }) + .await + { + return rollback_provider_create_after_gcloud_adc_failure( + &mut client, + &provider_name, + "mint the initial access token for", + &rotate_err, + workspace, + ) + .await; + } + + println!("{} Created provider {}", "✓".green().bold(), provider_name); + println!("Configured GCP credentials from gcloud ADC and minted the initial access token"); + return Ok(()); + } + + println!("{} Created provider {}", "✓".green().bold(), provider_name); + Ok(()) +} + +fn provider_profile_allows_empty_credentials(profile: &ProviderProfile) -> bool { + ProviderTypeProfile::from_proto(profile).allows_empty_provider_credentials() +} + +pub async fn provider_get( + server: &str, + name: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .get_provider(GetProviderRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + + let provider = response + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider missing from response"))?; + + let credential_keys = provider_credential_keys(&provider); + let config_keys = provider.config.keys().cloned().collect::>(); + + println!("{}", "Provider:".cyan().bold()); + println!(); + println!(" {} {}", "Id:".dimmed(), provider.object_id()); + println!(" {} {}", "Name:".dimmed(), provider.object_name()); + println!(" {} {}", "Type:".dimmed(), provider.r#type); + println!( + " {} {}", + "Resource version:".dimmed(), + provider.metadata.as_ref().map_or(0, |m| m.resource_version) + ); + println!( + " {} {}", + "Credential keys:".dimmed(), + if credential_keys.is_empty() { + "".to_string() + } else { + credential_keys.join(", ") + } + ); + println!( + " {} {}", + "Config keys:".dimmed(), + if config_keys.is_empty() { + "".to_string() + } else { + config_keys.join(", ") + } + ); + + Ok(()) +} + +fn provider_to_json(provider: &Provider) -> serde_json::Value { + let mut obj = serde_json::Map::new(); + + // Core fields + obj.insert("id".to_string(), serde_json::json!(provider.object_id())); + obj.insert( + "name".to_string(), + serde_json::json!(provider.object_name()), + ); + obj.insert( + "workspace".to_string(), + serde_json::json!(provider.object_workspace()), + ); + obj.insert("type".to_string(), serde_json::json!(provider.r#type)); + + // Credential keys (NEVER values - security) + let credential_keys = provider_credential_keys(provider); + obj.insert( + "credential_keys".to_string(), + serde_json::json!(credential_keys), + ); + + // Config keys (keys only, not values) + if !provider.config.is_empty() { + let config_keys: Vec = provider.config.keys().cloned().collect(); + obj.insert("config_keys".to_string(), serde_json::json!(config_keys)); + } + + // Metadata fields (only if metadata exists) + if let Some(meta) = &provider.metadata { + if !meta.labels.is_empty() { + obj.insert("labels".to_string(), serde_json::json!(meta.labels)); + } + if meta.resource_version != 0 { + obj.insert( + "resource_version".to_string(), + serde_json::json!(meta.resource_version), + ); + } + if meta.created_at_ms != 0 { + obj.insert( + "created_at".to_string(), + serde_json::json!(format_epoch_ms(meta.created_at_ms)), + ); + } + } + + // Credential expiration times (only if present) + if !provider.credential_expires_at_ms.is_empty() { + obj.insert( + "credential_expires_at_ms".to_string(), + serde_json::json!(provider.credential_expires_at_ms), + ); + } + + serde_json::Value::Object(obj) +} + +fn provider_credential_keys(provider: &Provider) -> Vec { + let mut keys: Vec = provider + .credentials + .keys() + .chain(provider.credential_handles.keys()) + .cloned() + .collect(); + keys.sort(); + keys.dedup(); + keys +} + +#[allow(clippy::too_many_arguments)] +pub async fn provider_list( + server: &str, + limit: u32, + offset: u32, + names_only: bool, + output: &str, + workspace: &str, + all_workspaces: bool, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .list_providers(ListProvidersRequest { + limit, + offset, + workspace: if all_workspaces { + String::new() + } else { + workspace.to_string() + }, + all_workspaces, + }) + .await + .into_diagnostic()?; + let providers = response.into_inner().providers; + + // Handle structured output formats (json, yaml) + if crate::output::print_output_collection(output, &providers, provider_to_json)? { + return Ok(()); + } + + if providers.is_empty() { + if !names_only { + println!("No providers found."); + } + return Ok(()); + } + + if names_only { + for provider in &providers { + if all_workspaces { + println!("{}/{}", provider.object_workspace(), provider.object_name()); + } else { + println!("{}", provider.object_name()); + } + } + return Ok(()); + } + + let ws_width = if all_workspaces { + providers + .iter() + .map(|p| p.object_workspace().len()) + .max() + .unwrap_or(9) + .max(9) + } else { + 0 + }; + let name_width = providers + .iter() + .map(|provider| provider.object_name().len()) + .max() + .unwrap_or(4) + .max(4); + let type_width = providers + .iter() + .map(|provider| provider.r#type.len()) + .max() + .unwrap_or(4) + .max(4); + + if all_workspaces { + println!( + "{: Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .list_provider_profiles(ListProviderProfilesRequest { + limit: 100, + offset: 0, + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + let mut profiles = response.into_inner().profiles; + profiles.sort_by(|left, right| { + left.category + .cmp(&right.category) + .then_with(|| left.id.cmp(&right.id)) + }); + let dto_profiles = profiles + .iter() + .map(ProviderTypeProfile::from_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(()); + } + + if profiles.is_empty() { + println!("No provider profiles found."); + return 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); + } + + Ok(()) +} + +pub async fn provider_profile_export( + server: &str, + id: &str, + output: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let rendered = provider_profile_export_text(server, id, output, workspace, tls).await?; + if output == "json" { + println!("{rendered}"); + } else { + print!("{rendered}"); + } + Ok(()) +} + +pub async fn provider_profile_export_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: workspace.to_string(), + }) + .await + .into_diagnostic()?; + let profile = response + .into_inner() + .profile + .ok_or_else(|| miette!("provider profile '{id}' not found"))?; + let profile = ProviderTypeProfile::from_proto(&profile); + + match output { + "json" => profile_to_json(&profile).into_diagnostic(), + "yaml" => profile_to_yaml(&profile).into_diagnostic(), + "table" => Err(miette!( + "profile export supports '-o yaml' and '-o json'; table output is not supported" + )), + _ => Err(miette!("unsupported output format: {output}")), + } +} + +pub async fn provider_profile_import( + server: &str, + file: Option<&Path>, + from: Option<&Path>, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let (items, mut diagnostics) = load_profile_import_items(file, from)?; + if items.is_empty() && diagnostics.is_empty() { + return Err(miette!("no provider profile files found")); + } + if profile_diagnostics_have_errors(&diagnostics) { + print_profile_diagnostics(&diagnostics); + return Err(miette!("provider profile import failed")); + } + + let mut client = grpc_client(server, tls).await?; + if !items.is_empty() { + let response = client + .import_provider_profiles(ImportProviderProfilesRequest { + profiles: items, + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + diagnostics.extend(response.diagnostics); + if response.imported { + println!( + "Imported {} provider profile{}.", + response.profiles.len(), + if response.profiles.len() == 1 { + "" + } else { + "s" + } + ); + return Ok(()); + } + } + + print_profile_diagnostics(&diagnostics); + Err(miette!("provider profile import failed")) +} + +pub async fn provider_profile_update( + server: &str, + id: &str, + file: &Path, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let (mut items, mut diagnostics) = load_profile_import_items(Some(file), None)?; + if items.is_empty() && diagnostics.is_empty() { + return Err(miette!("no provider profile files found")); + } + if profile_diagnostics_have_errors(&diagnostics) { + print_profile_diagnostics(&diagnostics); + return Err(miette!("provider profile update failed")); + } + + let mut client = grpc_client(server, tls).await?; + if let Some(item) = items.pop() { + let expected_resource_version = item + .profile + .as_ref() + .map_or(0, |profile| profile.resource_version); + let response = client + .update_provider_profiles(UpdateProviderProfilesRequest { + profile: Some(item), + expected_resource_version, + id: id.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + diagnostics.extend(response.diagnostics); + if response.updated { + println!("Updated provider profile."); + return Ok(()); + } + } + + print_profile_diagnostics(&diagnostics); + Err(miette!("provider profile update failed")) +} + +pub async fn provider_profile_lint( + server: &str, + file: Option<&Path>, + from: Option<&Path>, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let (items, mut diagnostics) = load_profile_import_items(file, from)?; + if items.is_empty() && diagnostics.is_empty() { + return Err(miette!("no provider profile files found")); + } + + if !items.is_empty() { + let mut client = grpc_client(server, tls).await?; + let response = client + .lint_provider_profiles(LintProviderProfilesRequest { + profiles: items, + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + diagnostics.extend(response.diagnostics); + } + + if profile_diagnostics_have_errors(&diagnostics) { + print_profile_diagnostics(&diagnostics); + return Err(miette!("provider profile lint failed")); + } + + println!("Provider profile lint passed."); + Ok(()) +} + +pub async fn provider_profile_delete( + server: &str, + id: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .delete_provider_profile(DeleteProviderProfileRequest { + id: id.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + if response.deleted { + println!("Deleted provider profile '{id}'."); + } else { + println!("Provider profile '{id}' was not deleted."); + } + Ok(()) +} + +pub async fn provider_refresh_status( + server: &str, + name: &str, + credential_key: Option<&str>, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .get_provider_refresh_status(GetProviderRefreshStatusRequest { + provider: name.to_string(), + credential_key: credential_key.unwrap_or_default().to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + + if response.credentials.is_empty() { + if let Some(credential_key) = credential_key { + println!( + "No refresh configuration found for provider '{name}' credential '{credential_key}'." + ); + } else { + println!("No refresh configurations found for provider '{name}'."); + } + return Ok(()); + } + + println!("{}", refresh_status_header()); + for status in response.credentials { + print_refresh_status_row(&status); + } + Ok(()) +} + +fn refresh_status_header() -> String { + format!( + "{:<24} {:<28} {:<28} {:<18} {:<20} {:<20} {:<20} {}", + "PROVIDER".bold(), + "CREDENTIAL_KEY".bold(), + "STRATEGY".bold(), + "STATUS".bold(), + "EXPIRES_AT".bold(), + "NEXT_REFRESH".bold(), + "LAST_REFRESH".bold(), + "LAST_ERROR".bold(), + ) +} + +pub struct ProviderRefreshConfigInput<'a> { + pub name: &'a str, + pub credential_key: &'a str, + pub strategy: &'a str, + pub material: &'a [String], + pub secret_material_env: &'a [String], + pub secret_material_keys: &'a [String], + pub credential_expires_at_ms: Option, +} + +pub async fn provider_refresh_config( + server: &str, + input: ProviderRefreshConfigInput<'_>, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let strategy = provider_refresh_strategy(input.strategy)?; + let mut material = parse_key_value_pairs(input.material, "--material")?; + let mut secret_material_keys = input.secret_material_keys.to_vec(); + // Env-resolved secrets are auto-marked secret; duplicate keys are an + // error rather than a precedence order. + for (key, value) in parse_secret_material_env_pairs(input.secret_material_env)? { + if material.contains_key(&key) { + return Err(miette!( + "duplicate material key '{key}': supplied via both --material and --secret-material-env" + )); + } + if !secret_material_keys.contains(&key) { + secret_material_keys.push(key.clone()); + } + material.insert(key, value); + } + let mut client = grpc_client(server, tls).await?; + let status = client + .configure_provider_refresh(ConfigureProviderRefreshRequest { + provider: input.name.to_string(), + credential_key: input.credential_key.to_string(), + strategy: strategy as i32, + material, + secret_material_keys, + expires_at_ms: input.credential_expires_at_ms, + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .status + .ok_or_else(|| miette!("provider refresh status missing from response"))?; + + println!( + "{} Configured refresh for {} {}", + "✓".green().bold(), + status.provider_name, + status.credential_key + ); + Ok(()) +} + +pub async fn provider_rotate( + server: &str, + name: &str, + credential_key: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let status = client + .rotate_provider_credential(RotateProviderCredentialRequest { + provider: name.to_string(), + credential_key: credential_key.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .status + .ok_or_else(|| miette!("provider refresh status missing from response"))?; + + if status.last_error.is_empty() { + println!( + "{} Rotation requested for {} {} ({})", + "✓".green().bold(), + status.provider_name, + status.credential_key, + status.status + ); + } else { + println!( + "Rotation request recorded for {} {} ({}): {}", + status.provider_name, status.credential_key, status.status, status.last_error + ); + } + Ok(()) +} + +pub async fn provider_refresh_delete( + server: &str, + name: &str, + credential_key: &str, + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + let response = client + .delete_provider_refresh(DeleteProviderRefreshRequest { + provider: name.to_string(), + credential_key: credential_key.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner(); + + if response.deleted { + println!( + "{} Deleted refresh config for {} {}", + "✓".green().bold(), + name, + credential_key + ); + } else { + println!("No refresh config found for provider '{name}' credential '{credential_key}'."); + } + Ok(()) +} + +fn provider_refresh_strategy(strategy: &str) -> Result { + match strategy { + "oauth2_refresh_token" => Ok(ProviderCredentialRefreshStrategy::Oauth2RefreshToken), + "oauth2_client_credentials" => { + Ok(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials) + } + "google_service_account_jwt" => { + Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt) + } + "aws_sts_assume_role" => Ok(ProviderCredentialRefreshStrategy::AwsStsAssumeRole), + _ => Err(miette!("unsupported provider refresh strategy: {strategy}")), + } +} + +fn print_refresh_status_row(status: &ProviderCredentialRefreshStatus) { + println!("{}", refresh_status_row(status)); +} + +fn refresh_status_row(status: &ProviderCredentialRefreshStatus) -> String { + let strategy = ProviderCredentialRefreshStrategy::try_from(status.strategy) + .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); + format!( + "{:<24} {:<28} {:<28} {:<18} {:<20} {:<20} {:<20} {}", + status.provider_name, + status.credential_key, + provider_refresh_strategy_name(strategy), + status.status, + format_optional_epoch_ms(status.expires_at_ms), + format_optional_epoch_ms(status.next_refresh_at_ms), + format_optional_epoch_ms(status.last_refresh_at_ms), + truncate_status_field(&status.last_error, 72), + ) +} + +fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) -> &'static str { + match strategy { + ProviderCredentialRefreshStrategy::Static => "static", + ProviderCredentialRefreshStrategy::External => "external", + ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", + ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", + ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", + ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role", + ProviderCredentialRefreshStrategy::Unspecified => "unspecified", + } +} + +fn load_profile_import_items( + file: Option<&Path>, + from: Option<&Path>, +) -> Result<( + Vec, + Vec, +)> { + let paths = profile_source_paths(file, from)?; + let mut items = Vec::new(); + let mut diagnostics = Vec::new(); + for path in paths { + match load_profile_import_item(&path) { + Ok(item) => items.push(item), + Err(diagnostic) => diagnostics.push(diagnostic), + } + } + Ok((items, diagnostics)) +} + +fn profile_source_paths(file: Option<&Path>, from: Option<&Path>) -> Result> { + if let Some(file) = file { + return Ok(vec![file.to_path_buf()]); + } + let Some(from) = from else { + return Ok(Vec::new()); + }; + let mut paths = Vec::new(); + for entry in std::fs::read_dir(from) + .into_diagnostic() + .wrap_err_with(|| format!("failed to read profile directory {}", from.display()))? + { + let entry = entry.into_diagnostic()?; + let path = entry.path(); + if path.is_file() && profile_extension_supported(&path) { + paths.push(path); + } + } + paths.sort(); + Ok(paths) +} + +fn profile_extension_supported(path: &Path) -> bool { + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("yaml" | "yml" | "json") + ) +} + +fn load_profile_import_item( + path: &Path, +) -> Result { + let source = path.display().to_string(); + let input = std::fs::read_to_string(path).map_err(|err| { + profile_file_diagnostic( + &source, + format!("failed to read provider profile file: {err}"), + ) + })?; + let profile = match path.extension().and_then(|ext| ext.to_str()) { + Some("yaml" | "yml") => parse_profile_yaml(&input), + Some("json") => parse_profile_json(&input), + _ => { + return Err(profile_file_diagnostic( + &source, + "unsupported provider profile file format".to_string(), + )); + } + } + .map_err(|err| profile_file_diagnostic(&source, err.to_string()))?; + + let pre_lower = profile.validate_before_lowering(&source); + if let Some(diag) = pre_lower.into_iter().find(|d| d.severity == "error") { + return Err(ProviderProfileDiagnostic { + source: diag.source, + profile_id: diag.profile_id, + field: diag.field, + message: diag.message, + severity: diag.severity, + }); + } + + Ok(ProviderProfileImportItem { + profile: Some(profile.to_proto()), + source, + }) +} + +fn profile_file_diagnostic(source: &str, message: String) -> ProviderProfileDiagnostic { + ProviderProfileDiagnostic { + source: source.to_string(), + profile_id: String::new(), + field: "file".to_string(), + message, + severity: "error".to_string(), + } +} + +fn print_profile_diagnostics(diagnostics: &[ProviderProfileDiagnostic]) { + if diagnostics.is_empty() { + return; + } + eprintln!("{}", "Provider profile diagnostics:".red().bold()); + for diagnostic in diagnostics { + let source = if diagnostic.source.is_empty() { + "" + } else { + &diagnostic.source + }; + let profile = if diagnostic.profile_id.is_empty() { + "-".to_string() + } else { + diagnostic.profile_id.clone() + }; + eprintln!( + " {} {} profile={} field={} {}", + diagnostic.severity.as_str().red(), + source, + profile, + diagnostic.field, + diagnostic.message + ); + } +} + +fn profile_diagnostics_have_errors(diagnostics: &[ProviderProfileDiagnostic]) -> bool { + diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == "error") +} + +fn display_provider_category(category: i32) -> &'static str { + match ProviderProfileCategory::try_from(category).unwrap_or(ProviderProfileCategory::Other) { + ProviderProfileCategory::Inference => "INFERENCE", + ProviderProfileCategory::Agent => "AGENT", + ProviderProfileCategory::SourceControl => "SOURCE CONTROL", + ProviderProfileCategory::Messaging => "MESSAGING", + ProviderProfileCategory::Data => "DATA", + ProviderProfileCategory::Knowledge => "KNOWLEDGE", + ProviderProfileCategory::Other | ProviderProfileCategory::Unspecified => "OTHER", + } +} + +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 { + profiles + .iter() + .map(|profile| { + profile + .id + .chars() + .count() + .min(PROVIDER_PROFILE_ID_MAX_WIDTH) + }) + .max() + .unwrap_or(2) + .max(2) +} + +fn provider_profile_display_width(profiles: &[ProviderProfile]) -> usize { + profiles + .iter() + .map(|profile| { + profile + .display_name + .chars() + .count() + .min(PROVIDER_PROFILE_DISPLAY_MAX_WIDTH) + }) + .max() + .unwrap_or(4) + .max(4) +} + +fn provider_profile_scope_width(profiles: &[ProviderProfile]) -> usize { + profiles + .iter() + .map(|profile| profile.scope.chars().count()) + .max() + .unwrap_or(5) + .max(5) +} + +fn provider_profile_source_width(profiles: &[ProviderProfile]) -> usize { + profiles + .iter() + .map(|profile| { + profile + .source + .chars() + .count() + .min(PROVIDER_PROFILE_SOURCE_MAX_WIDTH) + }) + .max() + .unwrap_or(6) + .max(6) +} + +fn print_provider_type_header( + id_width: usize, + scope_width: usize, + source_width: usize, + display_width: usize, +) { + let endpoints = "ENDPOINTS"; + println!( + " {: Result<()> { + if from_existing && !credentials.is_empty() { + return Err(miette::miette!( + "--from-existing cannot be combined with --credential" + )); + } + + let mut client = grpc_client(server, tls).await?; + + let mut credential_map = parse_credential_pairs(credentials)?; + let mut config_map = parse_key_value_pairs(config, "--config")?; + let credential_expires_at_ms = parse_credential_expiry_pairs(credential_expires_at)?; + + if from_existing { + // Fetch the existing provider to discover its type for credential lookup. + let existing = client + .get_provider(GetProviderRequest { + name: name.to_string(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()? + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider '{name}' not found"))?; + + let provider_type = existing.r#type; + let discovered = + discover_existing_provider_data(&mut client, &provider_type, workspace).await?; + let Some(discovered) = discovered else { + return Err(miette::miette!( + "no existing local credentials/config found for provider type '{provider_type}'" + )); + }; + + for (key, value) in discovered.credentials { + credential_map.entry(key).or_insert(value); + } + for (key, value) in discovered.config { + config_map.entry(key).or_insert(value); + } + } + + let response = client + .update_provider(UpdateProviderRequest { + provider: Some(Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: name.to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + r#type: String::new(), + credentials: credential_map, + config: config_map, + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }), + credential_expires_at_ms, + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + + let provider = response + .into_inner() + .provider + .ok_or_else(|| miette::miette!("provider missing from response"))?; + + println!( + "{} Updated provider {}", + "✓".green().bold(), + provider.object_name() + ); + Ok(()) +} + +pub async fn provider_delete( + server: &str, + names: &[String], + workspace: &str, + tls: &TlsOptions, +) -> Result<()> { + let mut client = grpc_client(server, tls).await?; + for name in names { + let response = client + .delete_provider(DeleteProviderRequest { + name: name.clone(), + workspace: workspace.to_string(), + }) + .await + .into_diagnostic()?; + if response.into_inner().deleted { + println!("{} Deleted provider {name}", "✓".green().bold()); + } else { + println!("{} Provider {name} not found", "!".yellow()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TEST_ENV_LOCK; + use crate::test_utils::EnvVarGuard; + use std::fs; + use std::io::Write; + + use openshell_core::proto::{ + Provider, ProviderCredentialRefresh, ProviderCredentialRefreshStatus, + ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile, + ProviderProfileCredential, datamodel::v1::ObjectMeta, + }; + + #[test] + fn provider_attachment_table_formats_provider_counts() { + let output = format_provider_attachment_table( + &[Provider { + metadata: Some(ObjectMeta { + name: "work-custom".to_string(), + ..Default::default() + }), + r#type: "custom-api".to_string(), + credentials: [ + ("CUSTOM_API_KEY".to_string(), "REDACTED".to_string()), + ("CUSTOM_API_SECRET".to_string(), "REDACTED".to_string()), + ] + .into_iter() + .collect(), + config: std::iter::once(( + "BASE_URL".to_string(), + "https://api.custom.example".to_string(), + )) + .collect(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }], + false, + ); + + assert!(output.contains("NAME")); + assert!(output.contains("TYPE")); + assert!(output.contains("CREDENTIAL_KEYS")); + assert!(output.contains("CONFIG_KEYS")); + assert!(output.contains("work-custom")); + assert!(output.contains("custom-api")); + assert!(output.contains('2')); + assert!(output.contains('1')); + } + + #[test] + fn refresh_status_table_includes_operational_fields() { + let header = refresh_status_header(); + assert!(header.contains("NEXT_REFRESH")); + assert!(header.contains("LAST_REFRESH")); + assert!(header.contains("LAST_ERROR")); + + let row = refresh_status_row(&ProviderCredentialRefreshStatus { + provider_name: "my-graph".to_string(), + provider_id: "provider-id".to_string(), + credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + status: "error".to_string(), + expires_at_ms: 1_767_225_600_000, + next_refresh_at_ms: 1_767_225_660_000, + last_refresh_at_ms: 1_767_225_000_000, + last_error: "token endpoint returned a very long error message that should be truncated for table readability" + .to_string(), + }); + + assert!(row.contains("my-graph")); + assert!(row.contains("MS_GRAPH_ACCESS_TOKEN")); + assert!(row.contains("oauth2_client_credentials")); + assert!(row.contains("error")); + assert!(row.contains("2026-01-01 00:00:00")); + assert!(row.contains("...")); + } + + #[test] + fn empty_provider_credentials_require_all_required_credentials_to_be_runtime_resolvable() { + let refresh_token_profile = ProviderProfile { + credentials: vec![ProviderProfileCredential { + name: "MS_GRAPH_ACCESS_TOKEN".to_string(), + required: true, + refresh: Some(ProviderCredentialRefresh { + strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + assert!(provider_profile_allows_empty_credentials( + &refresh_token_profile + )); + + let token_grant_profile = ProviderProfile { + credentials: vec![ProviderProfileCredential { + name: "ACCESS_TOKEN".to_string(), + required: true, + token_grant: Some(ProviderCredentialTokenGrant { + token_endpoint: "https://auth.example.com/token".to_string(), + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + assert!(provider_profile_allows_empty_credentials( + &token_grant_profile + )); + + let mixed_static_profile = ProviderProfile { + credentials: vec![ + ProviderProfileCredential { + name: "ACCESS_TOKEN".to_string(), + required: true, + refresh: Some(ProviderCredentialRefresh { + strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, + ..Default::default() + }), + ..Default::default() + }, + ProviderProfileCredential { + name: "STATIC_API_KEY".to_string(), + required: true, + refresh: None, + ..Default::default() + }, + ], + ..Default::default() + }; + assert!(!provider_profile_allows_empty_credentials( + &mixed_static_profile + )); + + let optional_refresh_profile = ProviderProfile { + credentials: vec![ProviderProfileCredential { + name: "OPTIONAL_TOKEN".to_string(), + required: false, + refresh: Some(ProviderCredentialRefresh { + strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt as i32, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + assert!(provider_profile_allows_empty_credentials( + &optional_refresh_profile + )); + } + + #[test] + fn inferred_provider_type_returns_type_for_known_command() { + let result = inferred_provider_type(&["claude".to_string(), "--help".to_string()]); + assert_eq!(result, Some("claude-code".to_string())); + } + + #[test] + fn inferred_provider_type_returns_none_for_unknown_command() { + let result = inferred_provider_type(&["bash".to_string()]); + assert_eq!(result, None); + } + + #[test] + fn inferred_provider_type_returns_none_for_empty_command() { + let result = inferred_provider_type(&[]); + assert_eq!(result, None); + } + + #[test] + fn inferred_provider_type_normalizes_aliases() { + // `glab` should resolve to `gitlab` + let result = inferred_provider_type(&["glab".to_string()]); + assert_eq!(result, Some("gitlab".to_string())); + + // `gh` should resolve to `github` + let result = inferred_provider_type(&["gh".to_string()]); + assert_eq!(result, Some("github".to_string())); + } + + #[test] + fn inferred_provider_type_handles_full_path() { + let result = inferred_provider_type(&["/usr/local/bin/claude".to_string()]); + assert_eq!(result, Some("claude-code".to_string())); + } + + #[test] + fn read_gcloud_adc_missing_file_errors() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _guard = EnvVarGuard::set( + "GOOGLE_APPLICATION_CREDENTIALS", + "/nonexistent/path/to/adc.json", + ); + let err = read_gcloud_adc().expect_err("missing file should error"); + assert!( + err.to_string().contains("failed to read gcloud ADC file"), + "unexpected error: {err}" + ); + } + + #[test] + fn read_gcloud_adc_wrong_type_errors() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp = tempfile::NamedTempFile::new().expect("tempfile"); + let json = serde_json::json!({ + "type": "service_account", + "project_id": "my-project", + "private_key_id": "key123" + }); + Write::write_all(&mut tmp.as_file(), json.to_string().as_bytes()).expect("write tempfile"); + let _guard = EnvVarGuard::set( + "GOOGLE_APPLICATION_CREDENTIALS", + tmp.path().to_str().expect("tempfile path"), + ); + let err = read_gcloud_adc().expect_err("wrong type should error"); + // The service_account type gets a targeted message directing the user + // to the real Vertex service-account credential flow instead of the + // generic authorized_user hint. + assert!( + err.to_string() + .contains("GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN"), + "error should mention the service-account token key, got: {err}" + ); + } + + #[test] + fn read_gcloud_adc_parses_user_creds() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp = tempfile::NamedTempFile::new().expect("tempfile"); + let json = serde_json::json!({ + "type": "authorized_user", + "client_id": "test-client-id.apps.googleusercontent.com", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token" + }); + Write::write_all(&mut tmp.as_file(), json.to_string().as_bytes()).expect("write tempfile"); + let _guard = EnvVarGuard::set( + "GOOGLE_APPLICATION_CREDENTIALS", + tmp.path().to_str().expect("tempfile path"), + ); + let (client_id, client_secret, refresh_token) = + read_gcloud_adc().expect("valid ADC should parse"); + assert_eq!(client_id, "test-client-id.apps.googleusercontent.com"); + assert_eq!(client_secret, "test-client-secret"); + assert_eq!(refresh_token, "test-refresh-token"); + } + + #[test] + fn read_gcloud_adc_uses_cloudsdk_config_fallback() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let dir = tempfile::tempdir().expect("tempdir"); + let adc_path = dir.path().join("application_default_credentials.json"); + let json = serde_json::json!({ + "type": "authorized_user", + "client_id": "cloudsdk-client-id.apps.googleusercontent.com", + "client_secret": "cloudsdk-client-secret", + "refresh_token": "cloudsdk-refresh-token" + }); + fs::write(&adc_path, json.to_string()).expect("write adc file"); + let _adc_guard = EnvVarGuard::unset("GOOGLE_APPLICATION_CREDENTIALS"); + let _cloudsdk_guard = + EnvVarGuard::set("CLOUDSDK_CONFIG", dir.path().to_str().expect("config path")); + + let (client_id, client_secret, refresh_token) = + read_gcloud_adc().expect("valid CLOUDSDK_CONFIG ADC should parse"); + assert_eq!(client_id, "cloudsdk-client-id.apps.googleusercontent.com"); + assert_eq!(client_secret, "cloudsdk-client-secret"); + assert_eq!(refresh_token, "cloudsdk-refresh-token"); + } + + #[test] + fn read_gcloud_adc_malformed_json_errors() { + let _lock = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let tmp = tempfile::NamedTempFile::new().expect("tempfile"); + Write::write_all(&mut tmp.as_file(), b"not valid json at all {{{{") + .expect("write tempfile"); + let _guard = EnvVarGuard::set( + "GOOGLE_APPLICATION_CREDENTIALS", + tmp.path().to_str().expect("tempfile path"), + ); + let result = read_gcloud_adc(); + assert!( + result.is_err(), + "malformed JSON should produce an error, got: {result:?}" + ); + let err = result.unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("parse") + || msg.contains("JSON") + || msg.contains("json") + || msg.contains("invalid") + || msg.contains("failed"), + "error message should mention parse/JSON failure, got: {msg}" + ); + } + + #[test] + fn empty_provider_credentials_allow_oauth2_refresh_token() { + use openshell_core::proto::{ + ProviderCredentialRefresh, ProviderCredentialRefreshStrategy, ProviderProfile, + ProviderProfileCredential, + }; + + let strategy = ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32; + let profile = ProviderProfile { + credentials: vec![ProviderProfileCredential { + required: true, + refresh: Some(ProviderCredentialRefresh { + strategy, + ..Default::default() + }), + ..Default::default() + }], + ..Default::default() + }; + assert!( + provider_profile_allows_empty_credentials(&profile), + "Oauth2RefreshToken should be allowed for refresh bootstrap" + ); + } + + #[test] + fn provider_to_json_includes_core_fields() { + let metadata = ObjectMeta { + id: "prov-123".to_string(), + name: "test-provider".to_string(), + ..Default::default() + }; + + let provider = Provider { + metadata: Some(metadata), + r#type: "anthropic".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + assert_eq!(json["id"], "prov-123"); + assert_eq!(json["name"], "test-provider"); + assert_eq!(json["workspace"], ""); + assert_eq!(json["type"], "anthropic"); + } + + #[test] + fn provider_to_json_exposes_credential_keys_not_values() { + let mut credentials = HashMap::new(); + credentials.insert("ANTHROPIC_API_KEY".to_string(), "secret-value".to_string()); + credentials.insert("OTHER_KEY".to_string(), "other-secret".to_string()); + + let provider = Provider { + metadata: Some(ObjectMeta::default()), + r#type: "anthropic".to_string(), + credentials, + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + let json_str = json.to_string(); + + // Assert credential keys are present + let keys = json["credential_keys"].as_array().unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.iter().any(|k| k.as_str() == Some("ANTHROPIC_API_KEY"))); + assert!(keys.iter().any(|k| k.as_str() == Some("OTHER_KEY"))); + + // Assert credential values are NOT in the output (SECURITY) + assert!( + !json_str.contains("secret-value"), + "credential values must not be exposed" + ); + assert!( + !json_str.contains("other-secret"), + "credential values must not be exposed" + ); + } + + #[test] + fn provider_to_json_exposes_config_keys_not_values() { + let mut config = HashMap::new(); + config.insert("region".to_string(), "us-west".to_string()); + config.insert( + "endpoint".to_string(), + "https://api.example.com".to_string(), + ); + + let provider = Provider { + metadata: Some(ObjectMeta::default()), + r#type: "custom".to_string(), + credentials: HashMap::new(), + config, + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + let json_str = json.to_string(); + + // Assert config keys are present + let keys = json["config_keys"].as_array().unwrap(); + assert_eq!(keys.len(), 2); + assert!(keys.iter().any(|k| k.as_str() == Some("region"))); + assert!(keys.iter().any(|k| k.as_str() == Some("endpoint"))); + + // Assert config values are NOT in the output (SECURITY) + assert!( + !json_str.contains("us-west"), + "config values must not be exposed" + ); + assert!( + !json_str.contains("https://api.example.com"), + "config values must not be exposed" + ); + } + + #[test] + fn provider_to_json_omits_empty_config() { + let provider = Provider { + metadata: Some(ObjectMeta::default()), + r#type: "anthropic".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), // Empty config + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + assert!( + json.get("config_keys").is_none(), + "empty config_keys should be omitted" + ); + } + + #[test] + fn provider_to_json_includes_metadata_fields_when_present() { + let mut labels = HashMap::new(); + labels.insert("env".to_string(), "prod".to_string()); + + let metadata = ObjectMeta { + id: "prov-123".to_string(), + name: "test-provider".to_string(), + resource_version: 42, + created_at_ms: 1_234_567_890_000, + labels, + annotations: HashMap::new(), + workspace: String::new(), + deletion_timestamp_ms: 0, + }; + + let provider = Provider { + metadata: Some(metadata), + r#type: "anthropic".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + assert_eq!(json["resource_version"], 42); + assert_eq!(json["created_at"], "2009-02-13 23:31:30"); + assert_eq!(json["labels"]["env"], "prod"); + } + + #[test] + fn provider_to_json_omits_zero_metadata_fields() { + let metadata = ObjectMeta { + id: "prov-123".to_string(), + name: "test-provider".to_string(), + // resource_version and created_at_ms are 0 + // labels is empty + ..Default::default() + }; + + let provider = Provider { + metadata: Some(metadata), + r#type: "anthropic".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + assert!( + json.get("resource_version").is_none(), + "zero resource_version should be omitted" + ); + assert!( + json.get("created_at").is_none(), + "zero created_at should be omitted" + ); + assert!( + json.get("labels").is_none(), + "empty labels should be omitted" + ); + } + + #[test] + fn provider_to_json_includes_credential_expiration() { + let mut credential_expires_at_ms = HashMap::new(); + credential_expires_at_ms.insert("ACCESS_TOKEN".to_string(), 1_234_567_890); + + let provider = Provider { + metadata: Some(ObjectMeta::default()), + r#type: "oauth".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms, + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + assert_eq!( + json["credential_expires_at_ms"]["ACCESS_TOKEN"], + 1_234_567_890 + ); + } + + #[test] + fn provider_to_json_formats_created_at_as_human_readable() { + let metadata = ObjectMeta { + id: "prov-123".to_string(), + name: "test-provider".to_string(), + created_at_ms: 1_609_459_200_000, // 2021-01-01 00:00:00 + ..Default::default() + }; + + let provider = Provider { + metadata: Some(metadata), + r#type: "anthropic".to_string(), + credentials: HashMap::new(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: String::new(), + credential_handles: HashMap::new(), + }; + + let json = provider_to_json(&provider); + + // Should format as human-readable datetime, not raw milliseconds + assert_eq!(json["created_at"], "2021-01-01 00:00:00"); + assert!( + json.get("created_at_ms").is_none(), + "raw milliseconds field should not exist" + ); + } +} diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 48bf2d3dd5..26c9da40e8 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -9,22 +9,29 @@ pub use crate::commands::common::{ }; use crate::commands::common::{ ProvisioningDisplay, ProvisioningStep, confirm_global_setting_delete, - confirm_global_setting_takeover, format_epoch_ms, format_optional_epoch_ms, - format_setting_value, format_timestamp, format_timestamp_ms, handle_platform_progress_event, - is_provisioning_progress_event, non_empty_or, parse_cli_setting_value, - parse_credential_expiry_pairs, parse_credential_pairs, parse_duration_to_ms, phase_name, + confirm_global_setting_takeover, format_epoch_ms, format_setting_value, format_timestamp, + format_timestamp_ms, handle_platform_progress_event, is_provisioning_progress_event, + non_empty_or, parse_cli_setting_value, parse_duration_to_ms, phase_name, print_policy_merge_warnings, print_sandbox_header, print_sandbox_policy, provisioning_timeout_message, ready_false_condition_message, scrub_git_env, short_hash, - truncate_display, truncate_status_field, }; pub use crate::commands::gateway::{ gateway_add, gateway_info, gateway_info_not_configured, gateway_list, gateway_login, gateway_logout, gateway_remove, gateway_select, gateway_status, gateway_use, }; +pub use crate::commands::provider::{ + ProviderRefreshConfigInput, 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_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, + sandbox_provider_detach, sandbox_provider_list, +}; +use crate::commands::provider::{gateway_providers_v2_enabled, inferred_provider_type}; use crate::policy_update::build_policy_update_plan; use crate::tls::{TlsOptions, grpc_client, grpc_inference_client}; -use dialoguer::Confirm; use futures::StreamExt; use indicatif::{ProgressBar, ProgressStyle}; use miette::{IntoDiagnostic, Result, WrapErr, miette}; @@ -32,39 +39,25 @@ use openshell_bootstrap::{ GatewayMetadata, clear_last_sandbox_if_matches, get_gateway_metadata, save_last_sandbox, }; use openshell_core::net::set_tcp_nodelay_best_effort; -use openshell_core::proto::ProviderProfileCategory; use openshell_core::proto::{ - ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, AttachSandboxProviderRequest, - ClearDraftChunksRequest, ConfigureProviderRefreshRequest, CreateProviderRequest, + ApproveAllDraftChunksRequest, ApproveDraftChunkRequest, ClearDraftChunksRequest, CreateSandboxRequest, CreateSshSessionRequest, DeleteInferenceRouteRequest, - DeleteProviderProfileRequest, DeleteProviderRefreshRequest, DeleteProviderRequest, - DeleteSandboxRequest, DeleteServiceRequest, DetachSandboxProviderRequest, ExecSandboxRequest, - ExposeServiceRequest, GetCurrentUserRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, - GetGatewayConfigRequest, GetInferenceRouteRequest, GetProviderProfileRequest, - GetProviderRefreshStatusRequest, GetProviderRequest, GetSandboxConfigRequest, - GetSandboxConfigResponse, GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, - GetSandboxRequest, GetServiceRequest, GpuResourceRequirements, ImportProviderProfilesRequest, - LintProviderProfilesRequest, ListProviderProfilesRequest, ListProvidersRequest, - ListSandboxPoliciesRequest, ListSandboxProvidersRequest, ListSandboxesRequest, - ListServicesRequest, PolicySource, PolicyStatus, Provider, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderProfile, ProviderProfileDiagnostic, - ProviderProfileImportItem, RejectDraftChunkRequest, ResourceRequirements, - RevokeSshSessionRequest, RotateProviderCredentialRequest, Sandbox, SandboxPhase, SandboxPolicy, - SandboxSpec, SandboxTemplate, ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope, - TcpForwardFrame, TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, - UpdateProviderProfilesRequest, UpdateProviderRequest, WatchSandboxRequest, exec_sandbox_event, - setting_value, tcp_forward_init, + DeleteSandboxRequest, DeleteServiceRequest, ExecSandboxRequest, ExposeServiceRequest, + GetCurrentUserRequest, GetDraftHistoryRequest, GetDraftPolicyRequest, GetGatewayConfigRequest, + GetInferenceRouteRequest, GetSandboxConfigRequest, GetSandboxConfigResponse, + GetSandboxLogsRequest, GetSandboxPolicyStatusRequest, GetSandboxRequest, GetServiceRequest, + GpuResourceRequirements, ListSandboxPoliciesRequest, ListSandboxesRequest, ListServicesRequest, + PolicySource, PolicyStatus, RejectDraftChunkRequest, ResourceRequirements, + RevokeSshSessionRequest, Sandbox, SandboxPhase, SandboxPolicy, SandboxSpec, SandboxTemplate, + ServiceEndpointResponse, SetInferenceRouteRequest, SettingScope, TcpForwardFrame, + TcpForwardInit, TcpRelayTarget, UpdateConfigRequest, WatchSandboxRequest, exec_sandbox_event, + tcp_forward_init, }; use openshell_core::settings; use openshell_core::{ObjectId, ObjectName, ObjectWorkspace}; -use openshell_providers::{ - ProviderRegistry, ProviderTypeProfile, RealDiscoveryContext, detect_provider_from_command, - discover_from_profile, normalize_provider_type, parse_profile_json, parse_profile_yaml, - profile_to_json, profile_to_yaml, profiles_to_json, profiles_to_yaml, -}; use owo_colors::OwoColorize; use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::io::{ErrorKind, IsTerminal, Read, Write}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -2155,203 +2148,6 @@ fn sandbox_detail_to_json( Ok(value) } -pub async fn sandbox_provider_list( - server: &str, - name: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .list_sandbox_providers(ListSandboxProvidersRequest { - sandbox_name: name.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - let providers = response.into_inner().providers; - - if providers.is_empty() { - println!("No providers attached to sandbox {name}."); - return Ok(()); - } - - print_provider_attachment_table(&providers); - Ok(()) -} - -pub async fn sandbox_provider_attach( - server: &str, - name: &str, - provider: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - - // Fetch current sandbox to get resource_version for CAS - let sandbox = client - .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner() - .sandbox - .ok_or_else(|| miette::miette!("sandbox not found"))?; - - let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); - - let response = match client - .attach_sandbox_provider(AttachSandboxProviderRequest { - sandbox_name: name.to_string(), - provider_name: provider.to_string(), - expected_resource_version: resource_version, - workspace: workspace.to_string(), - }) - .await - { - Ok(response) => response.into_inner(), - Err(status) if status.code() == Code::Aborted => { - return Err(miette::miette!( - "Failed to attach provider: sandbox was modified by another operation.\n\ - Please retry the command." - ) - .with_source_code(status.message().to_string())); - } - Err(e) => return Err(e).into_diagnostic(), - }; - - if response.attached { - println!( - "{} Attached provider {} to sandbox {}", - "✓".green().bold(), - provider, - name - ); - } else { - println!("Provider {provider} is already attached to sandbox {name}."); - } - Ok(()) -} - -pub async fn sandbox_provider_detach( - server: &str, - name: &str, - provider: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - - // Fetch current sandbox to get resource_version for CAS - let sandbox = client - .get_sandbox(GetSandboxRequest { - name: name.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner() - .sandbox - .ok_or_else(|| miette::miette!("sandbox not found"))?; - - let resource_version = sandbox.metadata.as_ref().map_or(0, |m| m.resource_version); - - let response = match client - .detach_sandbox_provider(DetachSandboxProviderRequest { - sandbox_name: name.to_string(), - provider_name: provider.to_string(), - expected_resource_version: resource_version, - workspace: workspace.to_string(), - }) - .await - { - Ok(response) => response.into_inner(), - Err(status) if status.code() == Code::Aborted => { - return Err(miette::miette!( - "Failed to detach provider: sandbox was modified by another operation.\n\ - Please retry the command." - ) - .with_source_code(status.message().to_string())); - } - Err(e) => return Err(e).into_diagnostic(), - }; - - if response.detached { - println!( - "{} Detached provider {} from sandbox {}", - "✓".green().bold(), - provider, - name - ); - } else { - println!("Provider {provider} was not attached to sandbox {name}."); - } - Ok(()) -} - -fn print_provider_attachment_table(providers: &[Provider]) { - print!("{}", format_provider_attachment_table(providers, true)); -} - -fn format_provider_attachment_table(providers: &[Provider], color: bool) -> String { - use std::fmt::Write as _; - - let name_width = providers - .iter() - .map(|provider| provider.object_name().len()) - .max() - .unwrap_or(4) - .max(4); - let type_width = providers - .iter() - .map(|provider| provider.r#type.len()) - .max() - .unwrap_or(4) - .max(4); - - let name_header = if color { - "NAME".bold().to_string() - } else { - "NAME".to_string() - }; - let type_header = if color { - "TYPE".bold().to_string() - } else { - "TYPE".to_string() - }; - let credential_keys_header = if color { - "CREDENTIAL_KEYS".bold().to_string() - } else { - "CREDENTIAL_KEYS".to_string() - }; - let config_keys_header = if color { - "CONFIG_KEYS".bold().to_string() - } else { - "CONFIG_KEYS".to_string() - }; - - let mut output = String::new(); - let _ = writeln!( - output, - "{name_header: Option { - detect_provider_from_command(command).map(str::to_string) -} - -/// Ensure all required providers exist. -/// -/// `explicit_names` are provider **names** supplied via `--provider`. They are -/// passed through directly; the server validates they exist at sandbox creation. -/// -/// `inferred_types` are provider **types** inferred from the trailing command -/// (e.g. `claude` -> type `"claude-code"`). These are resolved to provider names via -/// a type→name lookup, and missing types may be auto-created interactively. -/// -/// Returns a deduplicated list of provider **names** suitable for -/// `SandboxSpec.providers`. -pub async fn ensure_required_providers( - client: &mut crate::tls::GrpcClient, - explicit_names: &[String], - inferred_types: &[String], - auto_providers_override: Option, - workspace: &str, -) -> Result> { - if explicit_names.is_empty() && inferred_types.is_empty() { - return Ok(Vec::new()); - } - - let mut configured_names: Vec = Vec::new(); - let mut seen_names: HashSet = HashSet::new(); - - // ── Fetch all existing providers ───────────────────────────────────── - // Build both a name set (for explicit --provider lookups) and a - // type-to-name map (for inferred provider resolution). - let mut known_names: HashSet = HashSet::new(); - let mut type_to_name: HashMap = HashMap::new(); - { - let mut offset = 0_u32; - let limit = 100_u32; - loop { - let response = client - .list_providers(ListProvidersRequest { - limit, - offset, - workspace: workspace.to_string(), - all_workspaces: false, - }) - .await - .into_diagnostic()?; - let providers = response.into_inner().providers; - for provider in &providers { - known_names.insert(provider.object_name().to_string()); - if !provider.r#type.is_empty() { - let type_lower = provider.r#type.to_ascii_lowercase(); - type_to_name - .entry(type_lower) - .or_insert_with(|| provider.object_name().to_string()); - } - } - if providers.len() < limit as usize { - break; - } - offset = offset.saturating_add(limit); - } - } - - // ── Explicit provider names ────────────────────────────────────────── - // If the name exists on the server, use it directly. Otherwise, if the - // name matches a known provider type, auto-create a provider of that - // type with the requested name. - for name in explicit_names { - if known_names.contains(name) { - if seen_names.insert(name.clone()) { - configured_names.push(name.clone()); - } - } else if let Some(provider_type) = normalize_provider_type(name) { - auto_create_provider( - client, - provider_type, - Some(name), - auto_providers_override, - &mut seen_names, - &mut configured_names, - workspace, - ) - .await?; - // Record the type mapping so the inferred-types pass below - // doesn't attempt to create a duplicate provider. - type_to_name - .entry(provider_type.to_ascii_lowercase()) - .or_insert_with(|| name.clone()); - } else { - return Err(miette::miette!( - "provider '{name}' not found and '{name}' is not a recognized provider type. \ - Create it first with `openshell provider create --type --name {name}`" - )); - } - } - - // ── Resolve inferred provider types ────────────────────────────────── - if !inferred_types.is_empty() { - // Collect resolved names for types that already have a provider. - for t in inferred_types { - if let Some(name) = type_to_name.get(&t.to_ascii_lowercase()) - && seen_names.insert(name.clone()) - { - configured_names.push(name.clone()); - } - } - - let missing = inferred_types - .iter() - .filter(|t| !type_to_name.contains_key(&t.to_ascii_lowercase())) - .cloned() - .collect::>(); - - for provider_type in missing { - auto_create_provider( - client, - &provider_type, - None, - auto_providers_override, - &mut seen_names, - &mut configured_names, - workspace, - ) - .await?; - } - } - - Ok(configured_names) -} - -/// Prompt for (or auto-confirm) creation of a provider from local credentials. -/// -/// When `preferred_name` is `Some`, the provider is created with that exact -/// name (used for explicit `--provider ` values). When `None`, the name -/// defaults to the type and retries with suffixes on conflict (used for -/// inferred provider types). -async fn auto_create_provider( - client: &mut crate::tls::GrpcClient, - provider_type: &str, - preferred_name: Option<&str>, - auto_providers_override: Option, - seen_names: &mut HashSet, - configured_names: &mut Vec, - workspace: &str, -) -> Result<()> { - eprintln!("Missing provider: {provider_type}"); - - // --no-auto-providers: skip silently. - if auto_providers_override == Some(false) { - eprintln!( - "{} Skipping provider '{provider_type}' (--no-auto-providers)", - "!".yellow(), - ); - eprintln!(); - return Ok(()); - } - - // No override and non-interactive: error. - if auto_providers_override.is_none() && !std::io::stdin().is_terminal() { - return Err(miette::miette!( - "missing required provider '{provider_type}'. Create it first with \ - `openshell provider create --type {provider_type} --name {provider_type} --from-existing`, \ - pass --auto-providers to auto-create, or set it up manually from inside the sandbox" - )); - } - - // --auto-providers: auto-confirm; otherwise prompt. - let should_create = if auto_providers_override == Some(true) { - true - } else { - Confirm::new() - .with_prompt("Create from local credentials?") - .default(true) - .interact() - .into_diagnostic()? - }; - - if !should_create { - eprintln!("{} Skipping provider '{provider_type}'", "!".yellow()); - eprintln!(); - return Ok(()); - } - - let discovered = discover_existing_provider_data(client, provider_type, workspace) - .await - .map_err(|err| miette::miette!("failed to discover provider '{provider_type}': {err}"))?; - let Some(discovered) = discovered else { - eprintln!( - "{} No existing local credentials/config found for '{}'. You can configure it from inside the sandbox.", - "!".yellow(), - provider_type - ); - eprintln!(); - return Ok(()); - }; - - if let Some(exact_name) = preferred_name { - // Explicit name: create with exactly that name, no retries. - let request = CreateProviderRequest { - provider: Some(Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: exact_name.to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: workspace.to_string(), - deletion_timestamp_ms: 0, - }), - r#type: provider_type.to_string(), - credentials: discovered.credentials.clone(), - config: discovered.config.clone(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: workspace.to_string(), - credential_handles: HashMap::new(), - }), - workspace: workspace.to_string(), - }; - - let response = client.create_provider(request).await.map_err(|status| { - miette::miette!("failed to create provider '{exact_name}': {status}") - })?; - let provider = response - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider missing from response"))?; - eprintln!( - "{} Created provider {} ({}) from existing local state", - "✓".green().bold(), - provider.object_name(), - provider.r#type - ); - if seen_names.insert(provider.object_name().to_string()) { - configured_names.push(provider.object_name().to_string()); - } - } else { - // Inferred type: try type as name, then suffixed variants. - let mut created = false; - for attempt in 0..5 { - let name = if attempt == 0 { - provider_type.to_string() - } else { - format!("{provider_type}-{attempt}") - }; - - let request = CreateProviderRequest { - provider: Some(Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: name.clone(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: workspace.to_string(), - deletion_timestamp_ms: 0, - }), - r#type: provider_type.to_string(), - credentials: discovered.credentials.clone(), - config: discovered.config.clone(), - credential_expires_at_ms: HashMap::new(), - profile_workspace: workspace.to_string(), - credential_handles: HashMap::new(), - }), - workspace: workspace.to_string(), - }; - - match client.create_provider(request).await { - Ok(response) => { - let provider = response - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider missing from response"))?; - eprintln!( - "{} Created provider {} ({}) from existing local state", - "✓".green().bold(), - provider.object_name(), - provider.r#type - ); - if seen_names.insert(provider.object_name().to_string()) { - configured_names.push(provider.object_name().to_string()); - } - created = true; - break; - } - Err(status) if status.code() == Code::AlreadyExists => {} - Err(status) => { - return Err(miette::miette!( - "failed to create provider for type '{provider_type}': {status}" - )); - } - } - } - - if !created { - return Err(miette::miette!( - "failed to create provider for type '{provider_type}' after name retries" - )); - } - } - - eprintln!(); - Ok(()) -} - pub async fn service_expose( server: &str, sandbox: &str, @@ -2972,1621 +2460,43 @@ fn print_service_endpoint_table( "SANDBOX".bold(), "SERVICE".bold(), "TARGET".bold(), - "URL".bold(), - ); - } - - for (workspace, sandbox, service, target, url) in rows { - if all_workspaces { - println!( - "{workspace: &str { - if service.is_empty() { "-" } else { service } -} - -/// Read gcloud Application Default Credentials from disk. -/// -/// Returns `(client_id, client_secret, refresh_token)`. -/// -/// Checks `GOOGLE_APPLICATION_CREDENTIALS` first; falls back to -/// `$CLOUDSDK_CONFIG/application_default_credentials.json` when set, then to -/// `~/.config/gcloud/application_default_credentials.json`. -fn read_gcloud_adc() -> Result<(String, String, String)> { - let path = if let Some(env_path) = std::env::var("GOOGLE_APPLICATION_CREDENTIALS") - .ok() - .filter(|v| !v.is_empty()) - { - PathBuf::from(env_path) - } else if let Some(config_dir) = std::env::var("CLOUDSDK_CONFIG") - .ok() - .filter(|v| !v.is_empty()) - { - PathBuf::from(config_dir).join("application_default_credentials.json") - } else { - let home = std::env::var("HOME") - .map_err(|_| miette::miette!("HOME is not set; cannot locate gcloud ADC file"))?; - PathBuf::from(home) - .join(".config") - .join("gcloud") - .join("application_default_credentials.json") - }; - - let content = std::fs::read_to_string(&path).map_err(|err| { - miette::miette!( - "failed to read gcloud ADC file at {}: {}. \ - Run: gcloud auth application-default login", - path.display(), - err - ) - })?; - - let json: serde_json::Value = serde_json::from_str(&content) - .map_err(|err| miette::miette!("failed to parse gcloud ADC file: {err}"))?; - - let cred_type = json.get("type").and_then(|v| v.as_str()); - match cred_type { - Some("service_account") => { - return Err(miette::miette!( - "Application Default Credentials are a service account key, not user credentials. \ - To use a service account, create the provider with the service account JSON key \ - and configure gateway-managed refresh for 'GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN'. \ - See: openshell provider create --help" - )); - } - Some("authorized_user") => {} - Some(other) => { - return Err(miette::miette!( - "Application Default Credentials have unsupported type '{other}' \ - (expected 'authorized_user'). \ - Run: gcloud auth application-default login" - )); - } - None => { - return Err(miette::miette!( - "gcloud ADC file is missing the 'type' field. \ - The file may be malformed. \ - Run: gcloud auth application-default login" - )); - } - } - - let client_id = json - .get("client_id") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'client_id'"))? - .to_string(); - - let client_secret = json - .get("client_secret") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'client_secret'"))? - .to_string(); - - let refresh_token = json - .get("refresh_token") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .ok_or_else(|| miette::miette!("gcloud ADC file is missing 'refresh_token'"))? - .to_string(); - - Ok((client_id, client_secret, refresh_token)) -} - -async fn rollback_provider_create_after_gcloud_adc_failure( - client: &mut crate::tls::GrpcClient, - provider_name: &str, - stage: &str, - source: &Status, - workspace: &str, -) -> Result<()> { - match client - .delete_provider(DeleteProviderRequest { - name: provider_name.to_string(), - workspace: workspace.to_string(), - }) - .await - { - Ok(_) => Err(miette!( - "failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \ - The provider was rolled back successfully." - )), - Err(cleanup_err) => { - eprintln!( - "{} Failed to clean up provider '{}' after {} failed: {}. \ - Run 'openshell provider delete {}' to remove it manually.", - "⚠".yellow(), - provider_name, - stage, - cleanup_err, - provider_name - ); - Err(miette!( - "failed to {stage} credentials from gcloud ADC for provider '{provider_name}': {source}. \ - Cleanup also failed, so the provider may still exist. \ - Run 'openshell provider delete {provider_name}' to remove it manually." - )) - } - } -} - -fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String { - let (Ok(mut service_url), Ok(gateway_endpoint)) = ( - url::Url::parse(service_url), - url::Url::parse(gateway_endpoint), - ) else { - return service_url.to_string(); - }; - - if service_url - .set_port(gateway_endpoint.port_or_known_default()) - .is_err() - { - return service_url.to_string(); - } - - service_url.to_string() -} - -async fn gateway_providers_v2_enabled(client: &mut crate::tls::GrpcClient) -> Result { - let response = client - .get_gateway_config(GetGatewayConfigRequest {}) - .await - .into_diagnostic()? - .into_inner(); - let Some(setting) = response.settings.get(settings::PROVIDERS_V2_ENABLED_KEY) else { - return Ok(false); - }; - match setting.value.as_ref() { - Some(setting_value::Value::BoolValue(enabled)) => Ok(*enabled), - None => Ok(false), - Some(_) => Err(miette::miette!( - "gateway setting '{}' has invalid value type; expected bool", - settings::PROVIDERS_V2_ENABLED_KEY - )), - } -} - -async fn fetch_provider_profile( - client: &mut crate::tls::GrpcClient, - provider_type: &str, - workspace: &str, -) -> Result { - let response = client - .get_provider_profile(GetProviderProfileRequest { - id: provider_type.to_string(), - workspace: workspace.to_string(), - }) - .await - .map_err(|status| { - if status.code() == Code::NotFound { - miette::miette!( - "provider profile '{provider_type}' not found; providers v2 discovery requires a provider profile" - ) - } else { - miette::miette!(status.to_string()) - } - })?; - - response - .into_inner() - .profile - .ok_or_else(|| miette::miette!("provider profile '{provider_type}' missing from response")) -} - -async fn discover_existing_provider_data( - client: &mut crate::tls::GrpcClient, - provider_type: &str, - workspace: &str, -) -> Result> { - if gateway_providers_v2_enabled(client).await? { - let profile = fetch_provider_profile(client, provider_type, workspace).await?; - let profile = ProviderTypeProfile::from_proto(&profile); - let mut discovered = - discover_from_profile(&profile, &RealDiscoveryContext).map_err(|err| { - miette::miette!("failed to discover existing provider data from profile: {err}") - })?; - - // Vertex AI config keys (project ID, region, base URL, publisher) are not - // declared in the profile's discovery.credentials list, so discover_from_profile - // does not scan them. Scan them directly here so --from-existing captures them. - if provider_type == VERTEX_AI_PROVIDER_TYPE { - let discovered = discovered.get_or_insert_with(Default::default); - for key in openshell_core::inference::VERTEX_AI_CONFIG_KEY_NAMES { - if let Ok(val) = std::env::var(key) { - let val = val.trim().to_string(); - if !val.is_empty() { - discovered.config.entry(key.to_string()).or_insert(val); - } - } - } - } - - Ok(discovered) - } else { - let registry = ProviderRegistry::new(); - registry - .discover_existing(provider_type) - .map_err(|err| miette::miette!("failed to discover existing provider data: {err}")) - } -} - -/// Canonical provider type string for Google Vertex AI. -const VERTEX_AI_PROVIDER_TYPE: &str = "google-vertex-ai"; - -/// Canonical provider type string for Google Cloud (GCP APIs). -const GOOGLE_CLOUD_PROVIDER_TYPE: &str = "google-cloud"; - -fn missing_credentials_error(provider_type: &str) -> miette::Report { - if provider_type == VERTEX_AI_PROVIDER_TYPE { - return miette::miette!( - "no credentials resolved for provider type '{provider_type}'. \ - Set GOOGLE_VERTEX_AI_TOKEN, VERTEX_AI_TOKEN, \ - GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN, or VERTEX_AI_SERVICE_ACCOUNT_TOKEN; \ - or use --from-gcloud-adc or --from-existing with those env vars set." - ); - } - - if provider_type == GOOGLE_CLOUD_PROVIDER_TYPE { - return miette::miette!( - "no credentials resolved for provider type '{provider_type}'. \ - Set GCP_ADC_ACCESS_TOKEN or GCP_SA_ACCESS_TOKEN; \ - or use --from-gcloud-adc / --from-existing with those env vars set." - ); - } - - miette::miette!( - "no credentials resolved for provider type '{provider_type}'. \ - Use --credential KEY[=VALUE], --runtime-credentials for runtime-resolved profile credentials, or --from-existing \ - with the appropriate env vars set." - ) -} - -#[allow(clippy::too_many_arguments)] -pub async fn provider_create( - server: &str, - name: &str, - provider_type: &str, - from_existing: bool, - credentials: &[String], - from_gcloud_adc: bool, - config: &[String], - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - provider_create_with_options( - server, - name, - provider_type, - from_existing, - credentials, - from_gcloud_adc, - false, - config, - workspace, - workspace, - tls, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -pub async fn provider_create_with_options( - server: &str, - name: &str, - provider_type: &str, - from_existing: bool, - credentials: &[String], - from_gcloud_adc: bool, - runtime_credentials: bool, - config: &[String], - workspace: &str, - profile_workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - if from_gcloud_adc && (from_existing || !credentials.is_empty() || runtime_credentials) { - return Err(miette::miette!( - "--from-gcloud-adc cannot be combined with --from-existing, --credential, or --runtime-credentials" - )); - } - if from_existing && (!credentials.is_empty() || runtime_credentials) { - return Err(miette::miette!( - "--from-existing cannot be combined with --credential or --runtime-credentials" - )); - } - if runtime_credentials && !credentials.is_empty() { - return Err(miette::miette!( - "--runtime-credentials cannot be combined with --credential" - )); - } - - let mut client = grpc_client(server, tls).await?; - - let provider_type = if let Some(provider_type) = normalize_provider_type(provider_type) { - provider_type.to_string() - } else { - let profile_id = provider_type.trim(); - if profile_id.is_empty() { - return Err(miette::miette!("provider type is required")); - } - let response = client - .get_provider_profile(GetProviderProfileRequest { - id: profile_id.to_string(), - workspace: profile_workspace.to_string(), - }) - .await; - match response { - Ok(response) => response - .into_inner() - .profile - .map(|profile| profile.id) - .filter(|id| !id.trim().is_empty()) - .unwrap_or_else(|| profile_id.to_string()), - Err(status) if status.code() == Code::NotFound => { - return Err(miette::miette!( - "unsupported provider type or profile: {provider_type}" - )); - } - Err(status) => return Err(status).into_diagnostic(), - } - }; - - let adc_credential_key = if from_gcloud_adc { - let profile = fetch_provider_profile(&mut client, &provider_type, profile_workspace) - .await - .map_err(|err| { - miette::miette!( - "--from-gcloud-adc is not supported for '{provider_type}' providers ({err})" - ) - })?; - let profile = ProviderTypeProfile::from_proto(&profile); - let adc_cred = profile.adc_credential().ok_or_else(|| { - miette::miette!( - "--from-gcloud-adc is not supported for '{provider_type}' providers \ - (no ADC-compatible credential in the provider profile)" - ) - })?; - Some( - adc_cred - .env_vars - .first() - .ok_or_else(|| { - miette::miette!( - "ADC credential in '{provider_type}' profile has no env_vars declared" - ) - })? - .clone(), - ) - } else { - None - }; - - let mut credential_map = parse_credential_pairs(credentials)?; - let mut config_map = parse_key_value_pairs(config, "--config")?; - - if from_existing { - let discovered = - discover_existing_provider_data(&mut client, &provider_type, profile_workspace).await?; - let Some(discovered) = discovered else { - return Err(miette::miette!( - "no existing local credentials/config found for provider type '{provider_type}'" - )); - }; - - for (key, value) in discovered.credentials { - credential_map.entry(key).or_insert(value); - } - for (key, value) in discovered.config { - config_map.entry(key).or_insert(value); - } - } - - if credential_map.is_empty() { - if from_existing { - return Err(missing_credentials_error(&provider_type)); - } - if !from_gcloud_adc && !runtime_credentials { - return Err(missing_credentials_error(&provider_type)); - } - let allows_empty_credentials = if runtime_credentials { - provider_profile_allows_empty_credentials( - &fetch_provider_profile(&mut client, &provider_type, profile_workspace).await?, - ) - } else { - fetch_provider_profile(&mut client, &provider_type, profile_workspace) - .await - .ok() - .is_some_and(|profile| provider_profile_allows_empty_credentials(&profile)) - }; - if !allows_empty_credentials { - if runtime_credentials { - return Err(miette::miette!( - "--runtime-credentials is only valid for provider profiles whose required credentials are resolved at runtime" - )); - } - return Err(missing_credentials_error(&provider_type)); - } - } - - // Validate and read the ADC file BEFORE creating the provider so that - // a bad/missing ADC does not leave an orphan provider behind. Bundle the - // credential key with the material so they stay coupled. - let gcloud_adc_bootstrap = if from_gcloud_adc { - let (client_id, client_secret, refresh_token) = read_gcloud_adc()?; - let key = adc_credential_key.expect("set when from_gcloud_adc is true"); - Some((key, client_id, client_secret, refresh_token)) - } else { - None - }; - - let response = client - .create_provider(CreateProviderRequest { - provider: Some(Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: name.to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: workspace.to_string(), - deletion_timestamp_ms: 0, - }), - r#type: provider_type.clone(), - credentials: credential_map, - config: config_map, - credential_expires_at_ms: HashMap::new(), - profile_workspace: profile_workspace.to_string(), - credential_handles: HashMap::new(), - }), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - - let provider = response - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider missing from response"))?; - let provider_name = provider.object_name().to_string(); - - if let Some((adc_credential_key, client_id, client_secret, refresh_token)) = - gcloud_adc_bootstrap - { - let mut material = HashMap::new(); - material.insert("client_id".to_string(), client_id); - material.insert("client_secret".to_string(), client_secret); - material.insert("refresh_token".to_string(), refresh_token); - - if let Err(configure_err) = client - .configure_provider_refresh(ConfigureProviderRefreshRequest { - provider: provider_name.clone(), - credential_key: adc_credential_key.clone(), - strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, - material, - secret_material_keys: vec![ - "client_secret".to_string(), - "refresh_token".to_string(), - ], - expires_at_ms: None, - workspace: workspace.to_string(), - }) - .await - { - return rollback_provider_create_after_gcloud_adc_failure( - &mut client, - &provider_name, - "configure", - &configure_err, - workspace, - ) - .await; - } - - if let Err(rotate_err) = client - .rotate_provider_credential(RotateProviderCredentialRequest { - provider: provider_name.clone(), - credential_key: adc_credential_key, - workspace: workspace.to_string(), - }) - .await - { - return rollback_provider_create_after_gcloud_adc_failure( - &mut client, - &provider_name, - "mint the initial access token for", - &rotate_err, - workspace, - ) - .await; - } - - println!("{} Created provider {}", "✓".green().bold(), provider_name); - println!("Configured GCP credentials from gcloud ADC and minted the initial access token"); - return Ok(()); - } - - println!("{} Created provider {}", "✓".green().bold(), provider_name); - Ok(()) -} - -fn provider_profile_allows_empty_credentials(profile: &ProviderProfile) -> bool { - ProviderTypeProfile::from_proto(profile).allows_empty_provider_credentials() -} - -pub async fn provider_get( - server: &str, - name: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .get_provider(GetProviderRequest { - name: name.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - - let provider = response - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider missing from response"))?; - - let credential_keys = provider_credential_keys(&provider); - let config_keys = provider.config.keys().cloned().collect::>(); - - println!("{}", "Provider:".cyan().bold()); - println!(); - println!(" {} {}", "Id:".dimmed(), provider.object_id()); - println!(" {} {}", "Name:".dimmed(), provider.object_name()); - println!(" {} {}", "Type:".dimmed(), provider.r#type); - println!( - " {} {}", - "Resource version:".dimmed(), - provider.metadata.as_ref().map_or(0, |m| m.resource_version) - ); - println!( - " {} {}", - "Credential keys:".dimmed(), - if credential_keys.is_empty() { - "".to_string() - } else { - credential_keys.join(", ") - } - ); - println!( - " {} {}", - "Config keys:".dimmed(), - if config_keys.is_empty() { - "".to_string() - } else { - config_keys.join(", ") - } - ); - - Ok(()) -} - -fn provider_to_json(provider: &Provider) -> serde_json::Value { - let mut obj = serde_json::Map::new(); - - // Core fields - obj.insert("id".to_string(), serde_json::json!(provider.object_id())); - obj.insert( - "name".to_string(), - serde_json::json!(provider.object_name()), - ); - obj.insert( - "workspace".to_string(), - serde_json::json!(provider.object_workspace()), - ); - obj.insert("type".to_string(), serde_json::json!(provider.r#type)); - - // Credential keys (NEVER values - security) - let credential_keys = provider_credential_keys(provider); - obj.insert( - "credential_keys".to_string(), - serde_json::json!(credential_keys), - ); - - // Config keys (keys only, not values) - if !provider.config.is_empty() { - let config_keys: Vec = provider.config.keys().cloned().collect(); - obj.insert("config_keys".to_string(), serde_json::json!(config_keys)); - } - - // Metadata fields (only if metadata exists) - if let Some(meta) = &provider.metadata { - if !meta.labels.is_empty() { - obj.insert("labels".to_string(), serde_json::json!(meta.labels)); - } - if meta.resource_version != 0 { - obj.insert( - "resource_version".to_string(), - serde_json::json!(meta.resource_version), - ); - } - if meta.created_at_ms != 0 { - obj.insert( - "created_at".to_string(), - serde_json::json!(format_epoch_ms(meta.created_at_ms)), - ); - } - } - - // Credential expiration times (only if present) - if !provider.credential_expires_at_ms.is_empty() { - obj.insert( - "credential_expires_at_ms".to_string(), - serde_json::json!(provider.credential_expires_at_ms), - ); - } - - serde_json::Value::Object(obj) -} - -fn provider_credential_keys(provider: &Provider) -> Vec { - let mut keys: Vec = provider - .credentials - .keys() - .chain(provider.credential_handles.keys()) - .cloned() - .collect(); - keys.sort(); - keys.dedup(); - keys -} - -#[allow(clippy::too_many_arguments)] -pub async fn provider_list( - server: &str, - limit: u32, - offset: u32, - names_only: bool, - output: &str, - workspace: &str, - all_workspaces: bool, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .list_providers(ListProvidersRequest { - limit, - offset, - workspace: if all_workspaces { - String::new() - } else { - workspace.to_string() - }, - all_workspaces, - }) - .await - .into_diagnostic()?; - let providers = response.into_inner().providers; - - // Handle structured output formats (json, yaml) - if crate::output::print_output_collection(output, &providers, provider_to_json)? { - return Ok(()); - } - - if providers.is_empty() { - if !names_only { - println!("No providers found."); - } - return Ok(()); - } - - if names_only { - for provider in &providers { - if all_workspaces { - println!("{}/{}", provider.object_workspace(), provider.object_name()); - } else { - println!("{}", provider.object_name()); - } - } - return Ok(()); - } - - let ws_width = if all_workspaces { - providers - .iter() - .map(|p| p.object_workspace().len()) - .max() - .unwrap_or(9) - .max(9) - } else { - 0 - }; - let name_width = providers - .iter() - .map(|provider| provider.object_name().len()) - .max() - .unwrap_or(4) - .max(4); - let type_width = providers - .iter() - .map(|provider| provider.r#type.len()) - .max() - .unwrap_or(4) - .max(4); - - if all_workspaces { - println!( - "{: Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .list_provider_profiles(ListProviderProfilesRequest { - limit: 100, - offset: 0, - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - let mut profiles = response.into_inner().profiles; - profiles.sort_by(|left, right| { - left.category - .cmp(&right.category) - .then_with(|| left.id.cmp(&right.id)) - }); - let dto_profiles = profiles - .iter() - .map(ProviderTypeProfile::from_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(()); - } - - if profiles.is_empty() { - println!("No provider profiles found."); - return 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); - } - - Ok(()) -} - -pub async fn provider_profile_export( - server: &str, - id: &str, - output: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let rendered = provider_profile_export_text(server, id, output, workspace, tls).await?; - if output == "json" { - println!("{rendered}"); - } else { - print!("{rendered}"); - } - Ok(()) -} - -pub async fn provider_profile_export_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: workspace.to_string(), - }) - .await - .into_diagnostic()?; - let profile = response - .into_inner() - .profile - .ok_or_else(|| miette!("provider profile '{id}' not found"))?; - let profile = ProviderTypeProfile::from_proto(&profile); - - match output { - "json" => profile_to_json(&profile).into_diagnostic(), - "yaml" => profile_to_yaml(&profile).into_diagnostic(), - "table" => Err(miette!( - "profile export supports '-o yaml' and '-o json'; table output is not supported" - )), - _ => Err(miette!("unsupported output format: {output}")), - } -} - -pub async fn provider_profile_import( - server: &str, - file: Option<&Path>, - from: Option<&Path>, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let (items, mut diagnostics) = load_profile_import_items(file, from)?; - if items.is_empty() && diagnostics.is_empty() { - return Err(miette!("no provider profile files found")); - } - if profile_diagnostics_have_errors(&diagnostics) { - print_profile_diagnostics(&diagnostics); - return Err(miette!("provider profile import failed")); - } - - let mut client = grpc_client(server, tls).await?; - if !items.is_empty() { - let response = client - .import_provider_profiles(ImportProviderProfilesRequest { - profiles: items, - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - diagnostics.extend(response.diagnostics); - if response.imported { - println!( - "Imported {} provider profile{}.", - response.profiles.len(), - if response.profiles.len() == 1 { - "" - } else { - "s" - } - ); - return Ok(()); - } - } - - print_profile_diagnostics(&diagnostics); - Err(miette!("provider profile import failed")) -} - -pub async fn provider_profile_update( - server: &str, - id: &str, - file: &Path, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let (mut items, mut diagnostics) = load_profile_import_items(Some(file), None)?; - if items.is_empty() && diagnostics.is_empty() { - return Err(miette!("no provider profile files found")); - } - if profile_diagnostics_have_errors(&diagnostics) { - print_profile_diagnostics(&diagnostics); - return Err(miette!("provider profile update failed")); - } - - let mut client = grpc_client(server, tls).await?; - if let Some(item) = items.pop() { - let expected_resource_version = item - .profile - .as_ref() - .map_or(0, |profile| profile.resource_version); - let response = client - .update_provider_profiles(UpdateProviderProfilesRequest { - profile: Some(item), - expected_resource_version, - id: id.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - diagnostics.extend(response.diagnostics); - if response.updated { - println!("Updated provider profile."); - return Ok(()); - } - } - - print_profile_diagnostics(&diagnostics); - Err(miette!("provider profile update failed")) -} - -pub async fn provider_profile_lint( - server: &str, - file: Option<&Path>, - from: Option<&Path>, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let (items, mut diagnostics) = load_profile_import_items(file, from)?; - if items.is_empty() && diagnostics.is_empty() { - return Err(miette!("no provider profile files found")); - } - - if !items.is_empty() { - let mut client = grpc_client(server, tls).await?; - let response = client - .lint_provider_profiles(LintProviderProfilesRequest { - profiles: items, - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - diagnostics.extend(response.diagnostics); - } - - if profile_diagnostics_have_errors(&diagnostics) { - print_profile_diagnostics(&diagnostics); - return Err(miette!("provider profile lint failed")); - } - - println!("Provider profile lint passed."); - Ok(()) -} - -pub async fn provider_profile_delete( - server: &str, - id: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .delete_provider_profile(DeleteProviderProfileRequest { - id: id.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - if response.deleted { - println!("Deleted provider profile '{id}'."); - } else { - println!("Provider profile '{id}' was not deleted."); - } - Ok(()) -} - -pub async fn provider_refresh_status( - server: &str, - name: &str, - credential_key: Option<&str>, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .get_provider_refresh_status(GetProviderRefreshStatusRequest { - provider: name.to_string(), - credential_key: credential_key.unwrap_or_default().to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - - if response.credentials.is_empty() { - if let Some(credential_key) = credential_key { - println!( - "No refresh configuration found for provider '{name}' credential '{credential_key}'." - ); - } else { - println!("No refresh configurations found for provider '{name}'."); - } - return Ok(()); - } - - println!("{}", refresh_status_header()); - for status in response.credentials { - print_refresh_status_row(&status); - } - Ok(()) -} - -fn refresh_status_header() -> String { - format!( - "{:<24} {:<28} {:<28} {:<18} {:<20} {:<20} {:<20} {}", - "PROVIDER".bold(), - "CREDENTIAL_KEY".bold(), - "STRATEGY".bold(), - "STATUS".bold(), - "EXPIRES_AT".bold(), - "NEXT_REFRESH".bold(), - "LAST_REFRESH".bold(), - "LAST_ERROR".bold(), - ) -} - -pub struct ProviderRefreshConfigInput<'a> { - pub name: &'a str, - pub credential_key: &'a str, - pub strategy: &'a str, - pub material: &'a [String], - pub secret_material_env: &'a [String], - pub secret_material_keys: &'a [String], - pub credential_expires_at_ms: Option, -} - -pub async fn provider_refresh_config( - server: &str, - input: ProviderRefreshConfigInput<'_>, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let strategy = provider_refresh_strategy(input.strategy)?; - let mut material = parse_key_value_pairs(input.material, "--material")?; - let mut secret_material_keys = input.secret_material_keys.to_vec(); - // Env-resolved secrets are auto-marked secret; duplicate keys are an - // error rather than a precedence order. - for (key, value) in parse_secret_material_env_pairs(input.secret_material_env)? { - if material.contains_key(&key) { - return Err(miette!( - "duplicate material key '{key}': supplied via both --material and --secret-material-env" - )); - } - if !secret_material_keys.contains(&key) { - secret_material_keys.push(key.clone()); - } - material.insert(key, value); - } - let mut client = grpc_client(server, tls).await?; - let status = client - .configure_provider_refresh(ConfigureProviderRefreshRequest { - provider: input.name.to_string(), - credential_key: input.credential_key.to_string(), - strategy: strategy as i32, - material, - secret_material_keys, - expires_at_ms: input.credential_expires_at_ms, - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner() - .status - .ok_or_else(|| miette!("provider refresh status missing from response"))?; - - println!( - "{} Configured refresh for {} {}", - "✓".green().bold(), - status.provider_name, - status.credential_key - ); - Ok(()) -} - -pub async fn provider_rotate( - server: &str, - name: &str, - credential_key: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let status = client - .rotate_provider_credential(RotateProviderCredentialRequest { - provider: name.to_string(), - credential_key: credential_key.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner() - .status - .ok_or_else(|| miette!("provider refresh status missing from response"))?; - - if status.last_error.is_empty() { - println!( - "{} Rotation requested for {} {} ({})", - "✓".green().bold(), - status.provider_name, - status.credential_key, - status.status - ); - } else { - println!( - "Rotation request recorded for {} {} ({}): {}", - status.provider_name, status.credential_key, status.status, status.last_error - ); - } - Ok(()) -} - -pub async fn provider_refresh_delete( - server: &str, - name: &str, - credential_key: &str, - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - let response = client - .delete_provider_refresh(DeleteProviderRefreshRequest { - provider: name.to_string(), - credential_key: credential_key.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner(); - - if response.deleted { - println!( - "{} Deleted refresh config for {} {}", - "✓".green().bold(), - name, - credential_key - ); - } else { - println!("No refresh config found for provider '{name}' credential '{credential_key}'."); - } - Ok(()) -} - -fn provider_refresh_strategy(strategy: &str) -> Result { - match strategy { - "oauth2_refresh_token" => Ok(ProviderCredentialRefreshStrategy::Oauth2RefreshToken), - "oauth2_client_credentials" => { - Ok(ProviderCredentialRefreshStrategy::Oauth2ClientCredentials) - } - "google_service_account_jwt" => { - Ok(ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt) - } - "aws_sts_assume_role" => Ok(ProviderCredentialRefreshStrategy::AwsStsAssumeRole), - _ => Err(miette!("unsupported provider refresh strategy: {strategy}")), - } -} - -fn print_refresh_status_row(status: &ProviderCredentialRefreshStatus) { - println!("{}", refresh_status_row(status)); -} - -fn refresh_status_row(status: &ProviderCredentialRefreshStatus) -> String { - let strategy = ProviderCredentialRefreshStrategy::try_from(status.strategy) - .unwrap_or(ProviderCredentialRefreshStrategy::Unspecified); - format!( - "{:<24} {:<28} {:<28} {:<18} {:<20} {:<20} {:<20} {}", - status.provider_name, - status.credential_key, - provider_refresh_strategy_name(strategy), - status.status, - format_optional_epoch_ms(status.expires_at_ms), - format_optional_epoch_ms(status.next_refresh_at_ms), - format_optional_epoch_ms(status.last_refresh_at_ms), - truncate_status_field(&status.last_error, 72), - ) -} - -fn provider_refresh_strategy_name(strategy: ProviderCredentialRefreshStrategy) -> &'static str { - match strategy { - ProviderCredentialRefreshStrategy::Static => "static", - ProviderCredentialRefreshStrategy::External => "external", - ProviderCredentialRefreshStrategy::Oauth2RefreshToken => "oauth2_refresh_token", - ProviderCredentialRefreshStrategy::Oauth2ClientCredentials => "oauth2_client_credentials", - ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt => "google_service_account_jwt", - ProviderCredentialRefreshStrategy::AwsStsAssumeRole => "aws_sts_assume_role", - ProviderCredentialRefreshStrategy::Unspecified => "unspecified", - } -} - -fn load_profile_import_items( - file: Option<&Path>, - from: Option<&Path>, -) -> Result<( - Vec, - Vec, -)> { - let paths = profile_source_paths(file, from)?; - let mut items = Vec::new(); - let mut diagnostics = Vec::new(); - for path in paths { - match load_profile_import_item(&path) { - Ok(item) => items.push(item), - Err(diagnostic) => diagnostics.push(diagnostic), - } - } - Ok((items, diagnostics)) -} - -fn profile_source_paths(file: Option<&Path>, from: Option<&Path>) -> Result> { - if let Some(file) = file { - return Ok(vec![file.to_path_buf()]); - } - let Some(from) = from else { - return Ok(Vec::new()); - }; - let mut paths = Vec::new(); - for entry in std::fs::read_dir(from) - .into_diagnostic() - .wrap_err_with(|| format!("failed to read profile directory {}", from.display()))? - { - let entry = entry.into_diagnostic()?; - let path = entry.path(); - if path.is_file() && profile_extension_supported(&path) { - paths.push(path); - } - } - paths.sort(); - Ok(paths) -} - -fn profile_extension_supported(path: &Path) -> bool { - matches!( - path.extension().and_then(|ext| ext.to_str()), - Some("yaml" | "yml" | "json") - ) -} - -fn load_profile_import_item( - path: &Path, -) -> Result { - let source = path.display().to_string(); - let input = std::fs::read_to_string(path).map_err(|err| { - profile_file_diagnostic( - &source, - format!("failed to read provider profile file: {err}"), - ) - })?; - let profile = match path.extension().and_then(|ext| ext.to_str()) { - Some("yaml" | "yml") => parse_profile_yaml(&input), - Some("json") => parse_profile_json(&input), - _ => { - return Err(profile_file_diagnostic( - &source, - "unsupported provider profile file format".to_string(), - )); - } - } - .map_err(|err| profile_file_diagnostic(&source, err.to_string()))?; - - let pre_lower = profile.validate_before_lowering(&source); - if let Some(diag) = pre_lower.into_iter().find(|d| d.severity == "error") { - return Err(ProviderProfileDiagnostic { - source: diag.source, - profile_id: diag.profile_id, - field: diag.field, - message: diag.message, - severity: diag.severity, - }); - } - - Ok(ProviderProfileImportItem { - profile: Some(profile.to_proto()), - source, - }) -} - -fn profile_file_diagnostic(source: &str, message: String) -> ProviderProfileDiagnostic { - ProviderProfileDiagnostic { - source: source.to_string(), - profile_id: String::new(), - field: "file".to_string(), - message, - severity: "error".to_string(), - } -} - -fn print_profile_diagnostics(diagnostics: &[ProviderProfileDiagnostic]) { - if diagnostics.is_empty() { - return; - } - eprintln!("{}", "Provider profile diagnostics:".red().bold()); - for diagnostic in diagnostics { - let source = if diagnostic.source.is_empty() { - "" - } else { - &diagnostic.source - }; - let profile = if diagnostic.profile_id.is_empty() { - "-".to_string() - } else { - diagnostic.profile_id.clone() - }; - eprintln!( - " {} {} profile={} field={} {}", - diagnostic.severity.as_str().red(), - source, - profile, - diagnostic.field, - diagnostic.message - ); - } -} - -fn profile_diagnostics_have_errors(diagnostics: &[ProviderProfileDiagnostic]) -> bool { - diagnostics - .iter() - .any(|diagnostic| diagnostic.severity == "error") -} - -fn display_provider_category(category: i32) -> &'static str { - match ProviderProfileCategory::try_from(category).unwrap_or(ProviderProfileCategory::Other) { - ProviderProfileCategory::Inference => "INFERENCE", - ProviderProfileCategory::Agent => "AGENT", - ProviderProfileCategory::SourceControl => "SOURCE CONTROL", - ProviderProfileCategory::Messaging => "MESSAGING", - ProviderProfileCategory::Data => "DATA", - ProviderProfileCategory::Knowledge => "KNOWLEDGE", - ProviderProfileCategory::Other | ProviderProfileCategory::Unspecified => "OTHER", - } -} - -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 { - profiles - .iter() - .map(|profile| { - profile - .id - .chars() - .count() - .min(PROVIDER_PROFILE_ID_MAX_WIDTH) - }) - .max() - .unwrap_or(2) - .max(2) -} - -fn provider_profile_display_width(profiles: &[ProviderProfile]) -> usize { - profiles - .iter() - .map(|profile| { - profile - .display_name - .chars() - .count() - .min(PROVIDER_PROFILE_DISPLAY_MAX_WIDTH) - }) - .max() - .unwrap_or(4) - .max(4) -} - -fn provider_profile_scope_width(profiles: &[ProviderProfile]) -> usize { - profiles - .iter() - .map(|profile| profile.scope.chars().count()) - .max() - .unwrap_or(5) - .max(5) -} - -fn provider_profile_source_width(profiles: &[ProviderProfile]) -> usize { - profiles - .iter() - .map(|profile| { - profile - .source - .chars() - .count() - .min(PROVIDER_PROFILE_SOURCE_MAX_WIDTH) - }) - .max() - .unwrap_or(6) - .max(6) -} - -fn print_provider_type_header( - id_width: usize, - scope_width: usize, - source_width: usize, - display_width: usize, -) { - let endpoints = "ENDPOINTS"; - println!( - " {: Result<()> { - if from_existing && !credentials.is_empty() { - return Err(miette::miette!( - "--from-existing cannot be combined with --credential" - )); - } - - let mut client = grpc_client(server, tls).await?; - - let mut credential_map = parse_credential_pairs(credentials)?; - let mut config_map = parse_key_value_pairs(config, "--config")?; - let credential_expires_at_ms = parse_credential_expiry_pairs(credential_expires_at)?; - - if from_existing { - // Fetch the existing provider to discover its type for credential lookup. - let existing = client - .get_provider(GetProviderRequest { - name: name.to_string(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()? - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider '{name}' not found"))?; - - let provider_type = existing.r#type; - let discovered = - discover_existing_provider_data(&mut client, &provider_type, workspace).await?; - let Some(discovered) = discovered else { - return Err(miette::miette!( - "no existing local credentials/config found for provider type '{provider_type}'" - )); - }; - - for (key, value) in discovered.credentials { - credential_map.entry(key).or_insert(value); - } - for (key, value) in discovered.config { - config_map.entry(key).or_insert(value); - } - } - - let response = client - .update_provider(UpdateProviderRequest { - provider: Some(Provider { - metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { - id: String::new(), - name: name.to_string(), - created_at_ms: 0, - labels: HashMap::new(), - resource_version: 0, - annotations: HashMap::new(), - workspace: workspace.to_string(), - deletion_timestamp_ms: 0, - }), - r#type: String::new(), - credentials: credential_map, - config: config_map, - credential_expires_at_ms: HashMap::new(), - profile_workspace: String::new(), - credential_handles: HashMap::new(), - }), - credential_expires_at_ms, - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - - let provider = response - .into_inner() - .provider - .ok_or_else(|| miette::miette!("provider missing from response"))?; - - println!( - "{} Updated provider {}", - "✓".green().bold(), - provider.object_name() - ); - Ok(()) -} + "URL".bold(), + ); + } -pub async fn provider_delete( - server: &str, - names: &[String], - workspace: &str, - tls: &TlsOptions, -) -> Result<()> { - let mut client = grpc_client(server, tls).await?; - for name in names { - let response = client - .delete_provider(DeleteProviderRequest { - name: name.clone(), - workspace: workspace.to_string(), - }) - .await - .into_diagnostic()?; - if response.into_inner().deleted { - println!("{} Deleted provider {name}", "✓".green().bold()); + for (workspace, sandbox, service, target, url) in rows { + if all_workspaces { + println!( + "{workspace: &str { + if service.is_empty() { "-" } else { service } +} + +fn service_url_for_gateway(service_url: &str, gateway_endpoint: &str) -> String { + let (Ok(mut service_url), Ok(gateway_endpoint)) = ( + url::Url::parse(service_url), + url::Url::parse(gateway_endpoint), + ) else { + return service_url.to_string(); + }; + + if service_url + .set_port(gateway_endpoint.port_or_known_default()) + .is_err() + { + return service_url.to_string(); + } + + service_url.to_string() } // --------------------------------------------------------------------------- @@ -7001,20 +4911,17 @@ fn format_endpoint(endpoint: &openshell_core::proto::NetworkEndpoint) -> String mod tests { use super::{ PolicyGetView, ProvisioningStep, build_sandbox_resource_limits, - dockerfile_sources_supported_for_gateway, format_endpoint, - format_provider_attachment_table, git_sync_files, inferred_provider_type, - parse_cli_setting_value, parse_credential_expiry_cli_value, parse_credential_expiry_pairs, - parse_credential_pairs, parse_driver_config_json, parse_secret_material_env_pairs, - policy_revision_to_json, provider_profile_allows_empty_credentials, - provisioning_timeout_message, ready_false_condition_message, refresh_status_header, - refresh_status_row, resolve_from, sandbox_should_persist, sandbox_upload_plan, + dockerfile_sources_supported_for_gateway, format_endpoint, git_sync_files, + parse_cli_setting_value, parse_credential_expiry_cli_value, parse_driver_config_json, + parse_secret_material_env_pairs, policy_revision_to_json, provisioning_timeout_message, + ready_false_condition_message, resolve_from, sandbox_should_persist, sandbox_upload_plan, service_expose_status_error, service_url_for_gateway, }; use crate::TEST_ENV_LOCK; use crate::commands::common::progress_step_from_metadata; + use crate::commands::common::{parse_credential_expiry_pairs, parse_credential_pairs}; use crate::test_utils::EnvVarGuard; use std::fs; - use std::io::Write; use std::path::Path; use std::process::Command; use tonic::Status; @@ -7025,11 +4932,9 @@ mod tests { PROGRESS_STEP_STARTING_SANDBOX, }; use openshell_core::proto::{ - GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, PolicyStatus, Provider, - ProviderCredentialRefresh, ProviderCredentialRefreshStatus, - ProviderCredentialRefreshStrategy, ProviderCredentialTokenGrant, ProviderProfile, - ProviderProfileCredential, ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, - SandboxPolicyRevision, SandboxStatus, datamodel::v1::ObjectMeta, + GetSandboxConfigResponse, GpuResourceRequirements, PolicySource, PolicyStatus, + ResourceRequirements, Sandbox, SandboxCondition, SandboxPhase, SandboxPolicyRevision, + SandboxStatus, datamodel::v1::ObjectMeta, }; #[test] @@ -7209,43 +5114,6 @@ mod tests { assert_eq!(parsed, 1_767_225_600_000); } - #[test] - fn provider_attachment_table_formats_provider_counts() { - let output = format_provider_attachment_table( - &[Provider { - metadata: Some(ObjectMeta { - name: "work-custom".to_string(), - ..Default::default() - }), - r#type: "custom-api".to_string(), - credentials: [ - ("CUSTOM_API_KEY".to_string(), "REDACTED".to_string()), - ("CUSTOM_API_SECRET".to_string(), "REDACTED".to_string()), - ] - .into_iter() - .collect(), - config: std::iter::once(( - "BASE_URL".to_string(), - "https://api.custom.example".to_string(), - )) - .collect(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - credential_handles: std::collections::HashMap::new(), - }], - false, - ); - - assert!(output.contains("NAME")); - assert!(output.contains("TYPE")); - assert!(output.contains("CREDENTIAL_KEYS")); - assert!(output.contains("CONFIG_KEYS")); - assert!(output.contains("work-custom")); - assert!(output.contains("custom-api")); - assert!(output.contains('2')); - assert!(output.contains('1')); - } - #[test] fn progress_step_metadata_values_map_to_cli_steps() { assert_eq!( @@ -7263,109 +5131,6 @@ mod tests { assert_eq!(progress_step_from_metadata("driver-private-step"), None); } - #[test] - fn refresh_status_table_includes_operational_fields() { - let header = refresh_status_header(); - assert!(header.contains("NEXT_REFRESH")); - assert!(header.contains("LAST_REFRESH")); - assert!(header.contains("LAST_ERROR")); - - let row = refresh_status_row(&ProviderCredentialRefreshStatus { - provider_name: "my-graph".to_string(), - provider_id: "provider-id".to_string(), - credential_key: "MS_GRAPH_ACCESS_TOKEN".to_string(), - strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, - status: "error".to_string(), - expires_at_ms: 1_767_225_600_000, - next_refresh_at_ms: 1_767_225_660_000, - last_refresh_at_ms: 1_767_225_000_000, - last_error: "token endpoint returned a very long error message that should be truncated for table readability" - .to_string(), - }); - - assert!(row.contains("my-graph")); - assert!(row.contains("MS_GRAPH_ACCESS_TOKEN")); - assert!(row.contains("oauth2_client_credentials")); - assert!(row.contains("error")); - assert!(row.contains("2026-01-01 00:00:00")); - assert!(row.contains("...")); - } - - #[test] - fn empty_provider_credentials_require_all_required_credentials_to_be_runtime_resolvable() { - let refresh_token_profile = ProviderProfile { - credentials: vec![ProviderProfileCredential { - name: "MS_GRAPH_ACCESS_TOKEN".to_string(), - required: true, - refresh: Some(ProviderCredentialRefresh { - strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32, - ..Default::default() - }), - ..Default::default() - }], - ..Default::default() - }; - assert!(provider_profile_allows_empty_credentials( - &refresh_token_profile - )); - - let token_grant_profile = ProviderProfile { - credentials: vec![ProviderProfileCredential { - name: "ACCESS_TOKEN".to_string(), - required: true, - token_grant: Some(ProviderCredentialTokenGrant { - token_endpoint: "https://auth.example.com/token".to_string(), - ..Default::default() - }), - ..Default::default() - }], - ..Default::default() - }; - assert!(provider_profile_allows_empty_credentials( - &token_grant_profile - )); - - let mixed_static_profile = ProviderProfile { - credentials: vec![ - ProviderProfileCredential { - name: "ACCESS_TOKEN".to_string(), - required: true, - refresh: Some(ProviderCredentialRefresh { - strategy: ProviderCredentialRefreshStrategy::Oauth2ClientCredentials as i32, - ..Default::default() - }), - ..Default::default() - }, - ProviderProfileCredential { - name: "STATIC_API_KEY".to_string(), - required: true, - refresh: None, - ..Default::default() - }, - ], - ..Default::default() - }; - assert!(!provider_profile_allows_empty_credentials( - &mixed_static_profile - )); - - let optional_refresh_profile = ProviderProfile { - credentials: vec![ProviderProfileCredential { - name: "OPTIONAL_TOKEN".to_string(), - required: false, - refresh: Some(ProviderCredentialRefresh { - strategy: ProviderCredentialRefreshStrategy::GoogleServiceAccountJwt as i32, - ..Default::default() - }), - ..Default::default() - }], - ..Default::default() - }; - assert!(provider_profile_allows_empty_credentials( - &optional_refresh_profile - )); - } - #[test] fn parse_cli_setting_value_parses_bool_aliases() { let yes_value = parse_cli_setting_value("ocsf_json_enabled", "yes").expect("parse yes"); @@ -7496,41 +5261,6 @@ mod tests { ); } - #[test] - fn inferred_provider_type_returns_type_for_known_command() { - let result = inferred_provider_type(&["claude".to_string(), "--help".to_string()]); - assert_eq!(result, Some("claude-code".to_string())); - } - - #[test] - fn inferred_provider_type_returns_none_for_unknown_command() { - let result = inferred_provider_type(&["bash".to_string()]); - assert_eq!(result, None); - } - - #[test] - fn inferred_provider_type_returns_none_for_empty_command() { - let result = inferred_provider_type(&[]); - assert_eq!(result, None); - } - - #[test] - fn inferred_provider_type_normalizes_aliases() { - // `glab` should resolve to `gitlab` - let result = inferred_provider_type(&["glab".to_string()]); - assert_eq!(result, Some("gitlab".to_string())); - - // `gh` should resolve to `github` - let result = inferred_provider_type(&["gh".to_string()]); - assert_eq!(result, Some("github".to_string())); - } - - #[test] - fn inferred_provider_type_handles_full_path() { - let result = inferred_provider_type(&["/usr/local/bin/claude".to_string()]); - assert_eq!(result, Some("claude-code".to_string())); - } - #[test] fn sandbox_should_persist_defaults_to_persistent() { assert!(sandbox_should_persist(true, None)); @@ -7972,394 +5702,6 @@ mod tests { ); } - #[test] - fn read_gcloud_adc_missing_file_errors() { - let _lock = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let _guard = EnvVarGuard::set( - "GOOGLE_APPLICATION_CREDENTIALS", - "/nonexistent/path/to/adc.json", - ); - let err = super::read_gcloud_adc().expect_err("missing file should error"); - assert!( - err.to_string().contains("failed to read gcloud ADC file"), - "unexpected error: {err}" - ); - } - - #[test] - fn read_gcloud_adc_wrong_type_errors() { - let _lock = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let tmp = tempfile::NamedTempFile::new().expect("tempfile"); - let json = serde_json::json!({ - "type": "service_account", - "project_id": "my-project", - "private_key_id": "key123" - }); - Write::write_all(&mut tmp.as_file(), json.to_string().as_bytes()).expect("write tempfile"); - let _guard = EnvVarGuard::set( - "GOOGLE_APPLICATION_CREDENTIALS", - tmp.path().to_str().expect("tempfile path"), - ); - let err = super::read_gcloud_adc().expect_err("wrong type should error"); - // The service_account type gets a targeted message directing the user - // to the real Vertex service-account credential flow instead of the - // generic authorized_user hint. - assert!( - err.to_string() - .contains("GOOGLE_VERTEX_AI_SERVICE_ACCOUNT_TOKEN"), - "error should mention the service-account token key, got: {err}" - ); - } - - #[test] - fn read_gcloud_adc_parses_user_creds() { - let _lock = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let tmp = tempfile::NamedTempFile::new().expect("tempfile"); - let json = serde_json::json!({ - "type": "authorized_user", - "client_id": "test-client-id.apps.googleusercontent.com", - "client_secret": "test-client-secret", - "refresh_token": "test-refresh-token" - }); - Write::write_all(&mut tmp.as_file(), json.to_string().as_bytes()).expect("write tempfile"); - let _guard = EnvVarGuard::set( - "GOOGLE_APPLICATION_CREDENTIALS", - tmp.path().to_str().expect("tempfile path"), - ); - let (client_id, client_secret, refresh_token) = - super::read_gcloud_adc().expect("valid ADC should parse"); - assert_eq!(client_id, "test-client-id.apps.googleusercontent.com"); - assert_eq!(client_secret, "test-client-secret"); - assert_eq!(refresh_token, "test-refresh-token"); - } - - #[test] - fn read_gcloud_adc_uses_cloudsdk_config_fallback() { - let _lock = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let dir = tempfile::tempdir().expect("tempdir"); - let adc_path = dir.path().join("application_default_credentials.json"); - let json = serde_json::json!({ - "type": "authorized_user", - "client_id": "cloudsdk-client-id.apps.googleusercontent.com", - "client_secret": "cloudsdk-client-secret", - "refresh_token": "cloudsdk-refresh-token" - }); - fs::write(&adc_path, json.to_string()).expect("write adc file"); - let _adc_guard = EnvVarGuard::unset("GOOGLE_APPLICATION_CREDENTIALS"); - let _cloudsdk_guard = - EnvVarGuard::set("CLOUDSDK_CONFIG", dir.path().to_str().expect("config path")); - - let (client_id, client_secret, refresh_token) = - super::read_gcloud_adc().expect("valid CLOUDSDK_CONFIG ADC should parse"); - assert_eq!(client_id, "cloudsdk-client-id.apps.googleusercontent.com"); - assert_eq!(client_secret, "cloudsdk-client-secret"); - assert_eq!(refresh_token, "cloudsdk-refresh-token"); - } - - #[test] - fn read_gcloud_adc_malformed_json_errors() { - let _lock = TEST_ENV_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let tmp = tempfile::NamedTempFile::new().expect("tempfile"); - Write::write_all(&mut tmp.as_file(), b"not valid json at all {{{{") - .expect("write tempfile"); - let _guard = EnvVarGuard::set( - "GOOGLE_APPLICATION_CREDENTIALS", - tmp.path().to_str().expect("tempfile path"), - ); - let result = super::read_gcloud_adc(); - assert!( - result.is_err(), - "malformed JSON should produce an error, got: {result:?}" - ); - let err = result.unwrap_err(); - let msg = format!("{err}"); - assert!( - msg.contains("parse") - || msg.contains("JSON") - || msg.contains("json") - || msg.contains("invalid") - || msg.contains("failed"), - "error message should mention parse/JSON failure, got: {msg}" - ); - } - - #[test] - fn empty_provider_credentials_allow_oauth2_refresh_token() { - use openshell_core::proto::{ - ProviderCredentialRefresh, ProviderCredentialRefreshStrategy, ProviderProfile, - ProviderProfileCredential, - }; - - let strategy = ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32; - let profile = ProviderProfile { - credentials: vec![ProviderProfileCredential { - required: true, - refresh: Some(ProviderCredentialRefresh { - strategy, - ..Default::default() - }), - ..Default::default() - }], - ..Default::default() - }; - assert!( - provider_profile_allows_empty_credentials(&profile), - "Oauth2RefreshToken should be allowed for refresh bootstrap" - ); - } - - #[test] - fn provider_to_json_includes_core_fields() { - let metadata = ObjectMeta { - id: "prov-123".to_string(), - name: "test-provider".to_string(), - ..Default::default() - }; - - let provider = Provider { - metadata: Some(metadata), - r#type: "anthropic".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - credential_handles: std::collections::HashMap::new(), - }; - - let json = super::provider_to_json(&provider); - - assert_eq!(json["id"], "prov-123"); - assert_eq!(json["name"], "test-provider"); - assert_eq!(json["workspace"], ""); - assert_eq!(json["type"], "anthropic"); - } - - #[test] - fn provider_to_json_exposes_credential_keys_not_values() { - let mut credentials = std::collections::HashMap::new(); - credentials.insert("ANTHROPIC_API_KEY".to_string(), "secret-value".to_string()); - credentials.insert("OTHER_KEY".to_string(), "other-secret".to_string()); - - let provider = Provider { - metadata: Some(ObjectMeta::default()), - r#type: "anthropic".to_string(), - credentials, - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - credential_handles: std::collections::HashMap::new(), - }; - - let json = super::provider_to_json(&provider); - let json_str = json.to_string(); - - // Assert credential keys are present - let keys = json["credential_keys"].as_array().unwrap(); - assert_eq!(keys.len(), 2); - assert!(keys.iter().any(|k| k.as_str() == Some("ANTHROPIC_API_KEY"))); - assert!(keys.iter().any(|k| k.as_str() == Some("OTHER_KEY"))); - - // Assert credential values are NOT in the output (SECURITY) - assert!( - !json_str.contains("secret-value"), - "credential values must not be exposed" - ); - assert!( - !json_str.contains("other-secret"), - "credential values must not be exposed" - ); - } - - #[test] - fn provider_to_json_exposes_config_keys_not_values() { - let mut config = std::collections::HashMap::new(); - config.insert("region".to_string(), "us-west".to_string()); - config.insert( - "endpoint".to_string(), - "https://api.example.com".to_string(), - ); - - let provider = Provider { - metadata: Some(ObjectMeta::default()), - r#type: "custom".to_string(), - credentials: std::collections::HashMap::new(), - config, - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - credential_handles: std::collections::HashMap::new(), - }; - - let json = super::provider_to_json(&provider); - let json_str = json.to_string(); - - // Assert config keys are present - let keys = json["config_keys"].as_array().unwrap(); - assert_eq!(keys.len(), 2); - assert!(keys.iter().any(|k| k.as_str() == Some("region"))); - assert!(keys.iter().any(|k| k.as_str() == Some("endpoint"))); - - // Assert config values are NOT in the output (SECURITY) - assert!( - !json_str.contains("us-west"), - "config values must not be exposed" - ); - assert!( - !json_str.contains("https://api.example.com"), - "config values must not be exposed" - ); - } - - #[test] - fn provider_to_json_omits_empty_config() { - let provider = Provider { - metadata: Some(ObjectMeta::default()), - r#type: "anthropic".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), // Empty config - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - credential_handles: std::collections::HashMap::new(), - }; - - let json = super::provider_to_json(&provider); - - assert!( - json.get("config_keys").is_none(), - "empty config_keys should be omitted" - ); - } - - #[test] - fn provider_to_json_includes_metadata_fields_when_present() { - let mut labels = std::collections::HashMap::new(); - labels.insert("env".to_string(), "prod".to_string()); - - let metadata = ObjectMeta { - id: "prov-123".to_string(), - name: "test-provider".to_string(), - resource_version: 42, - created_at_ms: 1_234_567_890_000, - labels, - annotations: std::collections::HashMap::new(), - workspace: String::new(), - deletion_timestamp_ms: 0, - }; - - let provider = Provider { - metadata: Some(metadata), - r#type: "anthropic".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - credential_handles: std::collections::HashMap::new(), - }; - - let json = super::provider_to_json(&provider); - - assert_eq!(json["resource_version"], 42); - assert_eq!(json["created_at"], "2009-02-13 23:31:30"); - assert_eq!(json["labels"]["env"], "prod"); - } - - #[test] - fn provider_to_json_omits_zero_metadata_fields() { - let metadata = ObjectMeta { - id: "prov-123".to_string(), - name: "test-provider".to_string(), - // resource_version and created_at_ms are 0 - // labels is empty - ..Default::default() - }; - - let provider = Provider { - metadata: Some(metadata), - r#type: "anthropic".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - credential_handles: std::collections::HashMap::new(), - }; - - let json = super::provider_to_json(&provider); - - assert!( - json.get("resource_version").is_none(), - "zero resource_version should be omitted" - ); - assert!( - json.get("created_at").is_none(), - "zero created_at should be omitted" - ); - assert!( - json.get("labels").is_none(), - "empty labels should be omitted" - ); - } - - #[test] - fn provider_to_json_includes_credential_expiration() { - let mut credential_expires_at_ms = std::collections::HashMap::new(); - credential_expires_at_ms.insert("ACCESS_TOKEN".to_string(), 1_234_567_890); - - let provider = Provider { - metadata: Some(ObjectMeta::default()), - r#type: "oauth".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms, - profile_workspace: String::new(), - credential_handles: std::collections::HashMap::new(), - }; - - let json = super::provider_to_json(&provider); - - assert_eq!( - json["credential_expires_at_ms"]["ACCESS_TOKEN"], - 1_234_567_890 - ); - } - - #[test] - fn provider_to_json_formats_created_at_as_human_readable() { - let metadata = ObjectMeta { - id: "prov-123".to_string(), - name: "test-provider".to_string(), - created_at_ms: 1_609_459_200_000, // 2021-01-01 00:00:00 - ..Default::default() - }; - - let provider = Provider { - metadata: Some(metadata), - r#type: "anthropic".to_string(), - credentials: std::collections::HashMap::new(), - config: std::collections::HashMap::new(), - credential_expires_at_ms: std::collections::HashMap::new(), - profile_workspace: String::new(), - credential_handles: std::collections::HashMap::new(), - }; - - let json = super::provider_to_json(&provider); - - // Should format as human-readable datetime, not raw milliseconds - assert_eq!(json["created_at"], "2021-01-01 00:00:00"); - assert!( - json.get("created_at_ms").is_none(), - "raw milliseconds field should not exist" - ); - } - #[test] fn sandbox_detail_to_json_includes_policy_fields() { let mut sandbox = Sandbox {