diff --git a/src/cosh-ng/crates/cosh-core/src/auth/preflight.rs b/src/cosh-ng/crates/cosh-core/src/auth/preflight.rs index eae87a5319..afb1bb2849 100644 --- a/src/cosh-ng/crates/cosh-core/src/auth/preflight.rs +++ b/src/cosh-ng/crates/cosh-core/src/auth/preflight.rs @@ -14,6 +14,16 @@ use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::config::ResolvedProvider; +use crate::provider::sysom::{CredentialStatus, ProbeError}; + +fn ecs_preflight_result( + result: Result, +) -> Result<(), AuthPreflightError> { + match result.map_err(AuthPreflightError::MetadataProbe)? { + CredentialStatus::Ready => Ok(()), + CredentialStatus::NotReady(_) => Err(AuthPreflightError::CredentialSourceUnavailable), + } +} const CONNECT_TIMEOUT: Duration = Duration::from_secs(3); const REQUEST_TIMEOUT: Duration = Duration::from_secs(8); @@ -46,6 +56,7 @@ pub(crate) enum AuthPreflightError { ProviderUnavailable, ServiceNotReady, CredentialSourceUnavailable, + MetadataProbe(ProbeError), UnsupportedResponse, } @@ -61,6 +72,7 @@ impl AuthPreflightError { Self::ProviderUnavailable => "provider_unavailable", Self::ServiceNotReady => "service_not_ready", Self::CredentialSourceUnavailable => "credential_source_unavailable", + Self::MetadataProbe(error) => error.code(), Self::UnsupportedResponse => "unsupported_response", } } @@ -95,8 +107,9 @@ impl fmt::Display for AuthPreflightError { "Aliyun SysOM is not authorized for this account. Complete service authorization and try again.", ), Self::CredentialSourceUnavailable => formatter.write_str( - "ECS RAM Role credentials are not available yet. Authorize the instance role and try again.", + "ECS RAM Role credentials are not available yet. Check the instance role or wait for credential refresh.", ), + Self::MetadataProbe(error) => error.fmt(formatter), Self::UnsupportedResponse => formatter.write_str( "The endpoint returned an unsupported validation response. Check the endpoint configuration and provider compatibility.", ), @@ -372,13 +385,7 @@ async fn response_explicitly_reports_missing_model(response: Response) -> bool { async fn preflight_aliyun(provider: &ResolvedProvider) -> Result<(), AuthPreflightError> { if provider.auth_source.as_deref() == Some("ecs_ram_role") { - return tokio::task::spawn_blocking( - crate::provider::sysom::ecs_ram_role_credentials_available, - ) - .await - .map_err(|_| AuthPreflightError::EndpointUnreachable)? - .then_some(()) - .ok_or(AuthPreflightError::CredentialSourceUnavailable); + return ecs_preflight_result(crate::provider::sysom::probe_ecs_ram_role().await); } let resolved = crate::provider::sysom::endpoint::resolve(&provider.sysom_endpoint).await; @@ -740,6 +747,39 @@ mod tests { use super::*; + #[test] + fn ecs_metadata_preflight_distinguishes_ready_from_not_ready() { + use crate::provider::sysom::NotReadyReason; + assert_eq!(ecs_preflight_result(Ok(CredentialStatus::Ready)), Ok(())); + for reason in [ + NotReadyReason::RoleMissing, + NotReadyReason::CredentialsExpired, + ] { + assert_eq!( + ecs_preflight_result(Ok(CredentialStatus::NotReady(reason))), + Err(AuthPreflightError::CredentialSourceUnavailable) + ); + } + } + + #[test] + fn ecs_metadata_preflight_preserves_probe_errors_through_configure() { + for probe_error in [ + ProbeError::AccessDenied, + ProbeError::InvalidResponse, + ProbeError::Unreachable, + ProbeError::Timeout, + ProbeError::Http, + ] { + let error = ecs_preflight_result(Err(probe_error)).expect_err("probe failure"); + assert_eq!(error.code(), probe_error.code()); + assert_eq!(error.to_string(), probe_error.to_string()); + let configure_error = crate::auth::AuthConfigureError::from(error); + assert_eq!(configure_error.code(), probe_error.code()); + assert_eq!(configure_error.to_string(), probe_error.to_string()); + } + } + #[derive(Clone)] struct Reply { status: u16, diff --git a/src/cosh-ng/crates/cosh-core/src/provider/sysom.rs b/src/cosh-ng/crates/cosh-core/src/provider/sysom.rs index 2f1682625c..8a0660d1fd 100644 --- a/src/cosh-ng/crates/cosh-core/src/provider/sysom.rs +++ b/src/cosh-ng/crates/cosh-core/src/provider/sysom.rs @@ -3,8 +3,6 @@ //! Uses ACS3-HMAC-SHA256 signing and parses the cumulative SSE stream format //! into incremental `GenerateEvent`s compatible with `ContentGenerator` trait. -use std::io::{Read, Write}; -use std::net::{SocketAddr, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -15,12 +13,16 @@ use futures::StreamExt; use hmac::{Hmac, Mac}; use serde_json::Value; use sha2::{Digest, Sha256}; +use tokio::sync::OnceCell; use uuid::Uuid; use super::{ContentGenerator, GenerateConfig, GenerateStream, Message, ToolDeclaration}; use self::stream::sysom_event_stream; +pub use self::ecs_metadata::{probe_ecs_ram_role, CredentialStatus, NotReadyReason, ProbeError}; + +mod ecs_metadata; pub mod endpoint; mod stream; @@ -38,8 +40,6 @@ const CONSOLE_URL_TEMPLATE: &str = const INSTANCE_ID_CACHE_TTL_SECS: u64 = 3 * 3600; /// Connect timeout for ECS metadata service. const METADATA_CONNECT_TIMEOUT: Duration = Duration::from_secs(1); -/// Read timeout for ECS metadata service. -const METADATA_READ_TIMEOUT: Duration = Duration::from_secs(2); /// Connect timeout for the SysOM API. /// /// Bounds connection setup so an unreachable endpoint surfaces as a prompt @@ -104,7 +104,9 @@ pub struct SysomProvider { credentials: RwLock, is_sts: bool, cancelled: Arc, - instance_id: Option, + /// Resolved once per provider instance, including a negative result; only the + /// on-disk cache honours [`INSTANCE_ID_CACHE_TTL_SECS`]. + instance_id: OnceCell>, } impl SysomProvider { @@ -115,7 +117,6 @@ impl SysomProvider { configured_endpoint: &str, ) -> Self { let is_sts = security_token.is_some(); - let instance_id = resolve_instance_id(); Self { configured_endpoint: configured_endpoint.to_string(), credentials: RwLock::new(SysomCredentials { @@ -125,12 +126,11 @@ impl SysomProvider { }), is_sts, cancelled: Arc::new(AtomicBool::new(false)), - instance_id, + instance_id: OnceCell::new(), } } pub fn from_ecs_ram_role(configured_endpoint: &str) -> Self { - let instance_id = resolve_instance_id(); Self { configured_endpoint: configured_endpoint.to_string(), credentials: RwLock::new(SysomCredentials { @@ -140,7 +140,7 @@ impl SysomProvider { }), is_sts: true, cancelled: Arc::new(AtomicBool::new(false)), - instance_id, + instance_id: OnceCell::new(), } } @@ -190,7 +190,7 @@ impl SysomProvider { } } - if let Some(ref id) = self.instance_id { + if let Some(Some(id)) = self.instance_id.get() { inner["instance_id"] = serde_json::json!(id); } @@ -269,6 +269,7 @@ impl ContentGenerator for SysomProvider { config: &GenerateConfig, ) -> Result { self.cancelled.store(false, Ordering::SeqCst); + self.instance_id.get_or_init(resolve_instance_id).await; let body = self.build_request_body(messages, tools, config); let body_bytes = serde_json::to_vec(&body).map_err(|e| format!("JSON serialize: {e}"))?; @@ -409,47 +410,21 @@ impl SysomProvider { Ok(sysom_event_stream(Box::pin(byte_stream), cancelled)) } - /// Check if an error indicates STS credential expiration. - /// Refresh STS credentials from ECS metadata service. + /// Refresh only with credentials accepted by the same validator as auth preflight. async fn refresh_sts_credentials(&self) -> bool { - let url = format!( - "{}/latest/meta-data/ram/security-credentials/{}", - ECS_METADATA_ENDPOINT, ECS_RAM_ROLE_NAME - ); - let client = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(2)) - .timeout(std::time::Duration::from_secs(5)) - .build() - .unwrap_or_default(); - - let resp = match client.get(&url).send().await { - Ok(r) => r, - Err(e) => { - tracing::warn!("STS refresh failed: {e}"); - return false; + match ecs_metadata::load_role_credentials().await { + Ok(ecs_metadata::RoleCredentials::Ready(credentials)) => { + *self.credentials.write().unwrap() = credentials; + true + } + Ok(ecs_metadata::RoleCredentials::NotReady(reason)) => { + tracing::debug!(reason = reason.as_str(), "STS credentials not ready"); + false } - }; - let body: Value = match resp.json().await { - Ok(v) => v, - Err(e) => { - tracing::warn!("STS refresh parse failed: {e}"); - return false; + Err(error) => { + tracing::warn!(error_code = error.code(), "STS metadata refresh failed"); + false } - }; - - let ak = body.get("AccessKeyId").and_then(|v| v.as_str()); - let sk = body.get("AccessKeySecret").and_then(|v| v.as_str()); - let token = body.get("SecurityToken").and_then(|v| v.as_str()); - - if let (Some(ak), Some(sk), Some(token)) = (ak, sk, token) { - let mut creds = self.credentials.write().unwrap(); - creds.access_key_id = ak.to_string(); - creds.access_key_secret = sk.to_string(); - creds.security_token = Some(token.to_string()); - true - } else { - tracing::warn!("STS refresh: missing fields in response"); - false } } } @@ -467,19 +442,26 @@ fn is_sts_error(error: &str) -> bool { /// Resolve instance_id: read from local cache if valid, otherwise fetch from /// ECS metadata service and update the cache. -fn resolve_instance_id() -> Option { +async fn resolve_instance_id() -> Option { let config_dir = dirs::home_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join(".copilot-shell"); let cache_path = config_dir.join("instance_id"); + #[cfg(test)] + let cache_path = metadata_tests::TEST_CACHE_PATH + .try_with(Clone::clone) + .unwrap_or(cache_path); + resolve_instance_id_cached(&cache_path).await +} +async fn resolve_instance_id_cached(cache_path: &std::path::Path) -> Option { // Try reading from cache - if let Ok(metadata) = std::fs::metadata(&cache_path) { + if let Ok(metadata) = std::fs::metadata(cache_path) { if let Ok(modified) = metadata.modified() { let age = modified.elapsed().unwrap_or(Duration::from_secs(u64::MAX)); if age < Duration::from_secs(INSTANCE_ID_CACHE_TTL_SECS) { // Cache is still valid - let content = std::fs::read_to_string(&cache_path).unwrap_or_default(); + let content = std::fs::read_to_string(cache_path).unwrap_or_default(); let trimmed = content.trim(); if trimmed.is_empty() { // Empty file = previously failed to fetch @@ -491,55 +473,32 @@ fn resolve_instance_id() -> Option { } // Cache miss or expired — fetch from metadata service - let instance_id = fetch_instance_id_from_metadata(); + let instance_id = ecs_metadata::fetch_instance_id().await.ok(); // Write cache (create parent dir if needed) if let Some(parent) = cache_path.parent() { let _ = std::fs::create_dir_all(parent); } let content = instance_id.as_deref().unwrap_or(""); - if let Err(e) = std::fs::write(&cache_path, content) { + if let Err(e) = std::fs::write(cache_path, content) { tracing::debug!("failed to write instance_id cache: {e}"); } instance_id } -/// Fetch instance-id from ECS metadata service via raw TCP. -/// Returns None if not running on ECS or if the request fails. -fn fetch_instance_id_from_metadata() -> Option { - fetch_ecs_metadata_text("/latest/meta-data/instance-id").and_then(|body| { - let instance_id = body.trim(); - if instance_id.starts_with("i-") { - Some(instance_id.to_string()) - } else { - None - } - }) -} - -pub fn detect_ecs_auth_challenge() -> Option { - let instance_id = fetch_instance_id_from_metadata()?; - let region_id = fetch_ecs_region_id().unwrap_or_else(|| "cn-hangzhou".to_string()); - Some(EcsAuthChallenge { +/// Detect ECS identity without treating denied or malformed metadata as non-ECS. +/// +/// # Errors +/// Returns a safe error for HTTP rejection, invalid metadata, or any identity fetch +/// failure after acquiring a valid token. Only an unreachable or timed-out session +/// start is treated as non-ECS so manual authentication remains available. +pub async fn detect_ecs_auth_challenge() -> Result, ProbeError> { + let identity = ecs_metadata::fetch_identity().await?; + Ok(identity.map(|(instance_id, region_id)| EcsAuthChallenge { console_url: generate_console_url(&instance_id, ®ion_id), instance_id, - }) -} - -pub fn ecs_ram_role_credentials_available() -> bool { - let path = format!( - "/latest/meta-data/ram/security-credentials/{}", - ECS_RAM_ROLE_NAME - ); - fetch_ecs_metadata_text(&path) - .map(|body| body.contains("AccessKeyId") && body.contains("SecurityToken")) - .unwrap_or(false) -} - -fn fetch_ecs_region_id() -> Option { - let zone_id = fetch_ecs_metadata_text("/latest/meta-data/zone-id")?; - region_id_from_zone_id(zone_id.trim()) + })) } fn region_id_from_zone_id(zone_id: &str) -> Option { @@ -561,23 +520,6 @@ fn generate_console_url(instance_id: &str, region_id: &str) -> String { .replace("{instanceId}", instance_id) } -fn fetch_ecs_metadata_text(path: &str) -> Option { - let addr: SocketAddr = "100.100.100.200:80".parse().ok()?; - let mut stream = TcpStream::connect_timeout(&addr, METADATA_CONNECT_TIMEOUT).ok()?; - stream.set_read_timeout(Some(METADATA_READ_TIMEOUT)).ok()?; - stream - .set_write_timeout(Some(METADATA_CONNECT_TIMEOUT)) - .ok()?; - - let request = format!("GET {path} HTTP/1.0\r\nHost: 100.100.100.200\r\n\r\n"); - stream.write_all(request.as_bytes()).ok()?; - - let mut response = String::new(); - stream.read_to_string(&mut response).ok()?; - - response.split("\r\n\r\n").nth(1).map(str::to_string) -} - // --------------------------------------------------------------------------- // Crypto helpers // --------------------------------------------------------------------------- @@ -597,3 +539,7 @@ fn hex_hmac_sha256(key: &[u8], data: &[u8]) -> String { #[cfg(test)] #[path = "sysom/tests.rs"] mod tests; + +#[cfg(test)] +#[path = "sysom/metadata_tests.rs"] +mod metadata_tests; diff --git a/src/cosh-ng/crates/cosh-core/src/provider/sysom/ecs_metadata.rs b/src/cosh-ng/crates/cosh-core/src/provider/sysom/ecs_metadata.rs new file mode 100644 index 0000000000..22ed050c62 --- /dev/null +++ b/src/cosh-ng/crates/cosh-core/src/provider/sysom/ecs_metadata.rs @@ -0,0 +1,352 @@ +//! Bounded ECS credential checks expose classifications, never metadata or credentials. + +use std::fmt; +use std::time::Duration; + +use chrono::{DateTime, Utc}; +use reqwest::header::HeaderValue; +use serde_json::Value; +use tokio::time::{timeout_at, Instant}; + +use super::{SysomCredentials, ECS_METADATA_ENDPOINT, ECS_RAM_ROLE_NAME, METADATA_CONNECT_TIMEOUT}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(3); +const MAX_RESPONSE_BYTES: usize = 64 * 1024; +const MAX_TOKEN_BYTES: usize = 4096; +const TOKEN_HEADER: &str = "x-aliyun-ecs-metadata-token"; +const TOKEN_TTL_HEADER: &str = "x-aliyun-ecs-metadata-token-ttl-seconds"; +const TOKEN_TTL: &str = "60"; + +// A task-local loopback override cannot escape into production or another test. +#[cfg(test)] +tokio::task_local! { + pub(super) static TEST_ENDPOINT: String; +} + +fn metadata_endpoint() -> String { + #[cfg(test)] + if let Ok(endpoint) = TEST_ENDPOINT.try_with(Clone::clone) { + return endpoint; + } + ECS_METADATA_ENDPOINT.to_string() +} + +/// Availability of complete, unexpired metadata credentials, not SysOM permissions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CredentialStatus { + /// The specified role supplied structurally valid, unexpired credentials. + Ready, + /// The role or refreshed credentials are not yet available. + NotReady(NotReadyReason), +} + +/// Conditions for which waiting for role configuration or refresh is appropriate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotReadyReason { + /// The specified role endpoint returned HTTP 404. + RoleMissing, + /// Complete credentials have reached their expiration time. + CredentialsExpired, +} + +impl NotReadyReason { + /// Stable reason sent over the registry protocol. + pub fn as_str(self) -> &'static str { + match self { + Self::RoleMissing => "role_missing", + Self::CredentialsExpired => "credentials_expired", + } + } +} + +/// Safe metadata failures that do not retain upstream errors or response bodies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProbeError { + /// Metadata access was rejected by HTTP 401 or 403. + AccessDenied, + /// The response was too large or did not contain valid credentials. + InvalidResponse, + /// The metadata transport failed. + Unreachable, + /// The metadata request exceeded its deadline. + Timeout, + /// Metadata returned another non-success HTTP status. + Http, +} + +impl ProbeError { + /// Stable error code shared by verify and configure preflight. + pub fn code(&self) -> &'static str { + match self { + Self::AccessDenied => "metadata_access_denied", + Self::InvalidResponse => "invalid_metadata_response", + Self::Unreachable => "metadata_unreachable", + Self::Timeout => "metadata_timeout", + Self::Http => "metadata_http_error", + } + } +} + +impl fmt::Display for ProbeError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::AccessDenied => "Unable to access ECS instance metadata.", + Self::InvalidResponse => "ECS instance metadata returned an invalid response.", + Self::Unreachable => "Unable to reach ECS instance metadata.", + Self::Timeout => "ECS instance metadata request timed out.", + Self::Http => "ECS instance metadata returned an HTTP error.", + }) + } +} + +impl std::error::Error for ProbeError {} + +// Credentials never leave this owner and the signing provider; public probes get only status. +pub(super) enum RoleCredentials { + Ready(SysomCredentials), + NotReady(NotReadyReason), +} + +impl RoleCredentials { + fn status(&self) -> CredentialStatus { + match self { + Self::Ready(_) => CredentialStatus::Ready, + Self::NotReady(reason) => CredentialStatus::NotReady(*reason), + } + } +} + +#[cfg(test)] +pub(super) fn classify_credentials( + status: u16, + body: &[u8], + now: DateTime, +) -> Result { + validate_credentials(status, body, now).map(|credentials| credentials.status()) +} + +fn validate_credentials( + status: u16, + body: &[u8], + now: DateTime, +) -> Result { + if status == 404 { + return Ok(RoleCredentials::NotReady(NotReadyReason::RoleMissing)); + } + check_status(status)?; + if body.len() > MAX_RESPONSE_BYTES { + return Err(ProbeError::InvalidResponse); + } + let body: Value = serde_json::from_slice(body).map_err(|_| ProbeError::InvalidResponse)?; + if body.get("Code").and_then(Value::as_str) != Some("Success") { + return Err(ProbeError::InvalidResponse); + } + let field = |name| { + body.get(name) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or(ProbeError::InvalidResponse) + }; + let credentials = SysomCredentials { + access_key_id: field("AccessKeyId")?.to_string(), + access_key_secret: field("AccessKeySecret")?.to_string(), + security_token: Some(field("SecurityToken")?.to_string()), + }; + let expiration = DateTime::parse_from_rfc3339(field("Expiration")?) + .map_err(|_| ProbeError::InvalidResponse)?; + if expiration <= now { + Ok(RoleCredentials::NotReady( + NotReadyReason::CredentialsExpired, + )) + } else { + Ok(RoleCredentials::Ready(credentials)) + } +} + +/// Probe only the fixed ECS metadata address and SysOM role, without returning secrets. +/// +/// The three-second total deadline includes token acquisition and all body reads. +/// No redirects, proxies, retries, IMDSv1 fallback, or role enumeration are used. +/// +/// # Errors +/// Returns a safe classification for transport, HTTP, or credential format failures. +pub async fn probe_ecs_ram_role() -> Result { + load_role_credentials() + .await + .map(|credentials| credentials.status()) +} + +pub(super) async fn load_role_credentials() -> Result { + load_role_credentials_at(&metadata_endpoint()).await +} + +async fn load_role_credentials_at(base_url: &str) -> Result { + let session = MetadataSession::start(base_url).await?; + let (status, body) = session + .get(&format!( + "/latest/meta-data/ram/security-credentials/{ECS_RAM_ROLE_NAME}" + )) + .await?; + validate_credentials(status, &body, Utc::now()) +} + +// Only tests may inject a URL; the role path is always fixed by the owner. +#[cfg(test)] +pub(super) async fn probe_url(url: &str) -> Result { + let url = reqwest::Url::parse(url).map_err(|_| ProbeError::InvalidResponse)?; + let base_url = url.origin().ascii_serialization(); + load_role_credentials_at(&base_url) + .await + .map(|credentials| credentials.status()) +} + +pub(super) async fn fetch_instance_id() -> Result { + MetadataSession::start(&metadata_endpoint()) + .await? + .instance_id() + .await +} + +pub(super) async fn fetch_identity() -> Result, ProbeError> { + let session = match MetadataSession::start(&metadata_endpoint()).await { + Ok(session) => session, + Err(ProbeError::Unreachable | ProbeError::Timeout) => return Ok(None), + Err(error) => return Err(error), + }; + // A valid token establishes ECS; later metadata failures must not select manual auth. + let instance_id = session.instance_id().await?; + let zone_id = session.text("/latest/meta-data/zone-id").await?; + let region_id = super::region_id_from_zone_id(&zone_id).ok_or(ProbeError::InvalidResponse)?; + Ok(Some((instance_id, region_id))) +} + +/// A short-lived IMDSv2 session shares one deadline across token and GET requests. +/// The token is neither cached on disk nor passed outside the metadata owner. +struct MetadataSession { + client: reqwest::Client, + base_url: String, + token: HeaderValue, + deadline: Instant, +} + +impl MetadataSession { + async fn start(base_url: &str) -> Result { + let deadline = Instant::now() + REQUEST_TIMEOUT; + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .retry(reqwest::retry::never()) + .connect_timeout(METADATA_CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|_| ProbeError::Unreachable)?; + let (status, body) = read_response( + client + .put(format!("{base_url}/latest/api/token")) + .header(TOKEN_TTL_HEADER, TOKEN_TTL), + deadline, + MAX_TOKEN_BYTES, + ) + .await?; + // A missing token endpoint is not a missing role and never permits IMDSv1. + check_status(status)?; + if body.is_empty() || !body.iter().all(|byte| byte.is_ascii_graphic()) { + return Err(ProbeError::InvalidResponse); + } + let mut token = HeaderValue::from_bytes(&body).map_err(|_| ProbeError::InvalidResponse)?; + token.set_sensitive(true); + Ok(Self { + client, + base_url: base_url.to_string(), + token, + deadline, + }) + } + + async fn instance_id(&self) -> Result { + let id = self.text("/latest/meta-data/instance-id").await?; + if id.starts_with("i-") && id.len() > 2 { + Ok(id) + } else { + Err(ProbeError::InvalidResponse) + } + } + + async fn text(&self, path: &str) -> Result { + let (status, body) = self.get(path).await?; + check_status(status)?; + let text = std::str::from_utf8(&body) + .map_err(|_| ProbeError::InvalidResponse)? + .trim(); + // Identity fields become URL components, so reject delimiters and error pages. + if text.is_empty() + || !text + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(ProbeError::InvalidResponse); + } + Ok(text.to_string()) + } + + async fn get(&self, path: &str) -> Result<(u16, Vec), ProbeError> { + read_response( + self.client + .get(format!("{}{path}", self.base_url)) + .header(TOKEN_HEADER, self.token.clone()), + self.deadline, + MAX_RESPONSE_BYTES, + ) + .await + } +} + +async fn read_response( + request: reqwest::RequestBuilder, + deadline: Instant, + limit: usize, +) -> Result<(u16, Vec), ProbeError> { + timeout_at(deadline, async { + let mut response = request.send().await.map_err(transport_error)?; + let status = response.status(); + // Never read or retain an untrusted error body (which can echo secrets). + if !status.is_success() { + return Ok((status.as_u16(), Vec::new())); + } + if response + .content_length() + .is_some_and(|length| length > limit as u64) + { + return Err(ProbeError::InvalidResponse); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(transport_error)? { + if chunk.len() > limit - body.len() { + return Err(ProbeError::InvalidResponse); + } + body.extend_from_slice(&chunk); + } + Ok((status.as_u16(), body)) + }) + .await + .unwrap_or(Err(ProbeError::Timeout)) +} + +fn check_status(status: u16) -> Result<(), ProbeError> { + match status { + 200..=299 => Ok(()), + 401 | 403 => Err(ProbeError::AccessDenied), + _ => Err(ProbeError::Http), + } +} + +fn transport_error(error: reqwest::Error) -> ProbeError { + if error.is_timeout() { + ProbeError::Timeout + } else if error.is_connect() { + ProbeError::Unreachable + } else { + // A connected peer sending malformed HTTP is not evidence of non-ECS. + ProbeError::InvalidResponse + } +} diff --git a/src/cosh-ng/crates/cosh-core/src/provider/sysom/metadata_tests.rs b/src/cosh-ng/crates/cosh-core/src/provider/sysom/metadata_tests.rs new file mode 100644 index 0000000000..7138e54ad7 --- /dev/null +++ b/src/cosh-ng/crates/cosh-core/src/provider/sysom/metadata_tests.rs @@ -0,0 +1,511 @@ +//! IMDSv2 transport and end-to-end core auth regressions; all services are loopback. + +use super::ecs_metadata::TEST_ENDPOINT; +use super::*; +use std::path::PathBuf; +use std::sync::Mutex; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +const TOKEN_PATH: &str = "/latest/api/token"; +const ROLE_PATH: &str = "/latest/meta-data/ram/security-credentials/AliyunECSInstanceForSysomRole"; +const INSTANCE_PATH: &str = "/latest/meta-data/instance-id"; +const ZONE_PATH: &str = "/latest/meta-data/zone-id"; +const IMDS_TOKEN: &str = "synthetic-imds-secret"; + +tokio::task_local! { + pub(super) static TEST_CACHE_PATH: PathBuf; +} + +struct Reply { + path: &'static str, + response: String, + delay: Duration, + close_listener: bool, +} + +impl Reply { + fn new(path: &'static str, status: u16, body: &str) -> Self { + Self { + path, + response: format!( + "HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ), + delay: Duration::ZERO, + close_listener: false, + } + } + + fn token() -> Self { + Self::new(TOKEN_PATH, 200, IMDS_TOKEN) + } +} + +struct Fixture { + base_url: String, + requests: Arc>>, + task: tokio::task::JoinHandle>, +} + +impl Fixture { + async fn start(replies: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let task = tokio::spawn(async move { + let mut replies = replies.into_iter(); + loop { + let (mut socket, _) = listener.accept().await?; + let request = read_request(&mut socket).await?; + captured.lock().unwrap().push(request.clone()); + let Some(reply) = replies.next() else { + socket + .write_all(Reply::new("", 401, "no fallback").response.as_bytes()) + .await?; + continue; + }; + let method = match reply.path { + TOKEN_PATH => "PUT", + API_PATH => "POST", + _ => "GET", + }; + assert!(request.starts_with(&format!("{method} {} HTTP/1.1\r\n", reply.path))); + if reply.path == TOKEN_PATH { + assert!(request.contains("x-aliyun-ecs-metadata-token-ttl-seconds: 60\r\n")); + assert!(!request.contains("x-aliyun-ecs-metadata-token:")); + } else if reply.path == API_PATH { + assert!(!request.contains(IMDS_TOKEN)); + } else { + assert!( + request.contains(&format!("x-aliyun-ecs-metadata-token: {IMDS_TOKEN}\r\n")) + ); + } + if !reply.delay.is_zero() { + tokio::time::sleep(reply.delay).await; + } + if reply.close_listener { + // Close before replying so the next request fails to connect. + drop(listener); + return socket.write_all(reply.response.as_bytes()).await; + } + socket.write_all(reply.response.as_bytes()).await?; + socket.shutdown().await?; + } + }); + Self { + base_url, + requests, + task, + } + } + + async fn finish(self, count: usize) -> Vec { + self.task.abort(); + match self.task.await { + Err(error) => assert!(error.is_cancelled(), "fixture task panicked"), + Ok(Err(error)) => assert!(matches!( + error.kind(), + std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset + )), + Ok(Ok(())) => {} + } + let requests = self.requests.lock().unwrap().clone(); + assert_eq!( + requests.len(), + count, + "unexpected retry, fallback or missing metadata request" + ); + requests + } +} + +async fn read_request(socket: &mut TcpStream) -> std::io::Result { + let mut bytes = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let count = socket.read(&mut buffer).await?; + if count == 0 { + return Err(std::io::ErrorKind::UnexpectedEof.into()); + } + bytes.extend_from_slice(&buffer[..count]); + if let Some(end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..end]); + let length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::().ok()) + }) + .unwrap_or(0); + if bytes.len() >= end + 4 + length { + return Ok(String::from_utf8(bytes).unwrap()); + } + } + } +} + +fn credentials() -> Value { + serde_json::json!({ + "Code": "Success", "AccessKeyId": "synthetic-ak", "AccessKeySecret": "synthetic-sk", + "SecurityToken": "synthetic-sts", "Expiration": "2100-01-01T00:00:00Z" + }) +} + +#[test] +fn ecs_metadata_constructors_do_not_resolve_instance_id() { + for provider in [ + SysomProvider::new("ak", "sk", None, ""), + SysomProvider::from_ecs_ram_role(""), + ] { + assert!(provider.instance_id.get().is_none()); + } +} + +#[tokio::test] +async fn ecs_metadata_prepare_probe_and_sts_share_transport() { + let body = credentials().to_string(); + let fixture = Fixture::start(vec![ + Reply::token(), + Reply::new(INSTANCE_PATH, 200, "i-test123\n"), + Reply::new(ZONE_PATH, 200, "cn-shanghai-g\n"), + Reply::token(), + Reply::new(ROLE_PATH, 200, &body), + Reply::token(), + Reply::new(ROLE_PATH, 200, &body), + ]) + .await; + TEST_ENDPOINT + .scope(fixture.base_url.clone(), async { + let prepared = detect_ecs_auth_challenge().await.unwrap().unwrap(); + assert_eq!(prepared.instance_id, "i-test123"); + assert_eq!( + prepared.console_url, + "https://alinux.console.aliyun.com/cn-shanghai/guide/cosh?instance=i-test123" + ); + assert_eq!(probe_ecs_ram_role().await, Ok(CredentialStatus::Ready)); + let provider = SysomProvider::from_ecs_ram_role(""); + assert!(provider.refresh_sts_credentials().await); + let creds = provider.credentials.read().unwrap(); + assert_eq!(creds.access_key_id, "synthetic-ak"); + assert_eq!(creds.security_token.as_deref(), Some("synthetic-sts")); + }) + .await; + fixture.finish(7).await; +} + +#[tokio::test] +async fn ecs_metadata_malformed_http_is_not_manual_auth() { + let mut reply = Reply::token(); + reply.response = "not-an-http-response\r\n\r\n".to_string(); + let fixture = Fixture::start(vec![reply]).await; + let result = TEST_ENDPOINT + .scope(fixture.base_url.clone(), detect_ecs_auth_challenge()) + .await; + assert_eq!(result, Err(ProbeError::InvalidResponse)); + fixture.finish(1).await; +} + +#[tokio::test] +async fn ecs_metadata_token_redirect_does_not_forward_or_downgrade() { + use futures::FutureExt; + let target = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut reply = Reply::token(); + reply.response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{}/steal\r\nContent-Length: 0\r\n\r\n", + target.local_addr().unwrap() + ); + let fixture = Fixture::start(vec![reply]).await; + let result = TEST_ENDPOINT + .scope(fixture.base_url.clone(), probe_ecs_ram_role()) + .await; + assert_eq!(result, Err(ProbeError::Http)); + fixture.finish(1).await; + assert!(target.accept().now_or_never().is_none()); +} + +#[tokio::test] +async fn ecs_metadata_prepare_timeout_is_manual_auth() { + let mut reply = Reply::token(); + reply.delay = Duration::from_secs(4); + let fixture = Fixture::start(vec![reply]).await; + let result = TEST_ENDPOINT + .scope(fixture.base_url.clone(), detect_ecs_auth_challenge()) + .await; + assert_eq!(result, Ok(None)); + fixture.finish(1).await; +} + +#[tokio::test] +async fn ecs_metadata_prepare_instance_timeout_is_not_manual() { + let mut instance = Reply::new(INSTANCE_PATH, 200, "i-test"); + instance.delay = Duration::from_secs(4); + let fixture = Fixture::start(vec![Reply::token(), instance]).await; + let result = TEST_ENDPOINT + .scope(fixture.base_url.clone(), detect_ecs_auth_challenge()) + .await; + fixture.finish(2).await; + assert_eq!(result, Err(ProbeError::Timeout)); +} + +#[tokio::test] +async fn ecs_metadata_prepare_zone_timeout_is_not_manual() { + let mut zone = Reply::new(ZONE_PATH, 200, "cn-shanghai-g"); + zone.delay = Duration::from_secs(4); + let fixture = Fixture::start(vec![ + Reply::token(), + Reply::new(INSTANCE_PATH, 200, "i-test"), + zone, + ]) + .await; + let result = TEST_ENDPOINT + .scope(fixture.base_url.clone(), detect_ecs_auth_challenge()) + .await; + fixture.finish(3).await; + assert_eq!(result, Err(ProbeError::Timeout)); +} + +#[tokio::test] +async fn ecs_metadata_prepare_instance_unreachable_is_not_manual() { + let mut token = Reply::token(); + token.close_listener = true; + let fixture = Fixture::start(vec![token]).await; + let result = TEST_ENDPOINT + .scope(fixture.base_url.clone(), detect_ecs_auth_challenge()) + .await; + fixture.finish(1).await; + assert_eq!(result, Err(ProbeError::Unreachable)); +} + +#[tokio::test] +async fn ecs_metadata_prepare_zone_unreachable_is_not_manual() { + let mut instance = Reply::new(INSTANCE_PATH, 200, "i-test"); + instance.close_listener = true; + let fixture = Fixture::start(vec![Reply::token(), instance]).await; + let result = TEST_ENDPOINT + .scope(fixture.base_url.clone(), detect_ecs_auth_challenge()) + .await; + fixture.finish(2).await; + assert_eq!(result, Err(ProbeError::Unreachable)); +} + +#[tokio::test] +async fn ecs_metadata_token_failures_never_fall_back_or_leak() { + for (status, body, expected) in [ + (401, IMDS_TOKEN.to_string(), ProbeError::AccessDenied), + (403, IMDS_TOKEN.to_string(), ProbeError::AccessDenied), + (404, IMDS_TOKEN.to_string(), ProbeError::Http), + (405, IMDS_TOKEN.to_string(), ProbeError::Http), + (500, IMDS_TOKEN.to_string(), ProbeError::Http), + (200, String::new(), ProbeError::InvalidResponse), + (200, " ".to_string(), ProbeError::InvalidResponse), + ( + 200, + "injected\r\nheader".to_string(), + ProbeError::InvalidResponse, + ), + (200, "x".repeat(4097), ProbeError::InvalidResponse), + ] { + let fixture = Fixture::start(vec![Reply::new(TOKEN_PATH, status, &body)]).await; + let result = ecs_metadata::TEST_ENDPOINT + .scope(fixture.base_url.clone(), probe_ecs_ram_role()) + .await; + assert_eq!(result, Err(expected)); + assert!(!format!("{expected:?} {expected}").contains(IMDS_TOKEN)); + fixture.finish(1).await; + } +} + +#[tokio::test] +async fn ecs_metadata_prepare_rejects_denied_or_invalid_identity() { + for (path, status, body, code) in [ + (TOKEN_PATH, 401, IMDS_TOKEN, "metadata_access_denied"), + (TOKEN_PATH, 403, IMDS_TOKEN, "metadata_access_denied"), + (TOKEN_PATH, 200, "", "invalid_metadata_response"), + (INSTANCE_PATH, 401, IMDS_TOKEN, "metadata_access_denied"), + ( + INSTANCE_PATH, + 200, + "not-an-instance", + "invalid_metadata_response", + ), + ( + INSTANCE_PATH, + 200, + "i-test?token=secret", + "invalid_metadata_response", + ), + (ZONE_PATH, 403, IMDS_TOKEN, "metadata_access_denied"), + (ZONE_PATH, 200, "", "invalid_metadata_response"), + ] { + let mut replies = Vec::new(); + if path != TOKEN_PATH { + replies.push(Reply::token()); + } + if path == ZONE_PATH { + replies.push(Reply::new(INSTANCE_PATH, 200, "i-test")); + } + replies.push(Reply::new(path, status, body)); + let count = replies.len(); + let fixture = Fixture::start(replies).await; + let response = TEST_ENDPOINT + .scope(fixture.base_url.clone(), detect_ecs_auth_challenge()) + .await; + assert_eq!(response.unwrap_err().code(), code); + fixture.finish(count).await; + } +} + +#[tokio::test] +async fn ecs_metadata_prepare_unreachable_is_manual() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let response = TEST_ENDPOINT + .scope(base_url, detect_ecs_auth_challenge()) + .await; + assert_eq!(response, Ok(None)); +} + +#[tokio::test] +async fn ecs_metadata_runtime_rejects_every_nonready_probe_result() { + let mut invalid = credentials(); + invalid["Code"] = serde_json::json!("Failure"); + let mut expired = credentials(); + expired["Expiration"] = serde_json::json!("2000-01-01T00:00:00Z"); + let mut empty = credentials(); + empty["SecurityToken"] = serde_json::json!(" "); + for (status, body, code) in [ + (200, invalid, "invalid_metadata_response"), + (200, expired, "credential_source_unavailable"), + (200, empty, "invalid_metadata_response"), + (404, credentials(), "credential_source_unavailable"), + (401, credentials(), "metadata_access_denied"), + ] { + let fixture = Fixture::start( + (0..2) + .flat_map(|_| { + [ + Reply::token(), + Reply::new(ROLE_PATH, status, &body.to_string()), + ] + }) + .collect(), + ) + .await; + ecs_metadata::TEST_ENDPOINT + .scope(fixture.base_url.clone(), async { + match probe_ecs_ram_role().await { + Err(error) => assert_eq!(error.code(), code), + Ok(CredentialStatus::NotReady(_)) => { + assert_eq!(code, "credential_source_unavailable") + } + Ok(CredentialStatus::Ready) => panic!("invalid credentials accepted"), + } + let provider = SysomProvider::new("old-ak", "old-sk", Some("old-sts"), ""); + assert!(!provider.refresh_sts_credentials().await); + assert_eq!( + provider + .credentials + .read() + .unwrap() + .security_token + .as_deref(), + Some("old-sts") + ); + }) + .await; + fixture.finish(4).await; + } +} + +#[tokio::test] +async fn ecs_metadata_token_and_get_share_total_deadline() { + let mut token = Reply::token(); + token.delay = Duration::from_millis(1800); + let mut role = Reply::new(ROLE_PATH, 200, &credentials().to_string()); + role.delay = Duration::from_millis(1800); + let fixture = Fixture::start(vec![token, role]).await; + let result = ecs_metadata::TEST_ENDPOINT + .scope(fixture.base_url.clone(), probe_ecs_ram_role()) + .await; + assert_eq!(result, Err(ProbeError::Timeout)); + fixture.finish(2).await; +} + +#[tokio::test] +async fn ecs_metadata_generate_initializes_body_and_refreshes_sts_once() { + let mut refreshed = credentials(); + refreshed["AccessKeyId"] = serde_json::json!("refreshed-ak"); + let fixture = Fixture::start(vec![ + Reply::token(), + Reply::new(INSTANCE_PATH, 200, "i-live"), + Reply::token(), + Reply::new(ROLE_PATH, 200, &credentials().to_string()), + Reply::new(API_PATH, 403, "InvalidSecurityToken"), + Reply::token(), + Reply::new(ROLE_PATH, 200, &refreshed.to_string()), + Reply::new(API_PATH, 403, "SecurityTokenExpired"), + ]) + .await; + let dir = tempfile::tempdir().unwrap(); + let cache_path = dir.path().join("instance_id"); + let provider = SysomProvider::from_ecs_ram_role(&fixture.base_url); + let result = TEST_CACHE_PATH + .scope( + cache_path.clone(), + ecs_metadata::TEST_ENDPOINT.scope( + fixture.base_url.clone(), + provider.generate(&[], &[], &GenerateConfig::default()), + ), + ) + .await; + assert!(result.is_err()); + assert_eq!( + provider.instance_id.get(), + Some(&Some("i-live".to_string())) + ); + assert_eq!(std::fs::read_to_string(cache_path).unwrap(), "i-live"); + let requests = fixture.finish(8).await; + for index in [4, 7] { + let body: Value = + serde_json::from_str(requests[index].split_once("\r\n\r\n").unwrap().1).unwrap(); + let inner: Value = serde_json::from_str(body["llmParamString"].as_str().unwrap()).unwrap(); + assert_eq!(inner["instance_id"], "i-live"); + } + assert!(requests[4].contains("Credential=synthetic-ak,")); + assert!(requests[7].contains("Credential=refreshed-ak,")); +} + +#[tokio::test] +async fn ecs_metadata_disk_cache_preserves_positive_negative_and_expiry() { + let dir = tempfile::tempdir().unwrap(); + let cache = dir.path().join("instance_id"); + let fixture = Fixture::start(vec![ + Reply::token(), + Reply::new(INSTANCE_PATH, 200, "i-new"), + ]) + .await; + ecs_metadata::TEST_ENDPOINT + .scope(fixture.base_url.clone(), async { + for (content, expected) in [("i-cached\n", Some("i-cached".to_string())), ("", None)] { + std::fs::write(&cache, content).unwrap(); + assert_eq!(resolve_instance_id_cached(&cache).await, expected); + } + assert!(fixture.requests.lock().unwrap().is_empty()); + let file = std::fs::File::options().write(true).open(&cache).unwrap(); + file.set_times(std::fs::FileTimes::new().set_modified( + std::time::SystemTime::now() - Duration::from_secs(INSTANCE_ID_CACHE_TTL_SECS + 1), + )) + .unwrap(); + assert_eq!( + resolve_instance_id_cached(&cache).await, + Some("i-new".to_string()) + ); + assert_eq!(std::fs::read_to_string(&cache).unwrap(), "i-new"); + }) + .await; + fixture.finish(2).await; +} diff --git a/src/cosh-ng/crates/cosh-core/src/provider/sysom/tests.rs b/src/cosh-ng/crates/cosh-core/src/provider/sysom/tests.rs index f158f5f6de..a9884c506f 100644 --- a/src/cosh-ng/crates/cosh-core/src/provider/sysom/tests.rs +++ b/src/cosh-ng/crates/cosh-core/src/provider/sysom/tests.rs @@ -1,3 +1,4 @@ +use super::ecs_metadata::{classify_credentials, CredentialStatus, ProbeError}; use super::*; use futures::FutureExt; @@ -221,7 +222,7 @@ fn build_request_preserves_user_provided_secrets() { }), is_sts: false, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - instance_id: None, + instance_id: OnceCell::new(), }; let secret = "short-provider-secret"; let messages = vec![Message::user(&format!("api_key={secret}"))]; @@ -243,7 +244,7 @@ fn test_provider() -> SysomProvider { }), is_sts: false, cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - instance_id: None, + instance_id: OnceCell::new(), } } @@ -292,3 +293,449 @@ fn extra_params_may_still_lower_the_wire_output_cap() { assert_eq!(inner["max_tokens"], 512); } + +fn ecs_metadata_now() -> chrono::DateTime { + "2026-09-15T12:00:00Z" + .parse() + .expect("fixed UTC test timestamp") +} + +fn ecs_metadata_credentials() -> serde_json::Value { + serde_json::json!({ + "Code": "Success", + "AccessKeyId": "synthetic-ak", + "AccessKeySecret": "synthetic-sk", + "SecurityToken": "synthetic-token", + "Expiration": "2026-09-15T13:00:00Z", + }) +} + +#[test] +fn ecs_metadata_ready_for_complete_unexpired_credentials() { + for expiration in ["2026-09-15T12:00:01Z", "2026-09-15T13:00:00Z"] { + let mut body = ecs_metadata_credentials(); + body["Expiration"] = serde_json::json!(expiration); + assert!(matches!( + classify_credentials(200, body.to_string().as_bytes(), ecs_metadata_now()), + Ok(CredentialStatus::Ready) + )); + } +} + +#[test] +fn ecs_metadata_not_ready_for_missing_role() { + for body in [ + ecs_metadata_credentials().to_string(), + "not JSON".to_string(), + ] { + assert!(matches!( + classify_credentials(404, body.as_bytes(), ecs_metadata_now()), + Ok(CredentialStatus::NotReady(NotReadyReason::RoleMissing)) + )); + } +} + +#[test] +fn ecs_metadata_not_ready_at_or_after_expiration() { + for expiration in ["2026-09-15T12:00:00Z", "2026-09-15T11:59:59Z"] { + let mut body = ecs_metadata_credentials(); + body["Expiration"] = serde_json::json!(expiration); + assert!(matches!( + classify_credentials(200, body.to_string().as_bytes(), ecs_metadata_now()), + Ok(CredentialStatus::NotReady( + NotReadyReason::CredentialsExpired + )) + )); + } +} + +#[test] +fn ecs_metadata_classifies_http_errors_before_parsing_body() { + for status in [401, 403, 500] { + for body in [ + ecs_metadata_credentials().to_string(), + "not JSON".to_string(), + ] { + assert!( + matches!( + ( + status, + classify_credentials(status, body.as_bytes(), ecs_metadata_now()) + ), + (401 | 403, Err(ProbeError::AccessDenied)) | (500, Err(ProbeError::Http)) + ), + "HTTP {status} must retain its error classification" + ); + } + } +} + +#[test] +fn ecs_metadata_rejects_invalid_fields() { + for field in [ + "Code", + "AccessKeyId", + "AccessKeySecret", + "SecurityToken", + "Expiration", + ] { + for (case, replacement) in [ + ("missing", None), + ("null", Some(serde_json::json!(null))), + ("empty", Some(serde_json::json!(""))), + ("number", Some(serde_json::json!(42))), + ("boolean", Some(serde_json::json!(true))), + ("array", Some(serde_json::json!([]))), + ("object", Some(serde_json::json!({}))), + ] { + let mut body = ecs_metadata_credentials(); + match replacement { + Some(value) => body[field] = value, + None => { + body.as_object_mut() + .expect("fixture is an object") + .remove(field); + } + } + assert!( + matches!( + classify_credentials(200, body.to_string().as_bytes(), ecs_metadata_now()), + Err(ProbeError::InvalidResponse) + ), + "{field}/{case} must be rejected" + ); + } + } + for (field, value) in [ + ("Code", "SyntheticFailure"), + ("Code", "success"), + ("Expiration", "not-a-timestamp"), + ] { + let mut body = ecs_metadata_credentials(); + body[field] = serde_json::json!(value); + assert!( + matches!( + classify_credentials(200, body.to_string().as_bytes(), ecs_metadata_now()), + Err(ProbeError::InvalidResponse) + ), + "invalid {field} must be rejected" + ); + } +} + +#[test] +fn ecs_metadata_rejects_invalid_json_or_keyword_decoys() { + for (case, body) in [ + ("empty body", ""), + ("malformed JSON", "{\"Code\":\"Success\","), + ("null document", "null"), + ("array document", "[]"), + ("empty object", "{}"), + ( + "keyword text", + "metadata error: AccessKeyId AccessKeySecret SecurityToken", + ), + ( + "keyword JSON string", + r#""metadata error: AccessKeyId AccessKeySecret SecurityToken""#, + ), + ( + "keyword error object", + r#"{"Code":"Success","Message":"AccessKeyId AccessKeySecret SecurityToken"}"#, + ), + ] { + assert!( + matches!( + classify_credentials(200, body.as_bytes(), ecs_metadata_now()), + Err(ProbeError::InvalidResponse) + ), + "{case} must be rejected" + ); + } +} + +#[test] +fn ecs_metadata_errors_do_not_display_response_body_or_credentials() { + let mut body = ecs_metadata_credentials(); + body["Code"] = serde_json::json!("SyntheticFailure"); + let body = body.to_string(); + let malformed_body = format!("{body} invalid-json"); + + for body in [&body, &malformed_body] { + for status in [200, 401, 403, 500] { + let error = match classify_credentials(status, body.as_bytes(), ecs_metadata_now()) { + Err(error) => error, + Ok(_) => panic!("error fixture must not produce a credential status"), + }; + let message = error.to_string(); + // Do not print the message on failure: it may contain the leaked fixture. + for forbidden in [ + body.as_str(), + "synthetic-ak", + "synthetic-sk", + "synthetic-token", + ] { + assert!( + !message.contains(forbidden), + "HTTP {status} error Display must not expose response data" + ); + } + } + } + for error in [ProbeError::Unreachable, ProbeError::Timeout] { + let message = error.to_string(); + for forbidden in [ + body.as_str(), + "synthetic-ak", + "synthetic-sk", + "synthetic-token", + ] { + assert!( + !message.contains(forbidden), + "transport error Display must not expose response data" + ); + } + } +} + +#[test] +fn ecs_metadata_error_codes_and_messages_are_fixed() { + for (error, code, message) in [ + ( + ProbeError::AccessDenied, + "metadata_access_denied", + "Unable to access ECS instance metadata.", + ), + ( + ProbeError::InvalidResponse, + "invalid_metadata_response", + "ECS instance metadata returned an invalid response.", + ), + ( + ProbeError::Unreachable, + "metadata_unreachable", + "Unable to reach ECS instance metadata.", + ), + ( + ProbeError::Timeout, + "metadata_timeout", + "ECS instance metadata request timed out.", + ), + ( + ProbeError::Http, + "metadata_http_error", + "ECS instance metadata returned an HTTP error.", + ), + ] { + assert_eq!(error.code(), code); + assert_eq!(error.to_string(), message); + } +} + +async fn ecs_metadata_read_request(socket: &mut AsyncTcpStream) -> std::io::Result { + let mut request = Vec::new(); + let mut buffer = [0; 1024]; + while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + let count = socket.read(&mut buffer).await?; + if count == 0 { + return Err(std::io::Error::from(std::io::ErrorKind::UnexpectedEof)); + } + request.extend_from_slice(&buffer[..count]); + } + Ok(String::from_utf8(request).expect("ASCII HTTP request")) +} + +#[tokio::test] +async fn ecs_metadata_token_required_service_accepts_probe() { + let mut body = ecs_metadata_credentials(); + body["Expiration"] = serde_json::json!("2100-01-01T00:00:00Z"); + assert_eq!( + ecs_metadata_fixture( + ecs_metadata_http_response(200, &body.to_string()), + MetadataBodyMode::Complete, + ) + .await, + Ok(CredentialStatus::Ready), + ); +} + +#[derive(Clone, Copy)] +enum MetadataBodyMode { + Complete, + Stall, + Trickle, +} + +async fn ecs_metadata_fixture( + response: String, + mode: MetadataBodyMode, +) -> Result { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind metadata fixture"); + let url = format!( + "http://{}/latest/meta-data/ram/security-credentials/{ECS_RAM_ROLE_NAME}", + listener.local_addr().expect("fixture address") + ); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await?; + let request = ecs_metadata_read_request(&mut socket).await?; + // The fixture deliberately rejects IMDSv1 instead of accepting a tokenless GET. + if !request.starts_with("PUT /latest/api/token HTTP/1.1\r\n") { + socket + .write_all(ecs_metadata_http_response(401, "token required").as_bytes()) + .await?; + return Ok(()); + } + assert!(request.contains("x-aliyun-ecs-metadata-token-ttl-seconds: 60\r\n")); + assert!(!request.contains("x-aliyun-ecs-metadata-token:")); + socket + .write_all(ecs_metadata_http_response(200, "synthetic-imds-token").as_bytes()) + .await?; + socket.shutdown().await?; + let (mut socket, _) = listener.accept().await?; + let request = ecs_metadata_read_request(&mut socket).await?; + assert!(request.starts_with(&format!( + "GET /latest/meta-data/ram/security-credentials/{ECS_RAM_ROLE_NAME} HTTP/1.1\r\n" + ))); + assert!(request.contains("x-aliyun-ecs-metadata-token: synthetic-imds-token\r\n")); + socket.write_all(response.as_bytes()).await?; + match mode { + MetadataBodyMode::Complete => socket.shutdown().await?, + MetadataBodyMode::Stall => std::future::pending::<()>().await, + MetadataBodyMode::Trickle => loop { + tokio::time::sleep(Duration::from_millis(100)).await; + socket.write_all(b"1\r\n \r\n").await?; + }, + } + Ok::<_, std::io::Error>(()) + }); + let outcome = tokio::time::timeout(TEST_WATCHDOG, super::ecs_metadata::probe_url(&url)).await; + server.abort(); + match server.await { + Ok(Ok(())) => {} + Ok(Err(error)) => assert!(matches!( + error.kind(), + std::io::ErrorKind::BrokenPipe | std::io::ErrorKind::ConnectionReset + )), + Err(error) => assert!(error.is_cancelled(), "metadata fixture failed"), + } + outcome.expect("metadata probe must finish within watchdog") +} + +fn ecs_metadata_http_response(status: u16, body: &str) -> String { + format!( + "HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) +} + +#[tokio::test] +async fn ecs_metadata_http_probe_preserves_classification() { + let mut body = ecs_metadata_credentials(); + body["Expiration"] = serde_json::json!("2100-01-01T00:00:00Z"); + let body = body.to_string(); + for (status, expected) in [ + (200, Ok(CredentialStatus::Ready)), + (201, Ok(CredentialStatus::Ready)), + ( + 404, + Ok(CredentialStatus::NotReady(NotReadyReason::RoleMissing)), + ), + (401, Err(ProbeError::AccessDenied)), + (403, Err(ProbeError::AccessDenied)), + (500, Err(ProbeError::Http)), + (429, Err(ProbeError::Http)), + ] { + assert_eq!( + ecs_metadata_fixture( + ecs_metadata_http_response(status, &body), + MetadataBodyMode::Complete + ) + .await, + expected, + "HTTP {status}" + ); + } +} + +#[tokio::test] +async fn ecs_metadata_http_body_limit_includes_chunked_responses() { + let mut body = ecs_metadata_credentials(); + body["Expiration"] = serde_json::json!("2100-01-01T00:00:00Z"); + let mut body = body.to_string(); + body.push_str(&" ".repeat(64 * 1024 - body.len())); + for extra in [0, 1] { + let body = format!("{body}{}", " ".repeat(extra)); + let expected = if extra == 0 { + Ok(CredentialStatus::Ready) + } else { + Err(ProbeError::InvalidResponse) + }; + for response in [ + ecs_metadata_http_response(200, &body), + format!( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n{body}\r\n0\r\n\r\n", + body.len() + ), + ] { + assert_eq!( + ecs_metadata_fixture(response, MetadataBodyMode::Complete).await, + expected + ); + } + } +} + +#[tokio::test] +async fn ecs_metadata_http_redirect_is_not_followed() { + let target = TcpListener::bind("127.0.0.1:0") + .await + .expect("redirect target"); + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: http://{}/steal\r\nContent-Length: 0\r\n\r\n", + target.local_addr().expect("redirect address") + ); + let result = ecs_metadata_fixture(response, MetadataBodyMode::Complete).await; + assert_eq!(result, Err(ProbeError::Http)); + assert!( + target.accept().now_or_never().is_none(), + "redirect target must not receive a connection" + ); +} + +#[tokio::test] +async fn ecs_metadata_http_deadline_covers_headers_body_and_trickle() { + for (response, mode) in [ + (String::new(), MetadataBodyMode::Stall), + ( + "HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n".to_string(), + MetadataBodyMode::Stall, + ), + ( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n".to_string(), + MetadataBodyMode::Trickle, + ), + ] { + let started = std::time::Instant::now(); + let result = ecs_metadata_fixture(response, mode).await; + assert_eq!(result, Err(ProbeError::Timeout)); + assert!( + started.elapsed() < TEST_COMPLETION_BOUND, + "metadata deadline must cover the whole response" + ); + } +} + +#[tokio::test] +async fn ecs_metadata_http_connection_failure_is_unreachable() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("unused local port"); + let url = format!("http://{}", listener.local_addr().expect("local address")); + drop(listener); + assert_eq!( + super::ecs_metadata::probe_url(&url).await, + Err(ProbeError::Unreachable) + ); +} diff --git a/src/cosh-ng/crates/cosh-core/src/registry/auth.rs b/src/cosh-ng/crates/cosh-core/src/registry/auth.rs index 1ab399595d..7dea3b603d 100644 --- a/src/cosh-ng/crates/cosh-core/src/registry/auth.rs +++ b/src/cosh-ng/crates/cosh-core/src/registry/auth.rs @@ -1,6 +1,54 @@ //! Authentication registry commands and credential update rules. use super::*; +use crate::provider::sysom::{CredentialStatus, ProbeError}; + +fn ecs_probe_response( + request_id: &str, + result: Result, +) -> OutputMessage { + let (success, data, error) = match result { + Ok(CredentialStatus::Ready) => (true, serde_json::json!({ "status": "ready" }), None), + Ok(CredentialStatus::NotReady(reason)) => ( + true, + serde_json::json!({ "status": "not_ready", "reason": reason.as_str() }), + None, + ), + Err(error) => ( + false, + serde_json::json!({ "error_code": error.code() }), + Some(error.to_string()), + ), + }; + OutputMessage::RegistryResponse { + request_id: request_id.to_string(), + success, + data: Some(data), + error, + } +} + +fn ecs_prepare_response( + request_id: &str, + result: Result, ProbeError>, +) -> OutputMessage { + let data = match result { + Ok(Some(challenge)) => serde_json::json!({ + "mode": "ecs_ram_role", + "instance_id": challenge.instance_id, + "console_url": challenge.console_url, + "values": { "auth_source": "ecs_ram_role" } + }), + Ok(None) => serde_json::json!({ "mode": "manual" }), + Err(error) => return ecs_probe_response(request_id, Err(error)), + }; + OutputMessage::RegistryResponse { + request_id: request_id.to_string(), + success: true, + data: Some(data), + error: None, + } +} pub(super) async fn handle_auth( request_id: &str, @@ -135,31 +183,12 @@ pub(super) async fn handle_auth( if provider_type.is_empty() { return registry_error(request_id, "missing provider_type"); } - let data = if provider_type == "aliyun" { - match crate::provider::sysom::detect_ecs_auth_challenge() { - Some(challenge) => serde_json::json!({ - "mode": "ecs_ram_role", - "instance_id": challenge.instance_id, - "console_url": challenge.console_url, - "values": { - "auth_source": "ecs_ram_role" - } - }), - None => serde_json::json!({ - "mode": "manual" - }), - } + let result = if provider_type == "aliyun" { + crate::provider::sysom::detect_ecs_auth_challenge().await } else { - serde_json::json!({ - "mode": "manual" - }) + Ok(None) }; - OutputMessage::RegistryResponse { - request_id: request_id.to_string(), - success: true, - data: Some(data), - error: None, - } + ecs_prepare_response(request_id, result) } "verify" => { let provider_type = params @@ -171,15 +200,10 @@ pub(super) async fn handle_auth( .and_then(|v| v.as_str()) .unwrap_or(""); if provider_type == "aliyun" && auth_source == "ecs_ram_role" { - let authorized = crate::provider::sysom::ecs_ram_role_credentials_available(); - OutputMessage::RegistryResponse { - request_id: request_id.to_string(), - success: true, - data: Some(serde_json::json!({ - "authorized": authorized - })), - error: None, - } + ecs_probe_response( + request_id, + crate::provider::sysom::probe_ecs_ram_role().await, + ) } else { OutputMessage::RegistryResponse { request_id: request_id.to_string(), @@ -292,3 +316,123 @@ fn preserve_masked_secret( } } } + +#[cfg(test)] +mod ecs_tests { + use super::*; + use crate::provider::sysom::{EcsAuthChallenge, NotReadyReason}; + + #[test] + fn ecs_metadata_registry_prepare_encodes_ecs_and_manual() { + for (challenge, expected) in [ + (None, serde_json::json!({"mode": "manual"})), + ( + Some(EcsAuthChallenge { + instance_id: "i-test".to_string(), + console_url: + "https://alinux.console.aliyun.com/cn-shanghai/guide/cosh?instance=i-test" + .to_string(), + }), + serde_json::json!({"mode": "ecs_ram_role", "instance_id": "i-test", "console_url": "https://alinux.console.aliyun.com/cn-shanghai/guide/cosh?instance=i-test", "values": {"auth_source": "ecs_ram_role"}}), + ), + ] { + let OutputMessage::RegistryResponse { + success, + data, + error, + .. + } = ecs_prepare_response("prepare-id", Ok(challenge)) + else { + panic!("registry response") + }; + assert!(success); + assert_eq!(data, Some(expected)); + assert_eq!(error, None); + } + } + + #[test] + fn ecs_metadata_registry_prepare_never_masks_safe_errors_as_manual() { + for probe_error in [ + ProbeError::AccessDenied, + ProbeError::InvalidResponse, + ProbeError::Http, + ] { + let OutputMessage::RegistryResponse { + success, + data, + error, + .. + } = ecs_prepare_response("prepare-id", Err(probe_error)) + else { + panic!("registry response") + }; + assert!(!success); + assert_eq!( + data, + Some(serde_json::json!({"error_code": probe_error.code()})) + ); + assert_eq!(error, Some(probe_error.to_string())); + } + } + + #[test] + fn ecs_metadata_registry_encodes_ready_and_not_ready() { + for (status, expected) in [ + ( + CredentialStatus::Ready, + serde_json::json!({"status": "ready"}), + ), + ( + CredentialStatus::NotReady(NotReadyReason::RoleMissing), + serde_json::json!({"status": "not_ready", "reason": "role_missing"}), + ), + ( + CredentialStatus::NotReady(NotReadyReason::CredentialsExpired), + serde_json::json!({"status": "not_ready", "reason": "credentials_expired"}), + ), + ] { + let OutputMessage::RegistryResponse { + request_id, + success, + data, + error, + } = ecs_probe_response("probe-id", Ok(status)) + else { + panic!("expected registry response") + }; + assert_eq!(request_id, "probe-id"); + assert!(success); + assert_eq!(data, Some(expected)); + assert_eq!(error, None); + } + } + + #[test] + fn ecs_metadata_registry_preserves_safe_errors() { + for probe_error in [ + ProbeError::AccessDenied, + ProbeError::InvalidResponse, + ProbeError::Unreachable, + ProbeError::Timeout, + ProbeError::Http, + ] { + let OutputMessage::RegistryResponse { + request_id, + success, + data, + error, + } = ecs_probe_response("probe-error-id", Err(probe_error)) + else { + panic!("expected registry response") + }; + assert_eq!(request_id, "probe-error-id"); + assert!(!success); + assert_eq!( + data, + Some(serde_json::json!({"error_code": probe_error.code()})) + ); + assert_eq!(error, Some(probe_error.to_string())); + } + } +} diff --git a/src/cosh-ng/crates/cosh-shell/scripts/inventory-public-api.sh b/src/cosh-ng/crates/cosh-shell/scripts/inventory-public-api.sh index 976de4b82d..04617b825f 100755 --- a/src/cosh-ng/crates/cosh-shell/scripts/inventory-public-api.sh +++ b/src/cosh-ng/crates/cosh-shell/scripts/inventory-public-api.sh @@ -37,7 +37,7 @@ classification_for_owner_entry() { adapter::adapter_for_kind|adapter::AdapterError|adapter::AdapterInstance|adapter::AdapterKind|adapter::AgentAdapter|adapter::AgentBackendCapabilities|adapter::AgentRunHandle|adapter::AgentRunPoll|adapter::ApprovalDecision|adapter::ApprovalResponse|adapter::AuthFieldInfo|adapter::AuthProviderInfo|adapter::AuthResponse|adapter::ControlProtocolCapabilities|adapter::HostExecutedShellMetadata|adapter::HostExecutedShellResult|adapter::ShellEvidenceAction) echo "support-api-review-before-freeze" ;; - adapter::ClaudeCodeAdapter|adapter::CoshCoreAdapter|adapter::FakeAgentAdapter|adapter::QwenCliAdapter|adapter::ProviderCancellationArtifact|adapter::ProviderCancellationArtifactKind|adapter::ProviderCancellationArtifactStore|adapter::SessionClearFailure|adapter::SessionClearInterruption|adapter::SessionClearPlan|adapter::SessionClearResult|adapter::SessionErrorInfo|adapter::SessionHealth|adapter::SessionList|adapter::SessionManagementClient|adapter::SessionRecovery|adapter::SessionRecoveryState|adapter::SessionRuntimeState|adapter::SessionSummary) + adapter::ClaudeCodeAdapter|adapter::CoshCoreAdapter|adapter::EcsProbeTask|adapter::FakeAgentAdapter|adapter::QwenCliAdapter|adapter::ProviderCancellationArtifact|adapter::ProviderCancellationArtifactKind|adapter::ProviderCancellationArtifactStore|adapter::SessionClearFailure|adapter::SessionClearInterruption|adapter::SessionClearPlan|adapter::SessionClearResult|adapter::SessionErrorInfo|adapter::SessionHealth|adapter::SessionList|adapter::SessionManagementClient|adapter::SessionRecovery|adapter::SessionRecoveryState|adapter::SessionRuntimeState|adapter::SessionSummary) echo "private-candidate" ;; agent::govern_agent_events|agent::govern_agent_events_with_language|agent::GovernanceOutput|journal::read_shell_events|ledger::build_command_blocks|ledger::LedgerOutput|parser::agent_request_after_confirmation|parser::findings_from_blocks|raw_input::RawInputCapture|raw_input::RawObserverAction|raw_input::RawRelayAction|shell_host::run_line_interactive_bash|shell_host::run_raw_relay_bash|shell_host::run_raw_relay_bash_with_actions|shell_host::run_raw_relay_bash_with_actions_output_control|shell_host::run_raw_relay_bash_with_observer|shell_host::run_raw_relay_zsh_with_actions|shell_host::run_raw_relay_zsh_with_output_control|shell_host::run_scripted_bash|shell_host::run_scripted_zsh|shell_host::LineInteractiveOutput|shell_host::ScriptedInput|shell_host::ShellHostConfig|shell_host::ShellHostOutput) diff --git a/src/cosh-ng/crates/cosh-shell/src/adapter/cosh_core_registry.rs b/src/cosh-ng/crates/cosh-shell/src/adapter/cosh_core_registry.rs index bc111404bd..a2ff30fcb5 100644 --- a/src/cosh-ng/crates/cosh-shell/src/adapter/cosh_core_registry.rs +++ b/src/cosh-ng/crates/cosh-shell/src/adapter/cosh_core_registry.rs @@ -11,6 +11,15 @@ pub(super) const REGISTRY_READ_TIMEOUT: Duration = Duration::from_secs(5); pub(super) const REGISTRY_MUTATION_TIMEOUT: Duration = Duration::from_secs(120); pub(super) const AUTH_CONFIGURE_TIMEOUT: Duration = Duration::from_secs(12); +#[cfg(test)] +type ReaderSpawnCheck = fn(u32) -> std::io::Result<()>; + +#[cfg(test)] +thread_local! { + static READER_SPAWN_CHECK: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + #[derive(Debug, Clone, PartialEq, Eq)] /// Distinguishes registry protocol failures from transport failures. pub(crate) enum RegistryQueryError { @@ -147,23 +156,40 @@ impl CoshCoreAdapter { }; let (tx, rx) = std::sync::mpsc::channel(); - let reader_handle = std::thread::spawn(move || { - let reader = BufReader::new(stdout); - for line in reader.lines() { - match line { - Ok(l) if !l.trim().is_empty() => { - let _ = tx.send(Ok(l)); - return; - } - Ok(_) => continue, - Err(e) => { - let _ = tx.send(Err(format!("read error: {e}"))); - return; + #[cfg(test)] + let spawn_check = READER_SPAWN_CHECK + .take() + .map_or(Ok(()), |check| check(child.id())); + #[cfg(not(test))] + let spawn_check: std::io::Result<()> = Ok(()); + let reader_result = spawn_check.and_then(|()| { + std::thread::Builder::new().spawn(move || { + let reader = BufReader::new(stdout); + for line in reader.lines() { + match line { + Ok(l) if !l.trim().is_empty() => { + let _ = tx.send(Ok(l)); + return; + } + Ok(_) => continue, + Err(e) => { + let _ = tx.send(Err(format!("read error: {e}"))); + return; + } } } - } - let _ = tx.send(Err("no response received (EOF)".to_string())); + let _ = tx.send(Err("no response received (EOF)".to_string())); + }) }); + let reader_handle = match reader_result { + Ok(handle) => handle, + Err(_) => { + super::terminate_and_reap_process(&mut child); + return Err(RegistryQueryError::Transport( + "failed to start registry reader".into(), + )); + } + }; let response_line = match rx.recv_timeout(registry_timeout(domain, action)) { Ok(Ok(line)) => line, @@ -182,7 +208,14 @@ impl CoshCoreAdapter { }; let _ = reader_handle.join(); - let _ = child.wait(); + if domain == "auth" && action == "configure" { + use wait_timeout::ChildExt; + if !matches!(child.wait_timeout(Duration::from_millis(250)), Ok(Some(_))) { + super::terminate_and_reap_process(&mut child); + } + } else { + let _ = child.wait(); + } // Parse the response let resp: Value = serde_json::from_str(&response_line) @@ -250,3 +283,89 @@ pub(super) fn registry_timeout(domain: &str, action: &str) -> Duration { REGISTRY_READ_TIMEOUT } } + +#[cfg(test)] +mod tests { + use super::*; + use nix::libc; + use std::cell::Cell; + use std::os::unix::fs::PermissionsExt; + + thread_local! { + static SPAWNED_PID: Cell = const { Cell::new(0) }; + } + + #[test] + fn completed_configure_keeps_its_reply_and_reaps_a_lingering_child() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("registry.sh"); + std::fs::write( + &script, + r#"#!/bin/sh +read -r request +printf '%s\n' "$$" > "$0.pid" +printf '%s\n' '{"success":true,"data":{"saved":true}}' +exec sleep 60 +"#, + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let adapter = CoshCoreAdapter::new(script.to_string_lossy(), false); + let start = std::time::Instant::now(); + let result = adapter.registry_query_classified("auth", "configure", Value::Null); + assert!(start.elapsed() < Duration::from_secs(2)); + assert_eq!(result, Ok(serde_json::json!({"saved": true}))); + let pid: i32 = std::fs::read_to_string(dir.path().join("registry.sh.pid")) + .unwrap() + .trim() + .parse() + .unwrap(); + assert_eq!( + unsafe { libc::waitpid(pid, std::ptr::null_mut(), libc::WNOHANG) }, + -1 + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ECHILD) + ); + } + + #[test] + fn reader_spawn_failure_reaps_child_before_returning_transport_error() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("registry.sh"); + std::fs::write(&script, "#!/bin/sh\nread -r request\nexec sleep 60\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let adapter = CoshCoreAdapter::new(script.to_string_lossy(), false); + READER_SPAWN_CHECK.set(Some(|pid| { + SPAWNED_PID.set(pid); + Err(std::io::Error::other("private fixture detail")) + })); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + adapter.registry_query_classified("auth", "configure", Value::Null) + })); + READER_SPAWN_CHECK.set(None); + let pid = SPAWNED_PID.get() as i32; + assert!(pid > 0, "failure must happen after the child is spawned"); + let waited = unsafe { libc::waitpid(pid, std::ptr::null_mut(), libc::WNOHANG) }; + let reaped = + waited == -1 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ECHILD); + // Keep the RED run leak-free too, without hiding the missing production reap. + if waited == 0 { + unsafe { + libc::kill(-pid, libc::SIGKILL); + libc::waitpid(pid, std::ptr::null_mut(), 0); + } + } + assert!( + reaped, + "registry must reap the child before returning or unwinding" + ); + assert_eq!( + result.expect("thread creation failure must not panic"), + Err(RegistryQueryError::Transport( + "failed to start registry reader".into() + )) + ); + } +} diff --git a/src/cosh-ng/crates/cosh-shell/src/adapter/ecs_probe.rs b/src/cosh-ng/crates/cosh-shell/src/adapter/ecs_probe.rs new file mode 100644 index 0000000000..cd8d973a34 --- /dev/null +++ b/src/cosh-ng/crates/cosh-shell/src/adapter/ecs_probe.rs @@ -0,0 +1,362 @@ +use std::io::{self, Read, Write}; +use std::net::Shutdown; +use std::os::fd::{AsRawFd, RawFd}; +use std::os::unix::net::UnixStream; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use nix::libc; +use serde_json::{json, Value}; + +use super::CoshCoreAdapter; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_RESPONSE_BYTES: usize = 128 * 1024; +static NEXT_REQUEST: AtomicU64 = AtomicU64::new(1); + +/// Owns one read-only registry request through cancellation and thread termination. +#[derive(Debug)] +pub struct EcsProbeTask { + cancelled: Arc, + wake: UnixStream, + worker: Option>>, +} + +impl EcsProbeTask { + /// Interrupts the request without relinquishing its cleanup ownership. + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + let _ = self.wake.shutdown(Shutdown::Write); + } + + /// Returns a result only after the worker and its owned child have terminated. + pub fn try_finish(&mut self) -> Option> { + if !self.worker.as_ref()?.is_finished() { + return None; + } + let worker = self.worker.take()?; + Some( + worker + .join() + .unwrap_or_else(|_| Err("ECS probe worker failed".into())), + ) + } +} + +impl Drop for EcsProbeTask { + fn drop(&mut self) { + self.cancel(); + if let Some(worker) = self.worker.take() { + if worker.join().is_err() { + tracing::warn!("ECS probe worker failed during shutdown"); + } + } + } +} + +impl CoshCoreAdapter { + /// Starts an isolated, cancellable ECS prepare or verify request. + pub fn start_ecs_probe(&self, action: &str) -> io::Result { + if !matches!(action, "prepare" | "verify") { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid ECS probe action", + )); + } + let mut workspace = self + .shell_cwd + .lock() + .map_err(|_| io::Error::other("shell workspace lock poisoned"))? + .clone(); + if workspace.is_none() { + workspace = self + .session + .lock() + .map_err(|_| io::Error::other("core session lock poisoned"))? + .active_workspace_scope() + .map(str::to_string); + } + let program = self.program.clone(); + let action = action.to_string(); + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let (wake, worker_wake) = UnixStream::pair()?; + wake.set_nonblocking(true)?; + worker_wake.set_nonblocking(true)?; + // Settled before the worker can spawn, so no owned child predates it. + keep_children_waitable(); + let worker = thread::Builder::new() + .name("cosh-auth-ecs-probe".into()) + .spawn(move || { + run_probe( + &program, + workspace.as_deref(), + &action, + &worker_cancelled, + &worker_wake, + ) + })?; + Ok(EcsProbeTask { + cancelled, + wake, + worker: Some(worker), + }) + } +} + +struct ProbeChild(Child); + +/// Keeps terminated probe children waitable by this process. +/// +/// Signalling the recorded PID/PGID in cleanup is only sound while this process +/// is the sole reaper of the probe child: once it is reaped the number is freed +/// and can name an unrelated group. That invariant rests on cosh-shell installing +/// no `SIGCHLD` reaping handler and doing no wildcard `waitpid(-1)` (every wait is +/// targeted at a specific `Child`), so the only competing reaper is the kernel +/// under an inherited `SIGCHLD=SIG_IGN`, which auto-reaps with no zombie. This +/// normalizes that one case back to the default disposition, which retains the +/// zombie until [`ProbeChild`] waits for it. A caught handler cannot be inherited +/// across `execve`, so the inherited disposition is only ever `SIG_IGN` or +/// `SIG_DFL`; a non-ignore disposition is left untouched so an in-process handler +/// (e.g. a test's `wait-timeout`) is not clobbered. The disposition is not +/// restored because a restore would reopen the same window. Introducing any +/// reaping `SIGCHLD` handler or wildcard wait in production would break the +/// sole-reaper invariant and require a pidfd-based path instead. +fn keep_children_waitable() { + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + if unsafe { libc::sigaction(libc::SIGCHLD, std::ptr::null(), &mut action) } != 0 { + tracing::error!( + error = %io::Error::last_os_error(), + "ECS probe could not read the inherited SIGCHLD disposition" + ); + return; + } + if action.sa_sigaction != libc::SIG_IGN { + return; + } + if unsafe { libc::signal(libc::SIGCHLD, libc::SIG_DFL) } == libc::SIG_ERR { + tracing::error!( + error = %io::Error::last_os_error(), + "ECS probe could not stop the kernel from reaping owned children" + ); + } +} + +/// Whether the recorded PID/PGID still belongs to this probe. +/// +/// A reaped child releases its PID, and the process group is named by that same +/// number, so signalling after the wait succeeded (or after the kernel reaped the +/// child under an inherited `SIGCHLD=SIG_IGN`) can hit an unrelated group. +fn owns_signalable_group(wait: &io::Result>) -> bool { + matches!(wait, Ok(None)) +} + +impl Drop for ProbeChild { + fn drop(&mut self) { + let pid = self.0.id() as i32; + let mut reported_error = false; + let mut signalled = false; + loop { + let wait = self.0.try_wait(); + if !signalled && owns_signalable_group(&wait) { + // The isolated probe cannot share a process group with a live agent. + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + signalled = true; + } + match wait { + Ok(Some(_)) => break, + Ok(None) => thread::sleep(Duration::from_millis(5)), + // Inherited SIGCHLD ignore can let the kernel reap the child first. + Err(error) if error.raw_os_error() == Some(libc::ECHILD) => break, + Err(error) => { + if !reported_error { + tracing::error!(%error, "ECS probe child could not be reaped"); + reported_error = true; + } + // Keep ownership until reaped; a failed cleanup cannot become a completed task. + thread::sleep(Duration::from_millis(50)); + } + } + } + } +} + +fn run_probe( + program: &str, + workspace: Option<&str>, + action: &str, + cancelled: &AtomicBool, + wake: &UnixStream, +) -> Result { + let deadline = Instant::now() + PROBE_TIMEOUT; + check_deadline(cancelled, deadline)?; + let mut command = Command::new(program); + command + .arg("--registry") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .process_group(0); + if let Some(workspace) = workspace { + command.arg("--workspace").arg(workspace); + } + let mut child = ProbeChild( + command + .spawn() + .map_err(|_| "Could not start ECS probe".to_string())?, + ); + let mut input = child.0.stdin.take().ok_or("ECS probe stdin unavailable")?; + let mut output = child + .0 + .stdout + .take() + .ok_or("ECS probe stdout unavailable")?; + set_nonblocking(input.as_raw_fd()).map_err(|_| "Could not configure ECS probe stdin")?; + set_nonblocking(output.as_raw_fd()).map_err(|_| "Could not configure ECS probe stdout")?; + let request_id = format!( + "ecs-probe-{}-{}", + std::process::id(), + NEXT_REQUEST.fetch_add(1, Ordering::Relaxed) + ); + let mut request = json!({ + "type": "registry_request", "request_id": request_id, + "domain": "auth", "action": action, + "params": {"provider_type": "aliyun", "auth_source": "ecs_ram_role"} + }) + .to_string() + .into_bytes(); + request.push(b'\n'); + let mut written = 0; + while written < request.len() { + check_deadline(cancelled, deadline)?; + match input.write(&request[written..]) { + Ok(0) => return Err("ECS probe stdin closed".into()), + Ok(count) => written += count, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + wait_ready(input.as_raw_fd(), libc::POLLOUT, wake, cancelled, deadline)?; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => return Err("Could not write ECS probe request".into()), + } + } + drop(input); + let mut response = Vec::new(); + let mut buffer = [0; 4096]; + loop { + check_deadline(cancelled, deadline)?; + match output.read(&mut buffer) { + Ok(0) => return Err("ECS probe response ended unexpectedly".into()), + Ok(count) => { + response.extend_from_slice(&buffer[..count]); + if response.len() > MAX_RESPONSE_BYTES { + return Err("ECS probe response exceeded its size limit".into()); + } + while let Some(end) = response.iter().position(|byte| *byte == b'\n') { + let line: Vec<_> = response.drain(..=end).collect(); + if line.iter().all(u8::is_ascii_whitespace) { + continue; + } + return parse_response(&line, &request_id); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + wait_ready(output.as_raw_fd(), libc::POLLIN, wake, cancelled, deadline)?; + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => return Err("Could not read ECS probe response".into()), + } + } +} + +fn check_deadline(cancelled: &AtomicBool, deadline: Instant) -> Result<(), String> { + if cancelled.load(Ordering::Acquire) { + Err("ECS probe cancelled".into()) + } else if Instant::now() >= deadline { + Err("ECS probe timed out".into()) + } else { + Ok(()) + } +} + +fn set_nonblocking(fd: RawFd) -> io::Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn wait_ready( + fd: RawFd, + events: i16, + wake: &UnixStream, + cancelled: &AtomicBool, + deadline: Instant, +) -> Result<(), String> { + check_deadline(cancelled, deadline)?; + let mut fds = [ + libc::pollfd { + fd, + events, + revents: 0, + }, + libc::pollfd { + fd: wake.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + ]; + let remaining = deadline.saturating_duration_since(Instant::now()); + let milliseconds = remaining.as_millis().min(100) as i32; + let result = unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as _, milliseconds) }; + if result < 0 && io::Error::last_os_error().kind() != io::ErrorKind::Interrupted { + return Err("ECS probe readiness check failed".into()); + } + check_deadline(cancelled, deadline) +} + +fn parse_response(line: &[u8], request_id: &str) -> Result { + let response: Value = serde_json::from_slice(line).map_err(|_| "Invalid ECS probe response")?; + if response["type"] != "registry_response" || response["request_id"] != request_id { + return Err("Mismatched ECS probe response".into()); + } + if response["success"] == true { + return response + .get("data") + .cloned() + .ok_or_else(|| "Missing ECS probe result".into()); + } + let reason = match response["data"]["error_code"].as_str() { + Some("metadata_access_denied") => "Instance metadata access was denied", + Some("metadata_timeout") => "Instance metadata request timed out", + Some("metadata_unreachable") => "Instance metadata could not be reached", + Some("invalid_metadata_response") => "Instance metadata returned invalid credentials", + _ => "ECS credential check failed", + }; + Err(reason.into()) +} + +#[cfg(test)] +mod tests { + use std::os::unix::process::ExitStatusExt; + + use super::*; + + #[test] + fn only_an_unreaped_child_may_be_signalled_by_numeric_id() { + assert!(owns_signalable_group(&Ok(None))); + // A reaped PID/PGID can be reused, so the recorded number is no longer ours. + assert!(!owns_signalable_group(&Ok(Some(ExitStatus::from_raw(0))))); + for code in [libc::ECHILD, libc::EPERM, libc::EINVAL] { + assert!(!owns_signalable_group(&Err(io::Error::from_raw_os_error( + code + )))); + } + } +} diff --git a/src/cosh-ng/crates/cosh-shell/src/adapter/mod.rs b/src/cosh-ng/crates/cosh-shell/src/adapter/mod.rs index 5155361489..0826bb4235 100644 --- a/src/cosh-ng/crates/cosh-shell/src/adapter/mod.rs +++ b/src/cosh-ng/crates/cosh-shell/src/adapter/mod.rs @@ -24,6 +24,7 @@ mod cosh_core_registry_tests; mod cosh_core_service; #[cfg(test)] mod cosh_core_tests; +mod ecs_probe; mod fake; mod process; mod prompt; @@ -43,6 +44,7 @@ pub use cosh_core::{ SessionRecovery, SessionRecoveryState, SessionRuntimeState, SessionSummary, }; pub(crate) use cosh_core_registry::RegistryQueryError; +pub use ecs_probe::EcsProbeTask; pub use fake::FakeAgentAdapter; pub(crate) use process::{ agent_event_is_provider_progress, record_cancellation_pending_session, diff --git a/src/cosh-ng/crates/cosh-shell/src/adapter/public.rs b/src/cosh-ng/crates/cosh-shell/src/adapter/public.rs index a6ede72692..2710b65577 100644 --- a/src/cosh-ng/crates/cosh-shell/src/adapter/public.rs +++ b/src/cosh-ng/crates/cosh-shell/src/adapter/public.rs @@ -6,8 +6,8 @@ pub use implementation::{ adapter_for_kind, AdapterError, AdapterInstance, AdapterKind, AgentAdapter, AgentBackendCapabilities, AgentRunHandle, AgentRunPoll, ApprovalDecision, ApprovalResponse, AuthFieldInfo, AuthProviderInfo, AuthResponse, ClaudeCodeAdapter, ControlProtocolCapabilities, - CoshCoreAdapter, FakeAgentAdapter, HostExecutedShellMetadata, HostExecutedShellResult, - ProviderCancellationArtifact, ProviderCancellationArtifactKind, + CoshCoreAdapter, EcsProbeTask, FakeAgentAdapter, HostExecutedShellMetadata, + HostExecutedShellResult, ProviderCancellationArtifact, ProviderCancellationArtifactKind, ProviderCancellationArtifactStore, QwenCliAdapter, SessionClearFailure, SessionClearInterruption, SessionClearPlan, SessionClearResult, SessionErrorInfo, SessionHealth, SessionList, SessionManagementClient, SessionRecovery, SessionRecoveryState, diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/capture.rs b/src/cosh-ng/crates/cosh-shell/src/auth/capture.rs index 9b2da8dc81..771b6069a9 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/capture.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/capture.rs @@ -18,7 +18,11 @@ pub(super) fn auth_capture_id(auth: &RuntimeAuthState) -> String { "field-{}-{}", auth.current_field, auth.field_capture_revision ), - AuthPhase::AliyunEcsChallenge { .. } => "aliyun-challenge".to_string(), + AuthPhase::PreparingMenu + | AuthPhase::AliyunEcsPreparing + | AuthPhase::AliyunEcsChallenge { .. } => { + format!("aliyun-challenge-{}", auth.field_capture_revision) + } }; format!("{}@{scope}", auth.id) } @@ -83,9 +87,11 @@ pub(crate) fn pending_auth_capture(state: &InlineState) -> Option Some(RawInputCapture::Question { + AuthPhase::PreparingMenu + | AuthPhase::AliyunEcsPreparing + | AuthPhase::AliyunEcsChallenge { .. } => Some(RawInputCapture::Question { id: auth_capture_id(auth), - option_count: 1, + option_count: super::ecs_poll::option_count(state), selected: 0, allow_free_text: false, multiple: false, diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/default_id.rs b/src/cosh-ng/crates/cosh-shell/src/auth/default_id.rs new file mode 100644 index 0000000000..02e080673d --- /dev/null +++ b/src/cosh-ng/crates/cosh-shell/src/auth/default_id.rs @@ -0,0 +1,113 @@ +//! Provider identity and editable-field policy for auth forms. + +use crate::runtime::prelude::{AuthFieldInfo, AuthProviderInfo}; + +use super::runtime::{AuthBackend, AuthPhase, RuntimeAuthState}; +use super::validation::{PROVIDER_ID_HINT, PROVIDER_ID_OCCUPIED_ERROR}; + +pub(super) fn providers_with_provider_id_field( + providers: Vec, +) -> Vec { + providers + .into_iter() + .map(|mut provider| { + provider.fields.insert( + 0, + AuthFieldInfo { + name: "provider_id".to_string(), + label: "Provider ID".to_string(), + hint: Some(PROVIDER_ID_HINT.to_string()), + secret: false, + required: true, + placeholder: Some(provider.id.clone()), + }, + ); + provider + }) + .collect() +} + +fn reset_new_provider(auth: &mut RuntimeAuthState) { + auth.editing_provider_name = None; + auth.default_provider_id = false; + auth.from_sysom_shortcut = false; + auth.current_field = 0; + auth.collected_values.clear(); + auth.field_input.clear(); + auth.field_error = None; +} + +pub(super) fn begin_new_provider(auth: &mut RuntimeAuthState) { + reset_new_provider(auth); + auth.selected_provider = 0; + auth.phase = AuthPhase::SelectingProvider; +} + +pub(super) fn begin_provider_fields(auth: &mut RuntimeAuthState) { + reset_new_provider(auth); + auth.phase = AuthPhase::FillingField; + if auth.backend == AuthBackend::CoreRegistry { + let id = auth.current_provider().id.clone(); + let occupied = auth.provider_name_is_taken(&id); + if !occupied + && !auth + .existing_providers + .iter() + .any(|provider| provider.provider_type == id) + { + auth.default_provider_id = true; + auth.collected_values.insert("provider_id".to_string(), id); + } else if occupied { + auth.field_error = Some(PROVIDER_ID_OCCUPIED_ERROR.to_string()); + for field in &mut auth.providers[auth.selected_provider].fields { + if field.name == "provider_id" { + field.placeholder = None; + } + } + } + } + auth.current_field = auth.first_editable_field(); + auth.load_current_field_input(); +} + +pub(super) fn begin_sysom_shortcut(auth: &mut RuntimeAuthState) -> bool { + let Some(template_idx) = auth + .providers + .iter() + .position(|provider| provider.id == "aliyun") + else { + return false; + }; + auth.selected_provider = template_idx; + begin_provider_fields(auth); + auth.from_sysom_shortcut = true; + true +} + +impl RuntimeAuthState { + pub(super) fn provider_name_is_taken(&self, name: &str) -> bool { + self.existing_providers + .iter() + .any(|provider| provider.name == name) + } + + pub(super) fn field_is_editable(&self, index: usize) -> bool { + self.current_provider() + .fields + .get(index) + .is_some_and(|field| { + field.name != "provider_id" + || (!self.default_provider_id && self.editing_provider_name.is_none()) + }) + } + + pub(super) fn editable_field_at_or_after(&self, start: usize) -> usize { + (start..self.current_provider().fields.len()) + .find(|&index| self.field_is_editable(index)) + .unwrap_or(self.current_provider().fields.len()) + } + + pub(super) fn first_editable_field(&self) -> usize { + self.editable_field_at_or_after(0) + } +} diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/ecs_poll.rs b/src/cosh-ng/crates/cosh-shell/src/auth/ecs_poll.rs new file mode 100644 index 0000000000..9807da3fc4 --- /dev/null +++ b/src/cosh-ng/crates/cosh-shell/src/auth/ecs_poll.rs @@ -0,0 +1,668 @@ +use std::io::{self, Write}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +use crate::adapter::{AdapterInstance, EcsProbeTask}; +use crate::i18n::{I18n, MessageId}; +use crate::runtime::prelude::AuthResponse; +use crate::runtime::state::InlineState; + +use super::completion::finish_auth_configuration; +use super::menu::{EcsRamRolePrepare, SysomMenu}; +use super::prompt::{clear_active_auth_panel, render_current_auth_panel}; +use super::provider_management::{core_auth_configure, AuthConfigureFailure}; +use super::runtime::{self, AuthBackend, AuthPhase, RuntimeAuthState}; + +const INTERVAL: Duration = Duration::from_secs(2); +const WAIT_LIMIT: Duration = Duration::from_secs(200); +const OPERATION_LIMIT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Stage { + Preparing, + Checking, + Waiting, + Submitting, + Cancelling, + TimedOut, + Failed, + Unknown, + Editing, +} + +#[derive(Debug)] +enum Operation { + Probe(EcsProbeTask, bool), + Configure(Option>>), +} + +impl Drop for Operation { + fn drop(&mut self) { + if let Self::Configure(worker) = self { + if let Some(worker) = worker.take() { + if worker.join().is_err() { + tracing::warn!("ECS configuration worker failed during shutdown"); + } + } + } + } +} + +#[derive(Debug)] +pub(super) struct EcsFlow { + id: String, + stage: Stage, + operation: Option, + operation_deadline: Option, + deadline: Option, + next_check: Instant, + stop_stage: Stage, + error: Option, + challenge: Option, + waiting_for_refresh: bool, + cleanup_reported: bool, +} + +impl EcsFlow { + fn new(auth: &RuntimeAuthState, now: Instant) -> Self { + let challenge = match &auth.phase { + AuthPhase::AliyunEcsChallenge { + instance_id, + console_url, + } => Some(EcsRamRolePrepare { + instance_id: instance_id.clone(), + console_url: console_url.clone(), + values: Default::default(), + }), + _ => None, + }; + Self { + id: auth.id.clone(), + stage: if challenge.is_some() { + Stage::Checking + } else { + Stage::Preparing + }, + operation: None, + operation_deadline: None, + // WAIT_LIMIT budgets waiting for role authorization, so it starts with a + // challenge; prepare stays bounded by its per-operation deadline. + deadline: challenge.as_ref().map(|_| now + WAIT_LIMIT), + next_check: now, + stop_stage: Stage::Cancelling, + error: None, + challenge, + waiting_for_refresh: false, + cleanup_reported: false, + } + } + + fn stop(&mut self, destination: Stage, now: Instant) { + if self.stage == Stage::Cancelling { + if destination == Stage::Cancelling { + self.stop_stage = Stage::Cancelling; + } + return; + } + if let Some(Operation::Probe(task, _)) = &self.operation { + task.cancel(); + } + self.stage = Stage::Cancelling; + self.stop_stage = destination; + self.operation_deadline = Some(now + OPERATION_LIMIT); + } +} + +pub(super) fn handles(auth: &RuntimeAuthState) -> bool { + matches!( + auth.phase, + AuthPhase::PreparingMenu + | AuthPhase::AliyunEcsPreparing + | AuthPhase::AliyunEcsChallenge { .. } + ) +} + +pub(super) fn option_count(state: &InlineState) -> usize { + usize::from(state.auth.ecs.as_ref().is_some_and(|flow| { + flow.operation.is_none() + && matches!(flow.stage, Stage::TimedOut | Stage::Failed | Stage::Unknown) + })) +} + +pub(super) fn question(state: &InlineState) -> (String, Vec, bool) { + let i18n = I18n::new(state.language); + let flow = state.auth.ecs.as_ref(); + let stage = flow.map_or(Stage::Checking, |flow| flow.stage); + let id = match stage { + Stage::Preparing | Stage::Checking => MessageId::AuthEcsChecking, + Stage::Waiting if flow.is_some_and(|flow| flow.waiting_for_refresh) => { + MessageId::AuthEcsRefreshing + } + Stage::Waiting => MessageId::AuthEcsWaiting, + Stage::Submitting => MessageId::AuthEcsSaving, + Stage::Cancelling if flow.is_some_and(|flow| flow.cleanup_reported) => { + MessageId::AuthEcsCleanupFailed + } + Stage::Cancelling => MessageId::AuthEcsCancelling, + Stage::TimedOut => MessageId::AuthEcsTimedOut, + Stage::Unknown if flow.is_some_and(|flow| flow.operation.is_some()) => { + MessageId::AuthEcsCleanupFailed + } + Stage::Unknown => MessageId::AuthEcsUnknown, + _ => MessageId::AuthEcsFailed, + }; + let mut text = i18n.t(id).to_string(); + if let Some(error) = flow.and_then(|flow| flow.error.as_ref()) { + text.push('\n'); + text.push_str(error); + } + if !matches!(stage, Stage::Submitting | Stage::Cancelling) { + text.push('\n'); + text.push_str(i18n.t(MessageId::AuthEcsCancelHint)); + } + let options = if option_count(state) == 1 { + vec![i18n + .t(if stage == Stage::Unknown { + MessageId::AuthEcsReturn + } else { + MessageId::AuthEcsRetry + }) + .to_string()] + } else { + Vec::new() + }; + ( + text, + options, + matches!(stage, Stage::Waiting | Stage::TimedOut) + && !flow.is_some_and(|flow| flow.waiting_for_refresh), + ) +} + +fn redraw(state: &mut InlineState, output: &mut W) -> io::Result<()> { + let cancelling = state + .auth + .ecs + .as_ref() + .is_some_and(|flow| flow.stage == Stage::Cancelling); + // Cancellation finishes the submitted capture instead of arming a new input owner. + if !cancelling { + if let Some(auth) = state.auth.state.as_mut() { + auth.field_capture_revision = auth.field_capture_revision.wrapping_add(1); + } + } + clear_active_auth_panel(state, output)?; + render_current_auth_panel(state, output) +} + +pub(super) fn cancel(state: &mut InlineState, output: &mut W) -> io::Result { + let Some(flow) = state.auth.ecs.as_mut() else { + return Ok(false); + }; + if matches!(flow.operation, Some(Operation::Configure(_))) || flow.stage == Stage::Submitting { + return Ok(true); + } + flow.stop(Stage::Cancelling, Instant::now()); + if flow.operation.is_none() { + state.auth.ecs = None; + return Ok(false); + } + redraw(state, output)?; + Ok(true) +} + +pub(super) fn answer( + adapter: &AdapterInstance, + state: &mut InlineState, + output: &mut W, +) -> io::Result<()> { + if option_count(state) == 0 { + return Ok(()); + } + let Some(flow) = state.auth.ecs.as_mut() else { + return Ok(()); + }; + if flow.stage == Stage::Unknown { + state.auth.ecs = None; + state.auth.state = None; + clear_active_auth_panel(state, output)?; + return runtime::trigger_auth_from_slash(adapter, state, output); + } + flow.stage = if flow.challenge.is_some() { + Stage::Checking + } else { + Stage::Preparing + }; + let now = Instant::now(); + flow.deadline = flow.challenge.as_ref().map(|_| now + WAIT_LIMIT); + flow.next_check = now; + flow.error = None; + flow.cleanup_reported = false; + redraw(state, output) +} + +pub(super) fn start_configuration( + adapter: &AdapterInstance, + state: &mut InlineState, + output: &mut W, +) -> io::Result<()> { + if state.shell_exited { + return Ok(()); + } + let Some(auth) = state.auth.state.as_ref() else { + return Ok(()); + }; + let mut flow = state + .auth + .ecs + .take() + .unwrap_or_else(|| EcsFlow::new(auth, Instant::now())); + if flow.operation.is_some() { + state.auth.ecs = Some(flow); + return Ok(()); + } + let response = AuthResponse { + request_id: auth.request_id.clone(), + provider_id: auth + .editing_provider_name + .clone() + .or_else(|| auth.collected_values.get("provider_id").cloned()) + .unwrap_or_else(|| auth.current_provider().id.clone()), + provider_type: Some(auth.current_provider().id.clone()), + values: auth.collected_values.clone(), + persist: true, + }; + let adapter = adapter.clone(); + match thread::Builder::new() + .name("cosh-auth-ecs-save".into()) + .spawn(move || core_auth_configure(&adapter, &response)) + { + Ok(worker) => { + flow.operation = Some(Operation::Configure(Some(worker))); + flow.operation_deadline = Some(Instant::now() + Duration::from_secs(12)); + flow.stage = Stage::Submitting; + if let (Some(auth), Some(challenge)) = + (state.auth.state.as_mut(), flow.challenge.as_ref()) + { + set_challenge(auth, challenge.clone()); + } + } + Err(_) => { + flow.stage = Stage::Failed; + flow.error = Some("Unable to start configuration worker".into()); + } + } + state.auth.ecs = Some(flow); + redraw(state, output) +} + +pub(super) fn set_challenge(auth: &mut RuntimeAuthState, prepare: EcsRamRolePrepare) { + auth.collected_values + .insert("auth_source".into(), "ecs_ram_role".into()); + for key in ["access_key_id", "access_key_secret", "security_token"] { + auth.collected_values.remove(key); + } + auth.phase = AuthPhase::AliyunEcsChallenge { + instance_id: prepare.instance_id, + console_url: prepare.console_url, + }; +} + +enum Reply { + Prepared(Result), + Verified(Result), + Configured(Result<(), AuthConfigureFailure>), +} + +fn take_reply(flow: &mut EcsFlow) -> Option { + let reply = match flow.operation.as_mut()? { + Operation::Probe(task, prepare) => { + let result = task.try_finish()?; + if *prepare { + Reply::Prepared(result) + } else { + Reply::Verified(result) + } + } + Operation::Configure(worker) => { + if !worker.as_ref()?.is_finished() { + return None; + } + Reply::Configured(worker.take()?.join().unwrap_or_else(|_| { + Err(AuthConfigureFailure { + message: "Configuration worker failed; save result is unknown".into(), + code: None, + }) + })) + } + }; + flow.operation = None; + flow.operation_deadline = None; + Some(reply) +} + +pub(crate) fn poll( + adapter: &AdapterInstance, + state: &mut InlineState, + output: &mut W, +) -> io::Result<()> { + let now = Instant::now(); + let orphaned = state.auth.ecs.as_ref().is_some_and(|flow| { + state + .auth + .state + .as_ref() + .is_none_or(|auth| auth.id != flow.id) + }); + if orphaned { + let mut flow = state.auth.ecs.take().expect("orphaned flow present"); + if flow.stage != Stage::Cancelling { + flow.stop(Stage::Cancelling, now); + } + let _ = take_reply(&mut flow); + if flow.operation.is_some() { + if flow + .operation_deadline + .is_some_and(|deadline| now >= deadline) + { + flow.cleanup_reported = true; + } + state.auth.ecs = Some(flow); + } + return Ok(()); + } + let Some(auth) = state.auth.state.as_ref() else { + return Ok(()); + }; + if !handles(auth) && state.auth.ecs.is_none() { + return Ok(()); + } + let preparing_menu = auth.phase == AuthPhase::PreparingMenu; + let mut flow = state + .auth + .ecs + .take() + .unwrap_or_else(|| EcsFlow::new(auth, now)); + let previous = flow.stage; + let was_waiting_for_refresh = flow.waiting_for_refresh; + let had_operation = flow.operation.is_some(); + if !handles(auth) + && flow.operation.is_none() + && (auth.phase != AuthPhase::FillingField + || auth.collected_values.get("auth_source").map(String::as_str) != Some("ecs_ram_role")) + { + return Ok(()); + } + if flow.stage == Stage::Editing && handles(auth) { + flow.stage = Stage::Checking; + flow.deadline = Some(now + WAIT_LIMIT); + flow.next_check = now; + flow.error = None; + } + if state.shell_exited { + // Closing can cancel a read-only probe, never a save or its unknown result. + if matches!(flow.operation, Some(Operation::Probe(..))) + || matches!( + flow.stage, + Stage::Preparing | Stage::Checking | Stage::Waiting | Stage::Cancelling + ) + { + flow.stop(Stage::Cancelling, now); + } + } else if matches!( + flow.stage, + Stage::Preparing | Stage::Checking | Stage::Waiting + ) && flow.deadline.is_some_and(|deadline| now >= deadline) + { + flow.stop(Stage::TimedOut, now); + } else if matches!( + flow.stage, + Stage::Preparing | Stage::Checking | Stage::Waiting + ) && flow + .operation_deadline + .is_some_and(|deadline| now >= deadline) + { + flow.error = Some("ECS credential check timed out".into()); + flow.stop(Stage::Failed, now); + } + let completed_save = if matches!(flow.stage, Stage::Submitting | Stage::Unknown) { + take_reply(&mut flow) + } else { + None + }; + if flow.stage == Stage::Submitting + && completed_save.is_none() + && flow + .operation_deadline + .is_some_and(|deadline| now >= deadline) + { + flow.stage = Stage::Unknown; + } + let reply = completed_save.or_else(|| take_reply(&mut flow)); + if flow.stage == Stage::Cancelling { + if flow.operation.is_none() { + if flow.stop_stage == Stage::Cancelling { + return runtime::cancel_auth_panel(state, output); + } + flow.stage = flow.stop_stage; + } else if !flow.cleanup_reported + && flow + .operation_deadline + .is_some_and(|deadline| now >= deadline) + { + flow.cleanup_reported = true; + state.auth.ecs = Some(flow); + return redraw(state, output); + } + } else if let Some(reply) = reply { + match reply { + Reply::Prepared(result) if preparing_menu => { + runtime::finish_sysom_menu_prepare( + state + .auth + .state + .as_mut() + .expect("auth retained while preparing menu"), + result, + ); + return redraw(state, output); + } + Reply::Prepared(Ok(value)) if value["mode"] == "manual" => { + let auth = state + .auth + .state + .as_mut() + .expect("auth retained while preparing"); + auth.sysom = SysomMenu::on_manual(); + auth.collected_values.remove("auth_source"); + auth.phase = AuthPhase::FillingField; + auth.current_field = auth.first_editable_field(); + if auth + .current_field_info() + .is_some_and(|field| field.name == "provider_id") + && auth.collected_values.contains_key("provider_id") + { + auth.current_field = auth.editable_field_at_or_after(auth.current_field + 1); + } + auth.load_current_field_input(); + return redraw(state, output); + } + Reply::Prepared(Ok(value)) => { + if let (Some(instance), Some(url)) = + (value["instance_id"].as_str(), value["console_url"].as_str()) + { + if value["mode"] == "ecs_ram_role" && !instance.is_empty() && !url.is_empty() { + let prepare = EcsRamRolePrepare { + instance_id: instance.into(), + console_url: url.into(), + values: Default::default(), + }; + set_challenge( + state.auth.state.as_mut().expect("auth retained"), + prepare.clone(), + ); + flow.challenge = Some(prepare); + flow.deadline = Some(now + WAIT_LIMIT); + flow.stage = Stage::Checking; + flow.next_check = now; + } else { + flow.stage = Stage::Failed; + } + } else { + flow.stage = Stage::Failed; + } + } + Reply::Verified(Ok(value)) if value["status"] == "ready" => { + let active = state + .auth + .state + .as_ref() + .is_some_and(|auth| auth.backend == AuthBackend::ActiveRun); + if active { + clear_active_auth_panel(state, output)?; + return runtime::send_auth_response(Some(adapter), state, output); + } + state.auth.ecs = Some(flow); + return start_configuration(adapter, state, output); + } + Reply::Verified(Ok(value)) + if value["status"] == "not_ready" + && matches!( + value["reason"].as_str(), + Some("role_missing" | "credentials_expired") + ) => + { + flow.stage = Stage::Waiting; + flow.waiting_for_refresh = value["reason"] == "credentials_expired"; + flow.next_check = now + INTERVAL; + } + Reply::Prepared(Err(error)) | Reply::Verified(Err(error)) => { + flow.stage = Stage::Failed; + flow.error = Some(error); + } + Reply::Verified(Ok(_)) => { + flow.stage = Stage::Failed; + flow.error = Some("Invalid ECS verification result".into()); + } + Reply::Configured(result) => match result { + Ok(()) => { + let auth = state.auth.state.take().expect("auth retained while saving"); + let label = auth.current_provider().label.clone(); + state.auth.completed_ids.insert(auth.id); + runtime::clear_observed_model_after_provider_change(state); + clear_active_auth_panel(state, output)?; + return finish_auth_configuration(state, output, &label); + } + Err(error) if error.code.as_deref() == Some("credential_source_unavailable") => { + flow.stage = Stage::Checking; + flow.waiting_for_refresh = false; + flow.next_check = now + INTERVAL; + } + Err(error) => { + let auth = state + .auth + .state + .as_mut() + .expect("auth retained while saving"); + let field = error + .focus_field(&auth.current_provider().id) + .and_then(|name| { + auth.current_provider() + .fields + .iter() + .position(|field| field.name == name && !field.secret) + }); + if let Some(index) = field { + if auth.current_provider().fields[index].name == "provider_id" + && auth.editing_provider_name.is_none() + { + auth.default_provider_id = false; + } + if auth.field_is_editable(index) { + auth.phase = AuthPhase::FillingField; + auth.current_field = index; + auth.load_current_field_input(); + auth.field_error = Some(error.message.clone()); + flow.stage = Stage::Editing; + } else { + flow.stage = Stage::Failed; + } + } else { + flow.stage = if error.code.is_none() { + Stage::Unknown + } else { + Stage::Failed + }; + } + flow.error = Some(error.message); + } + }, + } + } + if !state.shell_exited + && flow.operation.is_none() + && now >= flow.next_check + && matches!( + flow.stage, + Stage::Preparing | Stage::Checking | Stage::Waiting + ) + { + let prepare = flow.stage == Stage::Preparing; + let result = match adapter { + AdapterInstance::CoshCore(core) => { + core.start_ecs_probe(if prepare { "prepare" } else { "verify" }) + } + _ => Err(io::Error::other("ECS authentication requires cosh-core")), + }; + match result { + Ok(task) => { + flow.operation = Some(Operation::Probe(task, prepare)); + flow.operation_deadline = Some(now + OPERATION_LIMIT); + } + Err(_) => { + flow.stage = Stage::Failed; + flow.error = Some("Unable to start ECS credential check".into()); + } + } + } + if preparing_menu && flow.stage == Stage::Failed && flow.operation.is_none() { + runtime::finish_sysom_menu_prepare( + state + .auth + .state + .as_mut() + .expect("auth retained while preparing menu"), + Err(flow + .error + .take() + .unwrap_or_else(|| "ECS menu prepare failed".into())), + ); + return redraw(state, output); + } + let changed = previous != flow.stage + || was_waiting_for_refresh != flow.waiting_for_refresh + || (previous == Stage::Unknown && had_operation && flow.operation.is_none()); + state.auth.ecs = Some(flow); + let width_changed = state.questions.active_panel_width.is_some_and(|width| { + width != crate::ui::RatatuiInlineRenderer::for_terminal().panel_standard_width() + }); + if changed || width_changed { + redraw(state, output)?; + } + Ok(()) +} + +pub(crate) fn shutdown(state: &mut InlineState) { + if let Some(mut flow) = state.auth.ecs.take() { + if let Some(Operation::Probe(task, _)) = &flow.operation { + task.cancel(); + } + flow.operation.take(); + } +} + +#[cfg(test)] +#[path = "ecs_poll_tests.rs"] +mod tests; diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/ecs_poll_tests.rs b/src/cosh-ng/crates/cosh-shell/src/auth/ecs_poll_tests.rs new file mode 100644 index 0000000000..6cfaeeec78 --- /dev/null +++ b/src/cosh-ng/crates/cosh-shell/src/auth/ecs_poll_tests.rs @@ -0,0 +1,1235 @@ +use super::*; +use crate::adapter::FakeAgentAdapter; +use crate::runtime::prelude::AuthProviderInfo; +use std::collections::HashMap; + +fn state(id: &str) -> InlineState { + let mut state = InlineState::default(); + state.auth.state = Some(RuntimeAuthState { + id: id.into(), + request_id: id.into(), + phase: AuthPhase::AliyunEcsChallenge { + instance_id: "i-test".into(), + console_url: "https://example.invalid/authorize".into(), + }, + providers: vec![AuthProviderInfo { + id: "aliyun".into(), + label: "Aliyun".into(), + description: None, + description_zh_cn: None, + builtin_base_url: None, + fields: Vec::new(), + }], + selected_provider: 0, + current_field: 0, + collected_values: HashMap::from([ + ("auth_source".into(), "ecs_ram_role".into()), + ("provider_id".into(), "aliyun".into()), + ]), + field_input: String::new(), + field_error: None, + field_capture_revision: 0, + existing_providers: Vec::new(), + editing_provider_name: None, + default_provider_id: true, + from_sysom_shortcut: false, + error_message: None, + backend: AuthBackend::CoreRegistry, + sysom: SysomMenu::default(), + }); + state.auth.ecs = Some(EcsFlow::new( + state.auth.state.as_ref().unwrap(), + Instant::now(), + )); + state +} + +#[test] +fn slash_auth_installs_cancellable_empty_capture_before_menu_prepare() { + let (dir, core) = probe_fixture(Value::Null); + // Both replies are immediate: this asserts ordering, not a timing threshold. + std::fs::write( + dir.path().join("registry.sh"), + r#"#!/bin/sh +read -r request +printf '%s\n' "$request" >> "$0.calls" +request_id=${request#*\"request_id\":\"} +request_id=${request_id%%\"*} +case "$request" in + *'"action":"state"'*) + data='{"templates":[{"id":"aliyun","label":"Aliyun","fields":[]}],"saved_providers":[]}' ;; + *'"action":"prepare"'*) data='{"mode":"manual"}' ;; + *) exit 1 ;; +esac +printf '{"type":"registry_response","request_id":"%s","success":true,"data":%s}\n' "$request_id" "$data" +"#, + ) + .unwrap(); + let adapter = AdapterInstance::CoshCore(core); + let mut state = InlineState::default(); + let mut output = Vec::new(); + + runtime::trigger_auth_from_slash(&adapter, &mut state, &mut output).unwrap(); + + let calls = registry_calls(&dir); + let actions: Vec<_> = calls.iter().map(|call| call["action"].as_str()).collect(); + assert_eq!( + actions, + vec![Some("state")], + "the slash handler may read config, but must defer prepare until poll" + ); + let capture = runtime::pending_auth_capture(&state).expect("initial auth capture"); + let crate::runtime::prelude::RawInputCapture::Question { + id, + option_count: 0, + allow_free_text: false, + multiple: false, + secret: false, + .. + } = capture + else { + panic!("menu prepare must have a non-editable capture with no options"); + }; + assert!(state.auth.state.is_some()); + assert_eq!( + state.questions.active_panel_id.as_deref(), + Some(id.as_str()) + ); + assert!(state.questions.active_panel_height > 0); + assert!( + !output.is_empty(), + "the initial panel must already be rendered" + ); + + runtime::cancel_auth_panel(&mut state, &mut output).unwrap(); + assert!(state.auth.state.is_none()); + assert!(state.auth.ecs.is_none()); + poll(&adapter, &mut state, &mut output).unwrap(); + assert!(runtime::pending_auth_capture(&state).is_none()); + assert_eq!(registry_calls(&dir), calls, "cancel must not start prepare"); + assert!(String::from_utf8(output) + .unwrap() + .contains("Auth cancelled")); +} + +fn menu_state(id: &str) -> InlineState { + let mut state = state(id); + let auth = state.auth.state.as_mut().unwrap(); + auth.phase = AuthPhase::PreparingMenu; + auth.collected_values.clear(); + state.auth.ecs = Some(EcsFlow::new(auth, Instant::now())); + state +} + +#[test] +fn menu_prepare_results_return_to_user_choice_without_verifying_or_saving() { + for (data, rejected, expected_phase, cached) in [ + ( + serde_json::json!({"mode": "manual"}), + false, + AuthPhase::SelectingProvider, + true, + ), + ( + serde_json::json!({"mode": "ecs_ram_role", "instance_id": "i-menu", "console_url": "https://example.invalid/authorize"}), + false, + AuthPhase::ManagingProviders, + true, + ), + (Value::Null, false, AuthPhase::SelectingProvider, false), + ( + serde_json::json!({"error_code": "metadata_access_denied"}), + true, + AuthPhase::SelectingProvider, + false, + ), + ] { + let (dir, core) = probe_fixture(data); + if rejected { + let path = dir.path().join("registry.sh"); + let script = std::fs::read_to_string(&path).unwrap(); + std::fs::write( + path, + script.replace("\"success\":true", "\"success\":false"), + ) + .unwrap(); + } + let mut state = menu_state("menu-result"); + state.auth.ecs.as_mut().unwrap().operation = Some(Operation::Probe( + core.start_ecs_probe("prepare").unwrap(), + true, + )); + let adapter = AdapterInstance::CoshCore(core); + let deadline = Instant::now() + Duration::from_secs(2); + let mut output = Vec::new(); + while state.auth.ecs.is_some() { + assert!(Instant::now() < deadline); + poll(&adapter, &mut state, &mut output).unwrap(); + thread::yield_now(); + } + let auth = state.auth.state.as_ref().unwrap(); + assert_eq!(auth.phase, expected_phase); + assert_eq!(auth.sysom.prefetched().is_some(), cached); + assert!(auth.collected_values.is_empty()); + assert!(!state.auth.completed_ids.contains("menu-result")); + poll(&adapter, &mut state, &mut output).unwrap(); + let calls = registry_calls(&dir); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0]["action"], "prepare"); + assert!(String::from_utf8(output) + .unwrap() + .contains("Select your AI provider:")); + } +} + +#[test] +fn menu_prepare_timeout_reaps_before_returning_to_the_menu() { + let (dir, core) = probe_fixture(Value::Null); + std::fs::write(dir.path().join("registry.sh"), "#!/bin/sh\nread -r request\nprintf '%s\\n' \"$request\" >> \"$0.calls\"\nprintf '%s\\n' \"$$\" > \"$0.pid\"\nexec sleep 60\n").unwrap(); + let mut state = menu_state("menu-timeout"); + state.auth.ecs.as_mut().unwrap().operation = Some(Operation::Probe( + core.start_ecs_probe("prepare").unwrap(), + true, + )); + let pid = probe_pid(&dir); + state.auth.ecs.as_mut().unwrap().operation_deadline = Some(Instant::now()); + let adapter = AdapterInstance::CoshCore(core); + let deadline = Instant::now() + Duration::from_secs(2); + while state.auth.ecs.is_some() { + assert!(Instant::now() < deadline); + assert_eq!( + state.auth.state.as_ref().unwrap().phase, + AuthPhase::PreparingMenu + ); + poll(&adapter, &mut state, &mut Vec::new()).unwrap(); + thread::yield_now(); + } + assert_probe_reaped(pid); + assert_eq!( + state.auth.state.as_ref().unwrap().phase, + AuthPhase::SelectingProvider + ); + assert_eq!(registry_calls(&dir).len(), 1); + assert!(!state.auth.completed_ids.contains("menu-timeout")); +} + +#[cfg(target_os = "linux")] +#[test] +fn menu_prepare_completed_reply_cannot_reopen_after_cancel_or_exit() { + for shell_exited in [false, true] { + let (dir, core) = probe_fixture(serde_json::json!({"mode": "manual"})); + let mut state = menu_state("menu-cancel"); + state.auth.ecs.as_mut().unwrap().operation = Some(Operation::Probe( + core.start_ecs_probe("prepare").unwrap(), + true, + )); + await_unconsumed_ready_probe(&dir); + let adapter = AdapterInstance::CoshCore(core); + let mut output = Vec::new(); + if shell_exited { + state.shell_exited = true; + } else { + runtime::cancel_auth_panel(&mut state, &mut output).unwrap(); + } + poll(&adapter, &mut state, &mut output).unwrap(); + assert!(state.auth.state.is_none()); + assert!(state.auth.ecs.is_none()); + output.clear(); + poll(&adapter, &mut state, &mut output).unwrap(); + assert!(output.is_empty()); + assert_eq!(registry_calls(&dir).len(), 1); + } +} + +#[test] +fn cancellation_keeps_the_submitted_capture_until_cleanup_finishes() { + for menu in [true, false] { + let (dir, core) = probe_fixture(Value::Null); + let mut state = if menu { + menu_state("capture-cancel") + } else { + state("capture-cancel") + }; + state.auth.ecs.as_mut().unwrap().operation = Some(Operation::Probe( + core.start_ecs_probe("prepare").unwrap(), + true, + )); + let pid = probe_pid(&dir); + let mut output = Vec::new(); + render_current_auth_panel(&mut state, &mut output).unwrap(); + let capture = runtime::pending_auth_capture(&state).unwrap(); + runtime::cancel_auth_panel(&mut state, &mut output).unwrap(); + assert_eq!(runtime::pending_auth_capture(&state), Some(capture.clone())); + state.auth.ecs.as_mut().unwrap().cleanup_reported = true; + redraw(&mut state, &mut output).unwrap(); + assert_eq!(runtime::pending_auth_capture(&state), Some(capture)); + let adapter = AdapterInstance::CoshCore(core); + let deadline = Instant::now() + Duration::from_secs(2); + while state.auth.ecs.is_some() { + assert!(Instant::now() < deadline); + poll(&adapter, &mut state, &mut output).unwrap(); + thread::yield_now(); + } + assert!(runtime::pending_auth_capture(&state).is_none()); + assert_probe_reaped(pid); + } +} + +#[test] +fn cancellation_overrides_cleanup_destination_without_resetting_deadline() { + let mut state = menu_state("cleanup-priority"); + let flow = state.auth.ecs.as_mut().unwrap(); + let now = Instant::now(); + flow.stop(Stage::Failed, now); + let deadline = flow.operation_deadline; + flow.stop(Stage::Cancelling, now + Duration::from_secs(1)); + assert_eq!(flow.stop_stage, Stage::Cancelling); + assert_eq!(flow.operation_deadline, deadline); + flow.stop(Stage::TimedOut, now + Duration::from_secs(2)); + assert_eq!(flow.stop_stage, Stage::Cancelling); + assert_eq!(flow.operation_deadline, deadline); +} + +#[cfg(target_os = "linux")] +#[test] +fn timeout_cleanup_then_cancel_or_exit_never_returns_to_auth() { + for menu in [true, false] { + for shell_exited in [false, true] { + let (dir, core) = probe_fixture(serde_json::json!({"mode": "manual"})); + let mut state = if menu { + menu_state("timeout-cancel") + } else { + state("timeout-cancel") + }; + state.auth.ecs.as_mut().unwrap().operation = Some(Operation::Probe( + core.start_ecs_probe("prepare").unwrap(), + true, + )); + await_unconsumed_ready_probe(&dir); + state + .auth + .ecs + .as_mut() + .unwrap() + .stop(Stage::Failed, Instant::now()); + let adapter = AdapterInstance::CoshCore(core); + let mut output = Vec::new(); + if shell_exited { + state.shell_exited = true; + } else { + runtime::cancel_auth_panel(&mut state, &mut output).unwrap(); + } + poll(&adapter, &mut state, &mut output).unwrap(); + assert!( + state.auth.state.is_none(), + "cancel/exit must override the pending timeout result" + ); + assert!(state.auth.ecs.is_none()); + assert!(!String::from_utf8(output) + .unwrap() + .contains("Select your AI provider:")); + assert_eq!(registry_calls(&dir).len(), 1); + } + } +} + +#[test] +fn orphaned_idle_flow_is_removed_without_an_auth_panel() { + let mut state = state("old"); + state.auth.state = None; + poll( + &AdapterInstance::Fake(FakeAgentAdapter), + &mut state, + &mut Vec::new(), + ) + .unwrap(); + assert!(state.auth.ecs.is_none()); +} + +#[test] +fn stale_flow_cleanup_does_not_cancel_a_new_auth_owner() { + let old = state("old"); + let mut current = state("new"); + current.auth.ecs = old.auth.ecs; + poll( + &AdapterInstance::Fake(FakeAgentAdapter), + &mut current, + &mut Vec::new(), + ) + .unwrap(); + assert_eq!( + current.auth.state.as_ref().map(|auth| auth.id.as_str()), + Some("new") + ); + assert!(!current.auth.completed_ids.contains("new")); +} + +#[test] +fn waiting_panel_is_redrawn_when_recorded_width_changes() { + let mut state = state("resize"); + let flow = state.auth.ecs.as_mut().unwrap(); + flow.stage = Stage::Waiting; + flow.next_check = Instant::now() + Duration::from_secs(60); + state.questions.active_panel_id = Some("resize".into()); + state.questions.active_panel_height = 4; + state.questions.active_panel_width = Some(1); + let mut output = Vec::new(); + poll( + &AdapterInstance::Fake(FakeAgentAdapter), + &mut state, + &mut output, + ) + .unwrap(); + assert!(String::from_utf8(output.clone()) + .unwrap() + .contains("Waiting for ECS RAM Role")); + assert_ne!(state.questions.active_panel_width, Some(1)); + output.clear(); + poll( + &AdapterInstance::Fake(FakeAgentAdapter), + &mut state, + &mut output, + ) + .unwrap(); + assert!( + output.is_empty(), + "unchanged width must not repaint the QR panel" + ); +} + +#[test] +fn reconfirmed_identity_leaves_the_ecs_field_error_stage() { + let mut state = state("identity-retry"); + let flow = state.auth.ecs.as_mut().unwrap(); + flow.stage = Stage::Editing; + flow.error = Some("invalid provider name".into()); + poll( + &AdapterInstance::Fake(FakeAgentAdapter), + &mut state, + &mut Vec::new(), + ) + .unwrap(); + let flow = state.auth.ecs.as_ref().unwrap(); + assert_ne!( + flow.stage, + Stage::Editing, + "returning to the ECS phase must restart verification" + ); +} + +#[test] +fn confirmed_save_resolves_an_earlier_unknown_outcome() { + let mut state = state("late-save"); + let worker = thread::spawn(|| Ok(())); + let deadline = Instant::now() + Duration::from_secs(2); + while !worker.is_finished() { + assert!(Instant::now() < deadline); + thread::yield_now(); + } + let flow = state.auth.ecs.as_mut().unwrap(); + flow.stage = Stage::Unknown; + flow.operation = Some(Operation::Configure(Some(worker))); + poll( + &AdapterInstance::Fake(FakeAgentAdapter), + &mut state, + &mut Vec::new(), + ) + .unwrap(); + assert!( + state.auth.state.is_none(), + "an authoritative save reply must resolve uncertainty" + ); + assert!(state.auth.completed_ids.contains("late-save")); +} + +#[test] +fn completed_save_is_consumed_before_the_observer_timeout() { + let mut state = state("save"); + let worker = thread::spawn(|| Ok(())); + let deadline = Instant::now() + Duration::from_secs(2); + while !worker.is_finished() { + assert!(Instant::now() < deadline); + thread::yield_now(); + } + let flow = state.auth.ecs.as_mut().unwrap(); + flow.stage = Stage::Submitting; + flow.operation = Some(Operation::Configure(Some(worker))); + flow.operation_deadline = Some(Instant::now() - Duration::from_millis(1)); + poll( + &AdapterInstance::Fake(FakeAgentAdapter), + &mut state, + &mut Vec::new(), + ) + .unwrap(); + assert!(state.auth.state.is_none()); + assert!(state.auth.completed_ids.contains("save")); +} + +fn completed_configuration(state: &mut InlineState, result: Result<(), AuthConfigureFailure>) { + let worker = thread::spawn(move || result); + let deadline = Instant::now() + Duration::from_secs(2); + while !worker.is_finished() { + assert!(Instant::now() < deadline); + thread::yield_now(); + } + let flow = state.auth.ecs.as_mut().unwrap(); + flow.stage = Stage::Submitting; + flow.operation = Some(Operation::Configure(Some(worker))); +} + +#[test] +fn shell_exit_consumes_successful_configuration_without_cancelling() { + for stage in [Stage::Submitting, Stage::Unknown] { + let mut state = state("closed-save"); + completed_configuration(&mut state, Ok(())); + state.auth.ecs.as_mut().unwrap().stage = stage; + state.shell_exited = true; + let mut output = Vec::new(); + poll( + &AdapterInstance::Fake(FakeAgentAdapter), + &mut state, + &mut output, + ) + .unwrap(); + assert!(state.auth.state.is_none()); + assert!(state.auth.completed_ids.contains("closed-save")); + assert!(!String::from_utf8(output) + .unwrap() + .contains("Auth cancelled")); + } +} + +#[test] +fn shell_exit_keeps_pending_configuration_owned_until_completion() { + let mut state = state("pending-save"); + let (send, receive) = std::sync::mpsc::channel(); + let worker = thread::spawn(move || { + receive.recv_timeout(Duration::from_secs(2)).unwrap(); + Ok(()) + }); + let flow = state.auth.ecs.as_mut().unwrap(); + flow.stage = Stage::Submitting; + flow.operation = Some(Operation::Configure(Some(worker))); + flow.operation_deadline = Some(Instant::now() + Duration::from_secs(12)); + state.shell_exited = true; + let adapter = AdapterInstance::Fake(FakeAgentAdapter); + let mut output = Vec::new(); + poll(&adapter, &mut state, &mut output).unwrap(); + let stage = state.auth.ecs.as_ref().unwrap().stage; + let owned = matches!( + state.auth.ecs.as_ref().unwrap().operation, + Some(Operation::Configure(_)) + ); + send.send(()).unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + while state.auth.ecs.is_some() { + assert!(Instant::now() < deadline); + poll(&adapter, &mut state, &mut output).unwrap(); + thread::yield_now(); + } + assert!(owned); + assert_eq!(stage, Stage::Submitting); + assert!(!String::from_utf8(output) + .unwrap() + .contains("Auth cancelled")); +} + +#[test] +fn shell_exit_preserves_unknown_save_result_without_scheduling() { + let mut state = state("unknown-save"); + completed_configuration( + &mut state, + Err(AuthConfigureFailure { + message: "Save result is unknown".into(), + code: None, + }), + ); + state.shell_exited = true; + let adapter = AdapterInstance::Fake(FakeAgentAdapter); + let mut output = Vec::new(); + poll(&adapter, &mut state, &mut output).unwrap(); + poll(&adapter, &mut state, &mut output).unwrap(); + let flow = state + .auth + .ecs + .as_ref() + .expect("unknown save must not be cancelled"); + assert_eq!(flow.stage, Stage::Unknown); + assert!(flow.operation.is_none()); + assert!(!String::from_utf8(output) + .unwrap() + .contains("Auth cancelled")); +} + +fn probe_fixture(data: Value) -> (tempfile::TempDir, crate::adapter::CoshCoreAdapter) { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("registry.sh"); + std::fs::write(&script, format!( + r##"#!/bin/sh +read -r request +request_id=${{request#*\"request_id\":\"}} +request_id=${{request_id%%\"*}} +printf '%s\n' "$request" >> "$0.calls" +printf '%s\n' "$$" > "$0.pid" +case "$request" in + *'"action":"configure"'*) + if [ -f "$0.configure-error" ]; then + read -r code < "$0.configure-error" + [ "$code" = transport ] && exit 0 + printf '{{"type":"registry_response","request_id":"%s","success":false,"error":"fixture save rejected","data":{{"error_code":"%s"}}}}\n' "$request_id" "$code" + exit 0 + fi + ;; +esac +printf '{{"type":"registry_response","request_id":"%s","success":true,"data":{data}}}\n' "$request_id" +exec sleep 60 +"## + )).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + let core = crate::adapter::CoshCoreAdapter::new(script.to_string_lossy(), false); + (dir, core) +} + +fn attach_probe(state: &mut InlineState, core: &crate::adapter::CoshCoreAdapter) { + state.auth.ecs.as_mut().unwrap().operation = Some(Operation::Probe( + core.start_ecs_probe("verify").unwrap(), + false, + )); +} + +fn finish_probe(state: &mut InlineState) -> Vec { + finish_operation(&AdapterInstance::Fake(FakeAgentAdapter), state) +} + +fn finish_operation(adapter: &AdapterInstance, state: &mut InlineState) -> Vec { + let deadline = Instant::now() + Duration::from_secs(2); + let mut output = Vec::new(); + while state.auth.ecs.as_ref().unwrap().operation.is_some() { + assert!(Instant::now() < deadline); + poll(adapter, state, &mut output).unwrap(); + thread::yield_now(); + } + output +} + +fn registry_calls(dir: &tempfile::TempDir) -> Vec { + std::fs::read_to_string(dir.path().join("registry.sh.calls")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +fn probe_pid(dir: &tempfile::TempDir) -> i32 { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Some(pid) = std::fs::read_to_string(dir.path().join("registry.sh.pid")) + .ok() + .and_then(|pid| pid.trim().parse().ok()) + { + return pid; + } + assert!(Instant::now() < deadline, "probe fixture did not start"); + thread::yield_now(); + } +} + +fn assert_probe_reaped(pid: i32) { + assert_eq!(unsafe { nix::libc::kill(pid, 0) }, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(nix::libc::ESRCH) + ); + assert_eq!( + unsafe { nix::libc::waitpid(pid, std::ptr::null_mut(), nix::libc::WNOHANG) }, + -1 + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(nix::libc::ECHILD) + ); +} + +#[cfg(target_os = "linux")] +fn await_unconsumed_ready_probe(dir: &tempfile::TempDir) { + let pid = probe_pid(dir); + let deadline = Instant::now() + Duration::from_secs(2); + // The fixture sleeps after Ready; only the probe's response cleanup can reap it. + while unsafe { nix::libc::kill(pid, 0) } == 0 { + assert!(Instant::now() < deadline, "Ready was not read and reaped"); + thread::yield_now(); + } + assert_probe_reaped(pid); + // try_finish consumes the reply. Observe worker exit instead, as in protocol tests, + // so the very next poll races an already-completed Ready against cancel/timeout. + while std::fs::read_dir("/proc/self/task").unwrap().any(|entry| { + std::fs::read_to_string(entry.unwrap().path().join("comm")) + .is_ok_and(|name| name.starts_with("cosh-auth-ecs")) + }) { + assert!(Instant::now() < deadline, "probe worker did not terminate"); + thread::yield_now(); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn expired_deadlines_discard_completed_ready_without_configuration() { + for stage in [Stage::Checking, Stage::Waiting] { + for (total_expired, operation_expired, expected) in [ + (true, false, Stage::TimedOut), + (false, true, Stage::Failed), + (true, true, Stage::TimedOut), + ] { + let (dir, core) = probe_fixture(serde_json::json!({"status": "ready"})); + let mut state = state("expired-ready"); + state.auth.ecs.as_mut().unwrap().stage = stage; + attach_probe(&mut state, &core); + await_unconsumed_ready_probe(&dir); + let now = Instant::now(); + let flow = state.auth.ecs.as_mut().unwrap(); + flow.deadline = Some(if total_expired { + now - Duration::from_millis(1) + } else { + now + WAIT_LIMIT + }); + flow.operation_deadline = Some(if operation_expired { + now - Duration::from_millis(1) + } else { + now + OPERATION_LIMIT + }); + let adapter = AdapterInstance::CoshCore(core); + let mut output = Vec::new(); + poll(&adapter, &mut state, &mut output).unwrap(); + let flow = state.auth.ecs.as_ref().unwrap(); + assert_eq!(flow.stage, expected); + assert!( + flow.operation.is_none(), + "expired Ready must be consumed, not saved" + ); + poll(&adapter, &mut state, &mut output).unwrap(); + assert_eq!(state.auth.ecs.as_ref().unwrap().stage, expected); + assert!(state.auth.ecs.as_ref().unwrap().operation.is_none()); + assert!(state.auth.state.is_some()); + assert!(!state.auth.completed_ids.contains("expired-ready")); + assert!(!String::from_utf8(output) + .unwrap() + .contains("Auth configured")); + let calls = registry_calls(&dir); + assert_eq!(calls.len(), 1, "expired Ready must never configure"); + assert_eq!(calls[0]["action"], "verify"); + } + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cancellation_wins_over_ready_already_completed_in_the_same_poll() { + let (dir, core) = probe_fixture(serde_json::json!({"status": "ready"})); + let mut state = state("cancel-ready"); + attach_probe(&mut state, &core); + await_unconsumed_ready_probe(&dir); + let adapter = AdapterInstance::CoshCore(core); + let mut output = Vec::new(); + runtime::cancel_auth_panel(&mut state, &mut output).unwrap(); + assert_eq!(state.auth.ecs.as_ref().unwrap().stage, Stage::Cancelling); + poll(&adapter, &mut state, &mut output).unwrap(); + assert!(state.auth.ecs.is_none()); + assert!(state.auth.state.is_none()); + assert!(state.auth.completed_ids.contains("cancel-ready")); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Auth cancelled")); + assert!(!output.contains("Auth configured")); + let calls = registry_calls(&dir); + assert_eq!( + calls.len(), + 1, + "cancellation must discard Ready without saving" + ); + assert_eq!(calls[0]["action"], "verify"); +} + +#[test] +fn unavailable_configure_preserves_identity_and_deadline_and_reprobes_fast_and_waiting() { + for waited in [false, true] { + let (dir, core) = probe_fixture(serde_json::json!({ + "status": "not_ready", "reason": "role_missing" + })); + std::fs::write( + dir.path().join("registry.sh.configure-error"), + "credential_source_unavailable\n", + ) + .unwrap(); + let mut state = state("unavailable-reprobe"); + let auth = state.auth.state.as_mut().unwrap(); + auth.request_id = "original-request".into(); + auth.collected_values + .insert("provider_id".into(), "ecs-custom".into()); + auth.editing_provider_name = waited.then(|| "ecs-existing".into()); + let identity = auth.clone(); + let original_deadline = Some(Instant::now() + Duration::from_secs(60)); + state.auth.ecs.as_mut().unwrap().deadline = original_deadline; + if waited { + attach_probe(&mut state, &core); + finish_probe(&mut state); + assert_eq!(state.auth.ecs.as_ref().unwrap().stage, Stage::Waiting); + } else { + assert_eq!(state.auth.ecs.as_ref().unwrap().stage, Stage::Checking); + } + let adapter = AdapterInstance::CoshCore(core); + let mut output = Vec::new(); + start_configuration(&adapter, &mut state, &mut output).unwrap(); + output.extend(finish_operation(&adapter, &mut state)); + let flow = state.auth.ecs.as_ref().unwrap(); + assert_eq!(flow.stage, Stage::Checking); + assert_eq!(flow.deadline, original_deadline); + assert!(flow.next_check > Instant::now()); + assert!(!question(&state).2); + let calls_before_reprobe = registry_calls(&dir); + let configure = calls_before_reprobe.last().unwrap(); + assert_eq!(configure["action"], "configure"); + assert_eq!(configure["params"]["provider_type"], "aliyun"); + assert_eq!( + configure["params"]["provider_id"], + if waited { "ecs-existing" } else { "ecs-custom" } + ); + assert_eq!( + configure["params"]["values"], + serde_json::json!(identity.collected_values) + ); + poll(&adapter, &mut state, &mut output).unwrap(); + assert!(state.auth.ecs.as_ref().unwrap().operation.is_none()); + assert_eq!(registry_calls(&dir), calls_before_reprobe); + state.auth.ecs.as_mut().unwrap().next_check = Instant::now() - INTERVAL; + poll(&adapter, &mut state, &mut output).unwrap(); + assert!(matches!( + state.auth.ecs.as_ref().unwrap().operation, + Some(Operation::Probe(_, false)) + )); + output.extend(finish_operation(&adapter, &mut state)); + let flow = state.auth.ecs.as_ref().unwrap(); + assert_eq!(flow.stage, Stage::Waiting); + assert_eq!(flow.deadline, original_deadline); + assert_eq!(flow.id, identity.id); + let auth = state.auth.state.as_ref().unwrap(); + assert_eq!(auth.id, identity.id); + assert_eq!(auth.request_id, identity.request_id); + assert_eq!(auth.collected_values, identity.collected_values); + assert_eq!(auth.editing_provider_name, identity.editing_provider_name); + assert_eq!(auth.phase, identity.phase); + assert!(!state.auth.completed_ids.contains(&identity.id)); + assert!(!String::from_utf8(output) + .unwrap() + .contains("Auth configured")); + let calls = registry_calls(&dir); + let actions: Vec<_> = calls + .iter() + .map(|call| call["action"].as_str().unwrap()) + .collect(); + assert_eq!( + actions, + if waited { + vec!["verify", "configure", "verify"] + } else { + vec!["configure", "verify"] + } + ); + assert_eq!( + calls.last().unwrap()["params"]["auth_source"], + "ecs_ram_role" + ); + } +} + +#[test] +fn rejected_and_transport_failed_saves_never_report_success_or_resubmit() { + for (code, expected_stage, expected_message) in [ + ("persistence_failed", Stage::Failed, "fixture save rejected"), + ("transport", Stage::Unknown, "no response received (EOF)"), + ] { + let (dir, core) = probe_fixture(serde_json::json!({"status": "ready"})); + std::fs::write( + dir.path().join("registry.sh.configure-error"), + format!("{code}\n"), + ) + .unwrap(); + let mut state = state("failed-save"); + let adapter = AdapterInstance::CoshCore(core); + let mut output = Vec::new(); + start_configuration(&adapter, &mut state, &mut output).unwrap(); + output.extend(finish_operation(&adapter, &mut state)); + for overdue in [INTERVAL, INTERVAL * 2] { + let flow = state.auth.ecs.as_mut().unwrap(); + assert_eq!(flow.stage, expected_stage); + assert_eq!(flow.error.as_deref(), Some(expected_message)); + assert!(flow.operation.is_none()); + flow.next_check = Instant::now() - overdue; + flow.deadline = Some(Instant::now() - overdue); + flow.operation_deadline = Some(Instant::now() - overdue); + poll(&adapter, &mut state, &mut output).unwrap(); + let flow = state.auth.ecs.as_ref().unwrap(); + assert_eq!(flow.stage, expected_stage); + assert!( + flow.operation.is_none(), + "terminal save must not start any worker" + ); + assert_eq!( + registry_calls(&dir).len(), + 1, + "configure must remain single-shot" + ); + } + assert_eq!(state.auth.state.as_ref().unwrap().id, "failed-save"); + assert!(!state.auth.completed_ids.contains("failed-save")); + assert_eq!(registry_calls(&dir)[0]["action"], "configure"); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains(expected_message)); + assert!(!output.contains("Auth configured")); + assert!(!output.contains("credentials saved")); + if expected_stage == Stage::Unknown { + assert_eq!(question(&state).1, vec!["Return to provider management"]); + } + } +} + +#[test] +fn cancelled_and_reaped_probe_stays_idle_despite_overdue_checks() { + let (dir, core) = probe_fixture(Value::Null); + std::fs::write( + dir.path().join("registry.sh"), + "#!/bin/sh\nread -r request\nprintf '%s\\n' \"$request\" >> \"$0.calls\"\nprintf '%s\\n' \"$$\" > \"$0.pid\"\nexec sleep 60\n", + ) + .unwrap(); + let mut state = state("cancel-running"); + attach_probe(&mut state, &core); + let pid = probe_pid(&dir); + let flow = state.auth.ecs.as_mut().unwrap(); + // Leave the check overdue by two intervals; cancellation must retire the timer. + flow.next_check = Instant::now() - INTERVAL * 2; + flow.deadline = Some(Instant::now() - INTERVAL * 2); + let adapter = AdapterInstance::CoshCore(core); + let mut output = Vec::new(); + runtime::cancel_auth_panel(&mut state, &mut output).unwrap(); + assert_eq!(state.auth.ecs.as_ref().unwrap().stage, Stage::Cancelling); + let limit = Instant::now() + Duration::from_secs(2); + while state.auth.ecs.is_some() { + assert!(Instant::now() < limit, "cancelled probe was not reaped"); + poll(&adapter, &mut state, &mut output).unwrap(); + thread::yield_now(); + } + assert_probe_reaped(pid); + assert!(state.auth.state.is_none()); + assert!(state.auth.completed_ids.contains("cancel-running")); + let calls = registry_calls(&dir); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0]["action"], "verify"); + let notice = String::from_utf8(output.clone()).unwrap(); + assert!(notice.contains("Auth cancelled")); + assert!(!notice.contains("Auth configured")); + output.clear(); + for _ in 0..2 { + poll(&adapter, &mut state, &mut output).unwrap(); + thread::yield_now(); + assert!( + state.auth.ecs.is_none(), + "cancelled flow must never respawn" + ); + assert!(state.auth.state.is_none()); + assert_eq!( + registry_calls(&dir), + calls, + "cancelled flow must not probe or save again" + ); + assert!( + output.is_empty(), + "cancelled flow must not publish later results" + ); + } +} + +#[test] +fn ecs_refresh_message_is_appended_and_bilingual() { + use crate::config::Language; + let id = *MessageId::ALL.last().unwrap(); + assert_eq!(format!("{id:?}"), "AuthEcsRefreshing"); + assert_eq!(id as usize, MessageId::AuthEcsCancelHint as usize + 1); + assert_eq!( + I18n::new(Language::EnUs).t(id), + "Waiting for ECS credentials to refresh. Configuration will continue automatically." + ); + assert_eq!( + I18n::new(Language::ZhCn).t(id), + "正在等待 ECS 凭据刷新,刷新后将自动继续配置。" + ); +} + +#[test] +fn expired_credentials_wait_for_refresh_without_authorization_qr() { + let (_dir, core) = probe_fixture(serde_json::json!({ + "status": "not_ready", "reason": "credentials_expired" + })); + let mut state = state("expired"); + attach_probe(&mut state, &core); + finish_probe(&mut state); + let (text, options, qr) = question(&state); + assert!( + !qr, + "expired credentials must not ask for RAM Role authorization" + ); + assert!(options.is_empty()); + assert!( + text.contains("Waiting for ECS credentials to refresh"), + "{text}" + ); + state.language = crate::config::Language::ZhCn; + assert!(question(&state).0.contains("等待 ECS 凭据刷新")); + let flow = state.auth.ecs.as_ref().unwrap(); + assert!(flow.next_check > Instant::now()); + assert!(flow.next_check <= Instant::now() + INTERVAL); +} + +#[test] +fn missing_role_keeps_authorization_wait_and_qr() { + let (_dir, core) = probe_fixture(serde_json::json!({ + "status": "not_ready", "reason": "role_missing" + })); + let mut state = state("missing-role"); + attach_probe(&mut state, &core); + finish_probe(&mut state); + assert_eq!(state.auth.ecs.as_ref().unwrap().stage, Stage::Waiting); + assert!(question(&state).2); +} + +#[test] +fn unavailable_configure_rechecks_before_claiming_authorization_is_missing() { + let mut state = state("unavailable"); + completed_configuration( + &mut state, + Err(AuthConfigureFailure { + message: "ECS credentials unavailable".into(), + code: Some("credential_source_unavailable".into()), + }), + ); + // Inspect the transition before the next scheduled probe. + state.auth.ecs.as_mut().unwrap().next_check = Instant::now() + INTERVAL; + poll( + &AdapterInstance::Fake(FakeAgentAdapter), + &mut state, + &mut Vec::new(), + ) + .unwrap(); + assert_eq!(state.auth.ecs.as_ref().unwrap().stage, Stage::Checking); + assert!(!question(&state).2); +} + +#[test] +fn shell_exit_discards_ready_probe_without_starting_configuration() { + let (dir, core) = probe_fixture(serde_json::json!({"status": "ready"})); + let mut state = state("closed-probe"); + attach_probe(&mut state, &core); + let deadline = Instant::now() + Duration::from_secs(2); + // The probe only reaps this sleeping fixture after reading its Ready reply. + loop { + assert!(Instant::now() < deadline); + if let Some(pid) = std::fs::read_to_string(dir.path().join("registry.sh.pid")) + .ok() + .and_then(|pid| pid.trim().parse::().ok()) + { + if unsafe { nix::libc::kill(pid, 0) } == -1 { + break; + } + } + thread::yield_now(); + } + state.shell_exited = true; + let adapter = AdapterInstance::CoshCore(core); + while state.auth.ecs.is_some() { + assert!(Instant::now() < deadline); + poll(&adapter, &mut state, &mut Vec::new()).unwrap(); + thread::yield_now(); + } + assert!(state.auth.state.is_none()); + let calls = std::fs::read_to_string(dir.path().join("registry.sh.calls")).unwrap(); + assert_eq!( + calls.lines().count(), + 1, + "shutdown must not schedule configure" + ); +} + +#[test] +fn active_run_ready_clears_auth_panel_before_sending_response() { + for stage in [Stage::Checking, Stage::Waiting] { + for send_succeeds in [true, false] { + let (dir, core) = probe_fixture(serde_json::json!({"status": "ready"})); + let mut state = state("active-ready"); + state.auth.state.as_mut().unwrap().backend = AuthBackend::ActiveRun; + state.auth.ecs.as_mut().unwrap().stage = stage; + let (mut active, _approval_rx) = + crate::agent::run::test_support::test_active_run_with_id("active-owner"); + let (auth_tx, auth_rx) = std::sync::mpsc::channel(); + active.handle.auth_sender = Some(auth_tx); + state.agent_run.active = Some(active); + let auth_rx = send_succeeds.then_some(auth_rx); + let mut output = Vec::new(); + render_current_auth_panel(&mut state, &mut output).unwrap(); + let height = state.questions.active_panel_height; + assert!(height > 0); + output.clear(); + attach_probe(&mut state, &core); + let adapter = AdapterInstance::CoshCore(core); + let deadline = Instant::now() + Duration::from_secs(2); + while state.auth.state.is_some() { + assert!(Instant::now() < deadline); + poll(&adapter, &mut state, &mut output).unwrap(); + thread::yield_now(); + } + assert!(state.auth.ecs.is_none()); + assert_eq!(state.questions.active_panel_height, 0); + assert!(state.questions.active_panel_id.is_none()); + assert!(state.questions.active_panel_width.is_none()); + assert!(state.auth.completed_ids.contains("active-ready")); + let output = String::from_utf8(output).unwrap(); + assert!(output.starts_with(&format!("\x1b[{height}A")), "{output}"); + assert!(!output.contains("credentials saved")); + if let Some(auth_rx) = auth_rx { + let response = auth_rx.try_recv().unwrap(); + assert_eq!(response.request_id, "active-ready"); + assert_eq!(response.provider_id, "aliyun"); + assert_eq!(response.values["auth_source"], "ecs_ram_role"); + assert!(auth_rx.try_recv().is_err()); + } else { + assert!(output.contains("Auth failed"), "{output}"); + } + let calls = registry_calls(&dir); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0]["action"], "verify"); + } + } +} + +#[test] +fn changing_wait_reason_repaints_the_existing_panel() { + for (reason, refreshing, message) in [ + ( + "credentials_expired", + true, + "Waiting for ECS credentials to refresh", + ), + ( + "role_missing", + false, + "Waiting for ECS RAM Role authorization", + ), + ] { + let (_dir, core) = probe_fixture(serde_json::json!({ + "status": "not_ready", "reason": reason + })); + let mut state = state("changed-reason"); + let flow = state.auth.ecs.as_mut().unwrap(); + flow.stage = Stage::Waiting; + flow.waiting_for_refresh = !refreshing; + attach_probe(&mut state, &core); + let output = String::from_utf8(finish_probe(&mut state)).unwrap(); + assert!( + output.contains(message), + "reason changes must repaint: {output}" + ); + assert_eq!(question(&state).2, !refreshing); + } +} + +#[test] +fn refresh_wait_uses_the_same_schedule_and_deadline_without_authorization_claims() { + let (_dir, core) = probe_fixture(serde_json::json!({ + "status": "not_ready", "reason": "credentials_expired" + })); + let mut state = state("refresh-schedule"); + let original_deadline = state.auth.ecs.as_ref().unwrap().deadline; + attach_probe(&mut state, &core); + finish_probe(&mut state); + assert_eq!(state.auth.ecs.as_ref().unwrap().deadline, original_deadline); + assert!(original_deadline.unwrap() <= Instant::now() + WAIT_LIMIT); + let adapter = AdapterInstance::CoshCore(core); + // The future next_check prevents an early probe. + poll(&adapter, &mut state, &mut Vec::new()).unwrap(); + assert!(state.auth.ecs.as_ref().unwrap().operation.is_none()); + state.auth.ecs.as_mut().unwrap().next_check = Instant::now(); + poll(&adapter, &mut state, &mut Vec::new()).unwrap(); + assert!(matches!( + state.auth.ecs.as_ref().unwrap().operation, + Some(Operation::Probe(..)) + )); + state.auth.ecs.as_mut().unwrap().deadline = Some(Instant::now()); + let limit = Instant::now() + Duration::from_secs(2); + while state.auth.ecs.as_ref().unwrap().stage != Stage::TimedOut { + assert!(Instant::now() < limit); + poll(&adapter, &mut state, &mut Vec::new()).unwrap(); + thread::yield_now(); + } + assert!(state.auth.ecs.as_ref().unwrap().operation.is_none()); + assert!(!question(&state).2); + assert!(!question(&state).0.contains("authorization")); + state.language = crate::config::Language::ZhCn; + assert!(!question(&state).0.contains("授权")); +} + +#[test] +fn shell_exit_does_not_schedule_new_prepare_verify_or_configure() { + for stage in [Stage::Preparing, Stage::Checking, Stage::Waiting] { + let (dir, core) = probe_fixture(serde_json::json!({"status": "ready"})); + let adapter = AdapterInstance::CoshCore(core); + let mut state = state("closed-idle"); + state.auth.ecs.as_mut().unwrap().stage = stage; + state.shell_exited = true; + start_configuration(&adapter, &mut state, &mut Vec::new()).unwrap(); + assert!(state.auth.ecs.as_ref().unwrap().operation.is_none()); + poll(&adapter, &mut state, &mut Vec::new()).unwrap(); + assert!(state.auth.ecs.is_none()); + assert!(!dir.path().join("registry.sh.calls").exists()); + } +} + +#[test] +fn shell_exit_cancels_and_reaps_an_inflight_probe() { + let (dir, core) = probe_fixture(Value::Null); + std::fs::write( + dir.path().join("registry.sh"), + "#!/bin/sh\nread -r request\nprintf '%s\\n' \"$$\" > \"$0.pid\"\nexec sleep 60\n", + ) + .unwrap(); + let mut state = state("closed-running-probe"); + attach_probe(&mut state, &core); + let deadline = Instant::now() + Duration::from_secs(2); + let pid = loop { + assert!(Instant::now() < deadline); + if let Some(pid) = std::fs::read_to_string(dir.path().join("registry.sh.pid")) + .ok() + .and_then(|pid| pid.trim().parse::().ok()) + { + break pid; + } + thread::yield_now(); + }; + state.shell_exited = true; + let adapter = AdapterInstance::CoshCore(core); + while state.auth.ecs.is_some() { + assert!(Instant::now() < deadline); + poll(&adapter, &mut state, &mut Vec::new()).unwrap(); + thread::yield_now(); + } + assert!(state.auth.state.is_none()); + assert_eq!(unsafe { nix::libc::kill(pid, 0) }, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(nix::libc::ESRCH) + ); + assert_eq!( + unsafe { nix::libc::waitpid(pid, std::ptr::null_mut(), nix::libc::WNOHANG) }, + -1 + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(nix::libc::ECHILD) + ); +} diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/mod.rs b/src/cosh-ng/crates/cosh-shell/src/auth/mod.rs index 6ffd90af3c..74ab26518d 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/mod.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/mod.rs @@ -1,7 +1,9 @@ mod active_submission; mod capture; mod completion; +mod default_id; mod delete_confirm; +pub(crate) mod ecs_poll; mod menu; mod navigation; mod prompt; diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/navigation.rs b/src/cosh-ng/crates/cosh-shell/src/auth/navigation.rs index 9db55b9606..319c8d002e 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/navigation.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/navigation.rs @@ -5,6 +5,7 @@ //! they already confirmed. The decision is a pure transition on [`RuntimeAuthState`] so the card //! dispatcher only has to choose between re-rendering the panel and cancelling it. +use super::menu::{management_entry_index, AuthManagementEntry}; use super::provider_management::{provider_actions, ExistingProvider, ProviderAction}; use super::runtime::{AuthPhase, RuntimeAuthState}; @@ -17,15 +18,6 @@ pub(super) enum BackOutcome { Cancel, } -/// Index of the first field the current flow is allowed to change. -/// -/// An edit starts at 1 because field 0 is the injected Provider ID, and -/// `send_auth_response` takes the identity of an edit from `editing_provider_name` instead — -/// stepping back onto that field would offer an edit that cannot take effect. -fn first_editable_field(auth: &RuntimeAuthState) -> usize { - usize::from(auth.editing_provider_name.is_some()) -} - /// Moves the flow one step back, reporting whether there was a step left to take. pub(super) fn step_back(auth: &mut RuntimeAuthState) -> BackOutcome { // Every other phase is a menu the user reaches in one keystroke, so ESC there keeps the @@ -33,8 +25,11 @@ pub(super) fn step_back(auth: &mut RuntimeAuthState) -> BackOutcome { if auth.phase != AuthPhase::FillingField { return BackOutcome::Cancel; } - if auth.current_field > first_editable_field(auth) { - auth.current_field -= 1; + if let Some(previous) = (0..auth.current_field.min(auth.current_provider().fields.len())) + .rev() + .find(|&index| auth.field_is_editable(index)) + { + auth.current_field = previous; auth.field_error = None; // `collected_values` is the form and `field_input` only the editable projection of the // field under the cursor, so re-projecting is what both restores the earlier value and @@ -51,10 +46,16 @@ pub(super) fn step_back(auth: &mut RuntimeAuthState) -> BackOutcome { /// flow the user pressed ESC on. fn leave_form(auth: &mut RuntimeAuthState) -> BackOutcome { let Some(provider_name) = auth.editing_provider_name.as_deref() else { - // A new provider came from the template picker, where a further ESC cancels. Values - // collected so far are left alone: re-answering the picker clears them anyway, so a - // template switch cannot leak the previous template's input. - auth.phase = AuthPhase::SelectingProvider; + if auth.from_sysom_shortcut { + auth.phase = AuthPhase::ManagingProviders; + auth.selected_provider = management_entry_index( + &auth.sysom, + auth.existing_providers.len(), + AuthManagementEntry::SysomShortcut, + ); + } else { + auth.phase = AuthPhase::SelectingProvider; + } discard_field_draft(auth); return BackOutcome::Redraw; }; diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/navigation/tests.rs b/src/cosh-ng/crates/cosh-shell/src/auth/navigation/tests.rs index 37f51a954c..4b49e0df0d 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/navigation/tests.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/navigation/tests.rs @@ -51,6 +51,8 @@ fn filling_state(current_field: usize, collected: &[(&str, &str)]) -> RuntimeAut field_capture_revision: 0, existing_providers: Vec::new(), editing_provider_name: None, + default_provider_id: false, + from_sysom_shortcut: false, error_message: None, backend: AuthBackend::CoreRegistry, sysom: SysomMenu::default(), @@ -327,3 +329,30 @@ fn consecutive_esc_walks_the_form_back_to_the_picker() { // Nothing was thrown away on the way out; re-answering the picker is what resets the form. assert_eq!(auth.collected_values.len(), 3); } + +#[test] +fn editing_without_a_name_field_keeps_the_first_credential_editable() { + let mut auth = filling_state(1, &[]); + auth.providers[0].fields.remove(0); + auth.editing_provider_name = Some("qwen-prod".into()); + auth.existing_providers = vec![saved_provider("qwen-prod")]; + + step_back(&mut auth); + + assert_eq!(auth.phase, AuthPhase::FillingField); + assert_eq!(auth.current_field_info().unwrap().name, "base_url"); + step_back(&mut auth); + assert_eq!(auth.phase, AuthPhase::ProviderAction { provider_idx: 0 }); +} + +#[test] +fn editing_skips_identity_by_field_name_not_position() { + let mut auth = filling_state(2, &[]); + auth.providers[0].fields.swap(0, 1); + auth.editing_provider_name = Some("qwen-prod".into()); + + step_back(&mut auth); + + assert_eq!(auth.phase, AuthPhase::FillingField); + assert_eq!(auth.current_field_info().unwrap().name, "base_url"); +} diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/prompt.rs b/src/cosh-ng/crates/cosh-shell/src/auth/prompt.rs index eb501ad43c..b7bb0c2849 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/prompt.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/prompt.rs @@ -145,19 +145,25 @@ pub(super) fn render_current_auth_panel( state.questions.active_panel_height = height; state.questions.active_panel_id = Some(panel_id.clone()); } - AuthPhase::AliyunEcsChallenge { - ref instance_id, - ref console_url, - } => { - let mut question = format!( - "\u{1f511} Aliyun Authentication \u{2014} Authorize ECS RAM Role\n \ - ECS Instance ID: {instance_id}\n URL: {console_url}" - ); - if let Some(qr) = generate_qr_text(console_url) { - question.push_str("\n\n"); - question.push_str(&qr); + AuthPhase::PreparingMenu + | AuthPhase::AliyunEcsPreparing + | AuthPhase::AliyunEcsChallenge { .. } => { + let (mut question, options, show_link) = super::ecs_poll::question(state); + if show_link { + if let AuthPhase::AliyunEcsChallenge { + instance_id, + console_url, + } = &auth.phase + { + question.push_str(&format!( + "\nECS Instance ID: {instance_id}\nURL: {console_url}" + )); + if let Some(qr) = generate_qr_text(console_url) { + question.push_str("\n\n"); + question.push_str(&qr); + } + } } - let options = vec!["I have authorized this ECS instance".to_string()]; let model = QuestionPanelModel { id: &panel_id, question: &question, @@ -174,6 +180,7 @@ pub(super) fn render_current_auth_panel( state.questions.active_panel_id = Some(panel_id); } } + state.questions.active_panel_width = Some(renderer.panel_standard_width()); output.flush() } diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/provider_management.rs b/src/cosh-ng/crates/cosh-shell/src/auth/provider_management.rs index 2c606b9866..d9fe05e6dd 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/provider_management.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/provider_management.rs @@ -91,6 +91,10 @@ impl From for ExistingProvider { let provider_type = provider .provider_type .unwrap_or_else(|| "openai_compat".to_string()); + let provider_type = match provider_type.as_str() { + "openai" | "generic" => "openai_compat".to_string(), + _ => provider_type, + }; let model = provider.model.unwrap_or_default(); Self { name: provider.provider_id, @@ -254,6 +258,20 @@ mod auth_configure_failure_tests { use super::{auth_configure_failure, AuthConfigureFailure}; use crate::adapter::RegistryQueryError; + #[test] + fn saved_openai_protocol_types_recover_the_auth_template_identity() { + for provider_type in ["openai", "generic", "openai_compat"] { + let saved: super::CoreSavedProvider = serde_json::from_value(serde_json::json!({ + "provider_id": "prod", + "provider_type": provider_type, + "active": false + })) + .unwrap(); + let provider = super::ExistingProvider::from(saved); + assert_eq!(provider.provider_type, "openai_compat"); + } + } + fn failure(code: &str) -> AuthConfigureFailure { AuthConfigureFailure { message: "safe message".to_string(), diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/required.rs b/src/cosh-ng/crates/cosh-shell/src/auth/required.rs index b68aaac383..d99ac2bbad 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/required.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/required.rs @@ -44,6 +44,8 @@ pub(crate) fn record_auth_required( field_capture_revision: 0, existing_providers: Vec::new(), editing_provider_name: None, + default_provider_id: false, + from_sysom_shortcut: false, error_message: error_message.clone(), backend: AuthBackend::ActiveRun, // The active-run flow never shows the management menu. diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/retry.rs b/src/cosh-ng/crates/cosh-shell/src/auth/retry.rs index 5710698d72..658b2f370b 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/retry.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/retry.rs @@ -16,6 +16,9 @@ pub(super) fn restore_after_failed_submission_at( ) { auth.phase = AuthPhase::FillingField; auth.field_error = None; + if field_name == Some("provider_id") && auth.editing_provider_name.is_none() { + auth.default_provider_id = false; + } let Some(provider_id) = auth.editing_provider_name.clone() else { let fields = &auth.providers[auth.selected_provider].fields; let secret_fields: HashSet<_> = fields @@ -25,19 +28,12 @@ pub(super) fn restore_after_failed_submission_at( .collect(); auth.collected_values .retain(|name, _| !secret_fields.contains(name.as_str())); - auth.current_field = field_name - .and_then(|name| fields.iter().position(|field| field.name == name)) - .unwrap_or(0); + auth.current_field = retry_field(auth, field_name); auth.load_current_field_input(); return; }; let fields = &auth.providers[auth.selected_provider].fields; - // Slash auth prepends provider_id before edit mode, so retries preserve that identity. - debug_assert_eq!( - fields.first().map(|field| field.name.as_str()), - Some("provider_id") - ); let secret_fields: HashSet = fields .iter() .filter(|field| field.secret) @@ -53,15 +49,22 @@ pub(super) fn restore_after_failed_submission_at( auth.collected_values .insert("provider_id".to_string(), provider_id); clear_ecs_auth_source(&mut auth.collected_values); - auth.current_field = 1.min(fields.len()); - if let Some(field_name) = field_name { - if let Some(index) = fields.iter().position(|field| field.name == field_name) { - auth.current_field = index; - } - } + auth.current_field = retry_field(auth, field_name); auth.load_current_field_input(); } +fn retry_field(auth: &RuntimeAuthState, field_name: Option<&str>) -> usize { + field_name + .and_then(|name| { + auth.current_provider() + .fields + .iter() + .position(|field| field.name == name) + }) + .filter(|&index| auth.field_is_editable(index)) + .unwrap_or_else(|| auth.first_editable_field()) +} + /// Drops the ECS RAM-role marker, which the restored phase contradicts. /// /// `auth_source` is not a template field, so the retain above keeps it — but the retry always diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/runtime.rs b/src/cosh-ng/crates/cosh-shell/src/auth/runtime.rs index 183929ec94..c6c5a7b95c 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/runtime.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/runtime.rs @@ -1,12 +1,16 @@ use std::collections::{HashMap, HashSet}; use serde::Deserialize; -use serde_json::json; +use serde_json::Value; use crate::adapter::AdapterInstance; use crate::auth::active_submission::finish_active_submission; use crate::auth::capture::matches_auth_capture; use crate::auth::completion::finish_auth_configuration; +use crate::auth::default_id::{ + begin_new_provider, begin_provider_fields, begin_sysom_shortcut, + providers_with_provider_id_field, +}; use crate::auth::delete_confirm::{ begin_delete_confirmation, focus_delete_confirmation, render_delete_outcome, submit_delete_confirmation, DeleteConfirmationOutcome, @@ -23,9 +27,7 @@ use crate::auth::provider_management::{ ExistingProvider, ProviderAction, }; use crate::auth::retry::restore_after_failed_submission_at; -use crate::auth::validation::{ - record_field_edit, record_field_submission, FieldSubmission, PROVIDER_ID_HINT, -}; +use crate::auth::validation::{record_field_edit, record_field_submission, FieldSubmission}; use crate::runtime::dispatcher::stable_event_key; use crate::runtime::prelude::{ AuthFieldInfo, AuthProviderInfo, AuthResponse, NoticePanelModel, RatatuiInlineRenderer, @@ -55,6 +57,8 @@ pub(crate) struct RuntimeAuthState { pub(crate) existing_providers: Vec, /// The section name of the provider being edited (None = new provider) pub(crate) editing_provider_name: Option, + pub(super) default_provider_id: bool, + pub(super) from_sysom_shortcut: bool, pub(super) error_message: Option, pub(super) backend: AuthBackend, /// SysOM placement plus the Aliyun prepare result prefetched for this `/auth`. @@ -70,6 +74,7 @@ pub(super) enum AuthBackend { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum AuthPhase { + PreparingMenu, /// Show existing providers list + "Add new" option ManagingProviders, /// Action menu after selecting an existing provider @@ -82,6 +87,7 @@ pub(crate) enum AuthPhase { }, SelectingProvider, FillingField, + AliyunEcsPreparing, AliyunEcsChallenge { instance_id: String, console_url: String, @@ -117,6 +123,7 @@ impl RuntimeAuthState { #[derive(Debug, Default)] pub(crate) struct AuthState { pub(crate) state: Option, + pub(super) ecs: Option, pub(crate) handled_card_events: HashSet, pub(crate) completed_ids: HashSet, } @@ -181,22 +188,10 @@ pub(crate) fn trigger_auth_from_slash( ); let id = format!("auth-{request_id}"); - let mut existing_providers = core_state.existing_providers; - let mut sysom = prefetch_sysom_menu(adapter); - sysom.sync(&mut existing_providers); - - // Saved providers or the SysOM shortcut give the management panel something to show; - // otherwise go straight to the template picker as before. - let phase = if has_manageable_entries(&sysom, existing_providers.len()) { - AuthPhase::ManagingProviders - } else { - AuthPhase::SelectingProvider - }; - state.auth.state = Some(RuntimeAuthState { id: id.clone(), request_id, - phase, + phase: AuthPhase::PreparingMenu, providers, selected_provider: 0, current_field: 0, @@ -204,11 +199,13 @@ pub(crate) fn trigger_auth_from_slash( field_input: String::new(), field_error: None, field_capture_revision: 0, - existing_providers, + existing_providers: core_state.existing_providers, editing_provider_name: None, + default_provider_id: false, + from_sysom_shortcut: false, error_message: None, backend: AuthBackend::CoreRegistry, - sysom, + sysom: SysomMenu::default(), }); render_current_auth_panel(state, output)?; @@ -235,28 +232,6 @@ fn clear_observed_model_after_provider_delete( } } -#[derive(Debug, Deserialize)] -struct CoreAuthVerify { - authorized: bool, -} - -fn core_auth_verify_aliyun_ecs(adapter: &AdapterInstance) -> Result { - let AdapterInstance::CoshCore(cosh_core) = adapter else { - return Err("auth registry requires cosh-core backend".to_string()); - }; - let value = cosh_core.registry_query( - "auth", - "verify", - json!({ - "provider_type": "aliyun", - "auth_source": "ecs_ram_role" - }), - )?; - let verify: CoreAuthVerify = - serde_json::from_value(value).map_err(|e| format!("invalid auth verify response: {e}"))?; - Ok(verify.authorized) -} - #[derive(Debug, Deserialize)] struct CoreAuthPrepare { mode: String, @@ -266,35 +241,31 @@ struct CoreAuthPrepare { values: HashMap, } -fn core_auth_prepare( - adapter: &AdapterInstance, - provider_type: &str, -) -> Result { - let AdapterInstance::CoshCore(cosh_core) = adapter else { - return Err("auth registry requires cosh-core backend".to_string()); - }; - let value = - cosh_core.registry_query("auth", "prepare", json!({ "provider_type": provider_type }))?; - serde_json::from_value(value).map_err(|e| format!("invalid auth prepare response: {e}")) -} - -/// Detects an ECS host once per `/auth` so the menu can offer the SysOM free trial. -/// -/// This is a recommendation, not a requirement: a failed, unsupported or `manual` prepare -/// yields the default (non-ECS) menu instead of breaking `/auth`. -fn prefetch_sysom_menu(adapter: &AdapterInstance) -> SysomMenu { - match core_auth_prepare(adapter, "aliyun") { +pub(super) fn finish_sysom_menu_prepare( + auth: &mut RuntimeAuthState, + result: Result, +) { + let prepare = result.and_then(|value| { + serde_json::from_value::(value) + .map_err(|_| "Invalid auth menu prepare response".to_string()) + }); + auth.sysom = match prepare { Ok(prepare) if prepare.mode == "manual" => SysomMenu::on_manual(), Ok(prepare) => ecs_ram_role_prepare(prepare) .map(SysomMenu::on_ecs) .unwrap_or_default(), Err(error) => { - // The panel fails open, but the cause must survive: without this a metadata - // timeout or a protocol mismatch is indistinguishable from "not on ECS". + // A failed recommendation must not prevent managing existing providers. tracing::debug!("auth prepare for the SysOM menu entry failed: {error}"); SysomMenu::default() } - } + }; + auth.sysom.sync(&mut auth.existing_providers); + auth.phase = if has_manageable_entries(&auth.sysom, auth.existing_providers.len()) { + AuthPhase::ManagingProviders + } else { + AuthPhase::SelectingProvider + }; } fn ecs_ram_role_prepare(prepare: CoreAuthPrepare) -> Option { @@ -305,26 +276,6 @@ fn ecs_ram_role_prepare(prepare: CoreAuthPrepare) -> Option { }) } -fn providers_with_provider_id_field(providers: Vec) -> Vec { - providers - .into_iter() - .map(|mut provider| { - provider.fields.insert( - 0, - AuthFieldInfo { - name: "provider_id".to_string(), - label: "Provider ID".to_string(), - hint: Some(PROVIDER_ID_HINT.to_string()), - secret: false, - required: true, - placeholder: Some(provider.id.clone()), - }, - ); - provider - }) - .collect() -} - fn handle_auth_focus( state: &mut InlineState, id: &str, @@ -416,12 +367,13 @@ fn handle_auth_answer( AuthManagementEntry::SysomShortcut => { if !begin_sysom_shortcut(auth) { begin_new_provider(auth); + } else if auth.default_provider_id { + apply_aliyun_prepare(auth); } } AuthManagementEntry::AddNew => begin_new_provider(auth), } - clear_active_auth_panel(state, output)?; - render_current_auth_panel(state, output)?; + render_or_submit_auth(adapter, state, output)?; Ok(true) } AuthPhase::ProviderAction { provider_idx } => { @@ -472,6 +424,8 @@ fn handle_auth_answer( auth.selected_provider = template_idx; auth.editing_provider_name = Some(existing.name.clone()); + auth.default_provider_id = false; + auth.from_sysom_shortcut = false; auth.collected_values .insert("provider_id".to_string(), existing.name.clone()); @@ -505,7 +459,7 @@ fn handle_auth_answer( } if should_apply_aliyun_prepare_for_edit(&existing) { - if apply_aliyun_prepare(adapter, auth).map_err(std::io::Error::other)? { + if apply_aliyun_prepare(auth) { clear_active_auth_panel(state, output)?; render_current_auth_panel(state, output)?; return Ok(true); @@ -517,10 +471,9 @@ fn handle_auth_answer( } auth.phase = AuthPhase::FillingField; - auth.current_field = 1.min(auth.current_provider().fields.len()); + auth.current_field = auth.first_editable_field(); auth.load_current_field_input(); - clear_active_auth_panel(state, output)?; - render_current_auth_panel(state, output)?; + render_or_submit_auth(adapter, state, output)?; } ProviderAction::Delete => { begin_delete_confirmation(auth, provider_idx); @@ -560,20 +513,14 @@ fn handle_auth_answer( Ok(true) } AuthPhase::SelectingProvider => { + begin_provider_fields(auth); if auth.current_provider().id == "aliyun" - && should_apply_aliyun_prepare_on_provider_selection(auth.backend) - && apply_aliyun_prepare(adapter, auth).map_err(std::io::Error::other)? + && (should_apply_aliyun_prepare_on_provider_selection(auth.backend) + || auth.default_provider_id) { - clear_active_auth_panel(state, output)?; - render_current_auth_panel(state, output)?; - return Ok(true); + apply_aliyun_prepare(auth); } - auth.phase = AuthPhase::FillingField; - auth.current_field = 0; - auth.collected_values.clear(); - auth.field_input.clear(); - clear_active_auth_panel(state, output)?; - render_current_auth_panel(state, output)?; + render_or_submit_auth(adapter, state, output)?; Ok(true) } AuthPhase::FillingField => { @@ -594,84 +541,42 @@ fn handle_auth_answer( auth.editing_provider_name.is_some(), auth.current_provider().id.as_str(), field.as_ref().map(|f| f.name.as_str()), - ) && apply_aliyun_prepare(adapter, auth).map_err(std::io::Error::other)? + ) && apply_aliyun_prepare(auth) { clear_active_auth_panel(state, output)?; render_current_auth_panel(state, output)?; return Ok(true); } - auth.current_field += 1; - // Load next field's pre-filled value (for edit mode) + auth.current_field = auth.editable_field_at_or_after(auth.current_field + 1); auth.load_current_field_input(); - - if auth.all_fields_collected() { - clear_active_auth_panel(state, output)?; - send_auth_response(Some(adapter), state, output)?; - Ok(true) - } else { - clear_active_auth_panel(state, output)?; - render_current_auth_panel(state, output)?; - Ok(true) - } + render_or_submit_auth(adapter, state, output)?; + Ok(true) } - AuthPhase::AliyunEcsChallenge { .. } => { - if !core_auth_verify_aliyun_ecs(adapter).map_err(std::io::Error::other)? { - clear_active_auth_panel(state, output)?; - let renderer = RatatuiInlineRenderer::for_terminal().with_language(state.language); - renderer.write_notice_panel( - output, - NoticePanelModel { - title: "Aliyun authorization pending", - body: vec![ - "ECS RAM Role credentials are not available yet.".to_string(), - "Open the authorization link or scan the QR code, then confirm again." - .to_string(), - ], - footer: None, - }, - )?; - render_current_auth_panel(state, output)?; - return Ok(true); - } - clear_active_auth_panel(state, output)?; - send_auth_response(Some(adapter), state, output)?; + AuthPhase::PreparingMenu + | AuthPhase::AliyunEcsPreparing + | AuthPhase::AliyunEcsChallenge { .. } => { + super::ecs_poll::answer(adapter, state, output)?; Ok(true) } } } -/// Resets the flow so the next answer picks a template for a brand-new provider. -fn begin_new_provider(auth: &mut RuntimeAuthState) { - auth.selected_provider = 0; - auth.editing_provider_name = None; - auth.phase = AuthPhase::SelectingProvider; - auth.current_field = 0; - auth.collected_values.clear(); - auth.field_input.clear(); -} - -/// Starts the SysOM free trial on the `aliyun` template, or reports `false` when the core -/// offers no such template. -/// -/// The Provider ID is still collected first: the shortcut must not silently overwrite an -/// existing configuration with a fixed id. The prefetched ECS challenge is applied once -/// that id validates, in the same place the manual aliyun flow would probe for it. -fn begin_sysom_shortcut(auth: &mut RuntimeAuthState) -> bool { - let Some(template_idx) = auth - .providers - .iter() - .position(|provider| provider.id == "aliyun") - else { - return false; - }; - auth.selected_provider = template_idx; - auth.editing_provider_name = None; - auth.phase = AuthPhase::FillingField; - auth.current_field = 0; - auth.collected_values.clear(); - auth.field_input.clear(); - auth.field_error = None; - true +fn render_or_submit_auth( + adapter: &AdapterInstance, + state: &mut InlineState, + output: &mut W, +) -> std::io::Result<()> { + clear_active_auth_panel(state, output)?; + if state + .auth + .state + .as_ref() + .is_some_and(|auth| auth.phase == AuthPhase::FillingField && auth.all_fields_collected()) + { + send_auth_response(Some(adapter), state, output) + } else { + render_current_auth_panel(state, output) + } } fn should_apply_aliyun_prepare_on_provider_selection(backend: AuthBackend) -> bool { @@ -707,36 +612,32 @@ fn clear_ecs_auth_source_for_manual_aliyun_edit( /// /// Reuses the challenge `/auth` already prefetched when there is one, so selecting the /// SysOM shortcut does not probe the ECS metadata service a second time. -fn apply_aliyun_prepare( - adapter: &AdapterInstance, - auth: &mut RuntimeAuthState, -) -> Result { - let prepare = match auth.sysom.prefetched() { - Some(PrefetchedAliyunPrepare::Manual) => return Ok(false), - Some(PrefetchedAliyunPrepare::EcsRamRole(prepare)) => prepare.clone(), - None => match ecs_ram_role_prepare(core_auth_prepare(adapter, "aliyun")?) { - Some(prepare) => prepare, - None => return Ok(false), - }, - }; - for (key, value) in prepare.values { - auth.collected_values.insert(key, value); +fn apply_aliyun_prepare(auth: &mut RuntimeAuthState) -> bool { + match auth.sysom.prefetched() { + Some(PrefetchedAliyunPrepare::Manual) => return false, + Some(PrefetchedAliyunPrepare::EcsRamRole(prepare)) => { + super::ecs_poll::set_challenge(auth, prepare.clone()); + } + None => auth.phase = AuthPhase::AliyunEcsPreparing, } - auth.collected_values.remove("access_key_id"); - auth.collected_values.remove("access_key_secret"); - auth.collected_values.remove("security_token"); - auth.phase = AuthPhase::AliyunEcsChallenge { - instance_id: prepare.instance_id, - console_url: prepare.console_url, - }; - Ok(true) + true } -fn send_auth_response( +pub(super) fn send_auth_response( adapter: Option<&AdapterInstance>, state: &mut InlineState, output: &mut W, ) -> std::io::Result<()> { + if state.agent_run.active.is_none() + && state.auth.state.as_ref().is_some_and(|auth| { + auth.backend == AuthBackend::CoreRegistry + && auth.collected_values.get("auth_source").map(String::as_str) + == Some("ecs_ram_role") + }) + { + let adapter = adapter.ok_or_else(|| std::io::Error::other("missing ECS auth adapter"))?; + return super::ecs_poll::start_configuration(adapter, state, output); + } let mut auth = state.auth.state.take().expect("auth state present"); let provider = &auth.providers[auth.selected_provider]; let provider_label = provider.label.clone(); @@ -879,10 +780,13 @@ fn handle_auth_back( } } -fn cancel_auth_panel( +pub(super) fn cancel_auth_panel( state: &mut InlineState, output: &mut W, ) -> std::io::Result<()> { + if super::ecs_poll::cancel(state, output)? { + return Ok(()); + } clear_active_auth_panel(state, output)?; if let Some(auth) = state.auth.state.as_ref() { state.auth.completed_ids.insert(auth.id.clone()); diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/runtime/default_name_tests.rs b/src/cosh-ng/crates/cosh-shell/src/auth/runtime/default_name_tests.rs new file mode 100644 index 0000000000..43dae1a21c --- /dev/null +++ b/src/cosh-ng/crates/cosh-shell/src/auth/runtime/default_name_tests.rs @@ -0,0 +1,297 @@ +use super::*; +use crate::auth::navigation::step_back; +use crate::auth::retry::restore_after_failed_submission_at; +use crate::auth::validation::{record_field_submission, FieldSubmission}; + +fn selecting(id: &str) -> InlineState { + let mut auth = slash_auth_state(&[id], SysomMenu::on_manual()); + auth.phase = AuthPhase::SelectingProvider; + auth.providers[0].fields = vec![ + field("provider_id", "Provider ID", false), + field("base_url", "Base URL", false), + field("model", "Model", false), + field("api_key", "API Key", true), + ]; + let mut state = InlineState::default(); + state.auth.state = Some(auth); + state +} + +#[test] +fn default_name_is_skipped_in_forward_and_backward_navigation() { + let mut state = selecting("openai_compat"); + answer_selected_row(&mut state); + handle_auth_answer( + &adapter_without_registry(), + &mut state, + "auth-slash", + "https://example.invalid", + &mut Vec::new(), + ) + .unwrap(); + let auth = state.auth.state.as_mut().unwrap(); + assert_eq!(auth.current_field_info().unwrap().name, "model"); + step_back(auth); + assert_eq!(auth.current_field_info().unwrap().name, "base_url"); + step_back(auth); + assert_eq!(auth.phase, AuthPhase::SelectingProvider); + assert_eq!(auth.collected_values["provider_id"], "openai_compat"); +} + +#[test] +fn default_name_is_preserved_by_generic_and_field_failure_recovery() { + for focus in [None, Some("model"), Some("missing_field")] { + let mut state = selecting("openai_compat"); + answer_selected_row(&mut state); + let auth = state.auth.state.as_mut().unwrap(); + auth.collected_values + .insert("api_key".into(), "secret".into()); + restore_after_failed_submission_at(auth, focus); + assert_eq!( + auth.collected_values.get("provider_id").map(String::as_str), + Some("openai_compat") + ); + assert_eq!( + auth.current_field_info().unwrap().name, + if focus == Some("model") { + "model" + } else { + "base_url" + } + ); + assert!(!auth.collected_values.contains_key("api_key")); + while auth.phase == AuthPhase::FillingField { + assert_ne!(auth.current_field_info().unwrap().name, "provider_id"); + step_back(auth); + } + } +} + +#[test] +fn default_name_survives_a_real_submission_failure() { + let mut state = selecting("dashscope"); + state.auth.state.as_mut().unwrap().providers[0] + .fields + .truncate(2); + answer_selected_row(&mut state); + let mut output = Vec::new(); + handle_auth_answer( + &adapter_without_registry(), + &mut state, + "auth-slash", + "https://example.invalid", + &mut output, + ) + .unwrap(); + assert!(String::from_utf8(output) + .unwrap() + .contains("Credentials were not saved")); + let auth = state.auth.state.as_ref().unwrap(); + assert_eq!(auth.current_field_info().unwrap().name, "base_url"); + assert_eq!(auth.collected_values["provider_id"], "dashscope"); +} + +#[test] +fn each_source_blocks_same_type_or_cross_type_name_reuse() { + for id in [ + "aliyun", + "dashscope", + "coding_plan", + "token_plan", + "openai_compat", + ] { + for source in ["user", "system", "runtime"] { + for (name, provider_type) in [("prod", id), (id, "other_type")] { + let mut state = selecting(id); + let auth = state.auth.state.as_mut().unwrap(); + auth.existing_providers = vec![ExistingProvider { + name: name.into(), + provider_type: provider_type.into(), + source: source.into(), + ..saved_dashscope() + }]; + answer_selected_row(&mut state); + let auth = state.auth.state.as_mut().unwrap(); + assert_eq!(auth.current_field_info().unwrap().name, "provider_id"); + assert!(!auth.collected_values.contains_key("provider_id")); + let field = auth.current_field_info().cloned().unwrap(); + assert_eq!( + record_field_submission(auth, Some(&field), name.into()), + FieldSubmission::Rejected, + "{id}/{source}/{name}" + ); + assert!(auth.field_error.as_deref().unwrap().contains("already")); + assert_eq!( + record_field_submission(auth, Some(&field), "new-name".into()), + FieldSubmission::Accepted + ); + } + } + } +} + +#[test] +fn occupied_default_name_is_not_offered_as_a_placeholder() { + let mut state = selecting("dashscope"); + let auth = state.auth.state.as_mut().unwrap(); + auth.providers[0].fields[0].placeholder = Some("dashscope".into()); + auth.existing_providers = vec![ExistingProvider { + name: "dashscope".into(), + provider_type: "other_type".into(), + ..saved_dashscope() + }]; + answer_selected_row(&mut state); + let auth = state.auth.state.as_ref().unwrap(); + assert_eq!(auth.current_field_info().unwrap().placeholder, None); + assert!(auth.field_error.as_deref().unwrap().contains("already")); +} + +#[test] +fn typed_template_name_remains_editable_when_same_type_already_exists() { + let mut state = selecting("dashscope"); + state.auth.state.as_mut().unwrap().existing_providers = vec![saved_dashscope()]; + answer_selected_row(&mut state); + handle_auth_answer( + &adapter_without_registry(), + &mut state, + "auth-slash", + "dashscope", + &mut Vec::new(), + ) + .unwrap(); + let auth = state.auth.state.as_mut().unwrap(); + step_back(auth); + assert_eq!(auth.current_field_info().unwrap().name, "provider_id"); + assert_eq!(auth.field_input, "dashscope"); +} + +#[test] +fn template_switch_discards_previous_identity_and_secrets() { + let mut state = selecting("dashscope"); + let mut other = state.auth.state.as_ref().unwrap().providers[0].clone(); + other.id = "coding_plan".into(); + state.auth.state.as_mut().unwrap().providers.push(other); + answer_selected_row(&mut state); + let auth = state.auth.state.as_mut().unwrap(); + auth.collected_values + .insert("api_key".into(), "secret".into()); + step_back(auth); + assert_eq!(auth.phase, AuthPhase::SelectingProvider); + auth.selected_provider = 1; + answer_selected_row(&mut state); + let auth = state.auth.state.as_mut().unwrap(); + assert_eq!(auth.collected_values["provider_id"], "coding_plan"); + assert!(!auth.collected_values.contains_key("api_key")); + step_back(auth); + auth.existing_providers = vec![saved_dashscope()]; + auth.selected_provider = 0; + answer_selected_row(&mut state); + let auth = state.auth.state.as_ref().unwrap(); + assert_eq!(auth.current_field_info().unwrap().name, "provider_id"); + assert!(auth.collected_values.is_empty()); +} + +#[test] +fn sysom_shortcut_name_field_returns_to_management() { + let mut auth = slash_auth_state(&["aliyun"], SysomMenu::on_ecs(ecs_prepare())); + auth.providers[0].fields = vec![field("provider_id", "Provider ID", false)]; + auth.existing_providers = vec![ExistingProvider { + provider_type: "aliyun".into(), + ..saved_dashscope() + }]; + assert!(begin_sysom_shortcut(&mut auth)); + step_back(&mut auth); + assert_eq!(auth.phase, AuthPhase::ManagingProviders); + assert_eq!( + management_entry( + &auth.sysom, + auth.existing_providers.len(), + auth.selected_provider + ), + AuthManagementEntry::SysomShortcut + ); +} + +#[test] +fn template_picker_default_aliyun_still_applies_prepare() { + let mut state = selecting("aliyun"); + state.auth.state.as_mut().unwrap().sysom = SysomMenu::on_ecs(ecs_prepare()); + answer_selected_row(&mut state); + let auth = state.auth.state.as_ref().unwrap(); + assert!(matches!(auth.phase, AuthPhase::AliyunEcsChallenge { .. })); + assert_eq!(auth.collected_values["provider_id"], "aliyun"); + assert_eq!(auth.collected_values["auth_source"], "ecs_ram_role"); +} + +#[test] +fn default_aliyun_without_cached_prepare_does_not_silently_use_manual_fields() { + let mut state = selecting("aliyun"); + state.auth.state.as_mut().unwrap().sysom = SysomMenu::default(); + assert!(handle_auth_answer( + &adapter_without_registry(), + &mut state, + "auth-slash", + "", + &mut Vec::new() + ) + .unwrap()); + let auth = state.auth.state.as_ref().unwrap(); + assert_eq!(auth.phase, AuthPhase::AliyunEcsPreparing); + assert_eq!(auth.collected_values["provider_id"], "aliyun"); +} + +#[test] +fn default_name_without_an_id_field_does_not_skip_a_credential() { + let mut state = selecting("dashscope"); + state.auth.state.as_mut().unwrap().providers[0] + .fields + .remove(0); + answer_selected_row(&mut state); + let auth = state.auth.state.as_ref().unwrap(); + assert_eq!(auth.current_field, 0); + assert_eq!(auth.current_field_info().unwrap().name, "base_url"); + assert_eq!( + auth.collected_values.get("provider_id").map(String::as_str), + Some("dashscope") + ); +} + +#[test] +fn empty_template_submits_after_resolving_identity() { + let mut state = selecting("dashscope"); + state.auth.state.as_mut().unwrap().providers[0] + .fields + .clear(); + let mut output = Vec::new(); + handle_auth_answer( + &adapter_without_registry(), + &mut state, + "auth-slash", + "", + &mut output, + ) + .unwrap(); + assert!(String::from_utf8(output) + .unwrap() + .contains("Credentials were not saved")); + assert_eq!( + state.auth.state.as_ref().unwrap().collected_values["provider_id"], + "dashscope" + ); +} + +#[test] +fn only_identity_failure_reopens_default_naming() { + let mut state = selecting("dashscope"); + answer_selected_row(&mut state); + let auth = state.auth.state.as_mut().unwrap(); + restore_after_failed_submission_at(auth, Some("provider_id")); + assert_eq!(auth.current_field_info().unwrap().name, "provider_id"); + let field = auth.current_field_info().cloned().unwrap(); + assert_eq!( + record_field_submission(auth, Some(&field), "new-name".into()), + FieldSubmission::Accepted + ); + restore_after_failed_submission_at(auth, None); + assert_eq!(auth.current_field_info().unwrap().name, "provider_id"); +} diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/runtime/tests.rs b/src/cosh-ng/crates/cosh-shell/src/auth/runtime/tests.rs index a6af9d87b4..8c703e2788 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/runtime/tests.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/runtime/tests.rs @@ -1,5 +1,8 @@ //! Unit tests for the `/auth` slash-command state machine. +#[path = "default_name_tests.rs"] +mod default_name_tests; + use super::{ apply_aliyun_prepare, auth_validation_body, begin_sysom_shortcut, clear_ecs_auth_source_for_manual_aliyun_edit, clear_observed_model_after_provider_change, @@ -84,6 +87,8 @@ fn slash_auth_state(templates: &[&str], sysom: SysomMenu) -> RuntimeAuthState { field_capture_revision: 0, existing_providers: Vec::new(), editing_provider_name: None, + default_provider_id: false, + from_sysom_shortcut: false, error_message: None, backend: AuthBackend::CoreRegistry, sysom, @@ -114,19 +119,22 @@ fn manual_prepare_mode_is_not_an_ecs_challenge() { ); let mut auth = slash_auth_state(&["aliyun"], SysomMenu::on_manual()); - assert!( - !apply_aliyun_prepare(&adapter_without_registry(), &mut auth) - .expect("cached manual prepare needs no registry") - ); + assert!(!apply_aliyun_prepare(&mut auth)); assert_eq!(auth.phase, AuthPhase::ManagingProviders); } #[test] -fn sysom_shortcut_starts_the_aliyun_template_at_provider_id() { +fn sysom_shortcut_requires_name_for_an_existing_aliyun_type() { let mut auth = slash_auth_state( &["dashscope", "openai_compat", "aliyun"], SysomMenu::on_ecs(ecs_prepare()), ); + auth.providers[2].fields = vec![field("provider_id", "Provider ID", false)]; + auth.existing_providers = vec![ExistingProvider { + name: "prod".to_string(), + provider_type: "aliyun".to_string(), + ..saved_dashscope() + }]; assert!(begin_sysom_shortcut(&mut auth)); @@ -147,6 +155,97 @@ fn sysom_shortcut_without_an_aliyun_template_reports_failure() { assert_eq!(auth.phase, AuthPhase::ManagingProviders); } +#[test] +fn first_sysom_provider_skips_the_name_field() { + let mut auth = slash_auth_state(&["aliyun"], SysomMenu::on_ecs(ecs_prepare())); + auth.providers[0].fields = vec![ + field("provider_id", "Provider ID", false), + field("access_key_id", "Access Key ID", true), + ]; + + assert!(begin_sysom_shortcut(&mut auth)); + assert_eq!( + auth.collected_values.get("provider_id").map(String::as_str), + Some("aliyun") + ); + assert_eq!(auth.current_field, 1); +} + +#[test] +fn every_first_provider_uses_its_own_default_name() { + for id in [ + "aliyun", + "dashscope", + "coding_plan", + "token_plan", + "openai_compat", + ] { + let mut auth = slash_auth_state(&[id], SysomMenu::on_manual()); + auth.phase = AuthPhase::SelectingProvider; + auth.providers[0].fields = vec![ + field("provider_id", "Provider ID", false), + field("api_key", "API Key", true), + ]; + let mut state = InlineState::default(); + state.auth.state = Some(auth); + + answer_selected_row(&mut state); + + let auth = state.auth.state.as_ref().unwrap(); + assert_eq!( + auth.collected_values.get("provider_id").map(String::as_str), + Some(id) + ); + assert_eq!( + auth.current_field_info().map(|field| field.name.as_str()), + Some("api_key") + ); + } +} + +#[test] +fn same_type_custom_name_and_cross_type_collision_require_naming() { + for (name, provider_type) in [("prod", "aliyun"), ("aliyun", "dashscope")] { + let mut auth = slash_auth_state(&["aliyun"], SysomMenu::on_manual()); + auth.phase = AuthPhase::SelectingProvider; + auth.providers[0].fields = vec![field("provider_id", "Provider ID", false)]; + auth.existing_providers = vec![ExistingProvider { + name: name.to_string(), + provider_type: provider_type.to_string(), + ..saved_dashscope() + }]; + let mut state = InlineState::default(); + state.auth.state = Some(auth); + + answer_selected_row(&mut state); + + let auth = state.auth.state.as_ref().unwrap(); + assert_eq!(auth.current_field, 0); + assert!(!auth.collected_values.contains_key("provider_id")); + } +} + +#[test] +fn active_run_selection_preserves_the_first_credential_field() { + for name in ["api_key", "base_url", "access_key_id"] { + let mut auth = slash_auth_state(&["openai_compat"], SysomMenu::default()); + auth.backend = AuthBackend::ActiveRun; + auth.phase = AuthPhase::SelectingProvider; + auth.providers[0].fields = vec![field(name, name, true)]; + let mut state = InlineState::default(); + state.auth.state = Some(auth); + + answer_selected_row(&mut state); + + let auth = state.auth.state.as_ref().unwrap(); + assert_eq!( + auth.current_field_info().map(|field| field.name.as_str()), + Some(name) + ); + assert!(!auth.collected_values.contains_key("provider_id")); + } +} + #[test] fn prefetched_challenge_is_applied_without_probing_ecs_again() { let mut auth = slash_auth_state(&["aliyun"], SysomMenu::on_ecs(ecs_prepare())); @@ -155,8 +254,7 @@ fn prefetched_challenge_is_applied_without_probing_ecs_again() { auth.collected_values .insert("access_key_id".to_string(), "stale-ak".to_string()); - let applied = apply_aliyun_prepare(&adapter_without_registry(), &mut auth) - .expect("cached prepare needs no registry"); + let applied = apply_aliyun_prepare(&mut auth); assert!(applied); assert_eq!( @@ -177,6 +275,26 @@ fn prefetched_challenge_is_applied_without_probing_ecs_again() { ); } +#[test] +fn waiting_ecs_authorization_does_not_offer_a_confirmation_action() { + let mut auth = slash_auth_state(&["aliyun"], SysomMenu::on_ecs(ecs_prepare())); + assert!(apply_aliyun_prepare(&mut auth)); + let mut state = InlineState::default(); + state.auth.state = Some(auth); + assert!(matches!( + crate::auth::capture::pending_auth_capture(&state), + Some(crate::runtime::prelude::RawInputCapture::Question { + option_count: 0, + .. + }) + )); + let mut output = Vec::new(); + crate::auth::prompt::render_current_auth_panel(&mut state, &mut output).unwrap(); + assert!(!String::from_utf8(output) + .unwrap() + .contains("I have authorized")); +} + #[test] fn unavailable_ecs_credentials_keep_the_authorization_challenge() { let mut auth = slash_auth_state(&["aliyun"], SysomMenu::on_ecs(ecs_prepare())); @@ -272,9 +390,12 @@ fn the_first_menu_row_starts_the_sysom_shortcut() { answer_selected_row(&mut state); let auth = state.auth.state.as_ref().expect("auth state"); - assert_eq!(auth.phase, AuthPhase::FillingField); + assert!(matches!(auth.phase, AuthPhase::AliyunEcsChallenge { .. })); assert_eq!(auth.current_provider().id, "aliyun"); - assert_eq!(auth.current_field, 0); + assert_eq!( + auth.collected_values.get("provider_id").map(String::as_str), + Some("aliyun") + ); } #[test] @@ -319,14 +440,26 @@ fn deleting_the_promoted_provider_restores_the_shortcut_row() { } #[test] -fn without_a_prefetched_challenge_prepare_still_asks_the_registry() { +fn without_a_prefetched_challenge_prepare_is_queued_without_registry_io() { let mut auth = slash_auth_state(&["aliyun"], SysomMenu::default()); + assert!(apply_aliyun_prepare(&mut auth)); + assert_eq!(auth.phase, AuthPhase::AliyunEcsPreparing); +} - let error = apply_aliyun_prepare(&adapter_without_registry(), &mut auth) - .expect_err("registry is consulted when nothing was prefetched"); - - assert!(error.contains("cosh-core"), "{error}"); - assert_eq!(auth.phase, AuthPhase::ManagingProviders); +#[test] +fn first_ecs_check_does_not_display_the_authorization_link() { + let mut auth = slash_auth_state(&["aliyun"], SysomMenu::on_ecs(ecs_prepare())); + assert!(apply_aliyun_prepare(&mut auth)); + let mut state = InlineState::default(); + state.auth.state = Some(auth); + let mut output = Vec::new(); + crate::auth::prompt::render_current_auth_panel(&mut state, &mut output).unwrap(); + let rendered = String::from_utf8(output).unwrap(); + assert!( + !rendered.contains("https://example.invalid/guide"), + "{rendered}" + ); + assert!(rendered.contains("Checking ECS RAM Role"), "{rendered}"); } #[test] diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/runtime_tests.rs b/src/cosh-ng/crates/cosh-shell/src/auth/runtime_tests.rs index a7e8702c3b..6ca1589197 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/runtime_tests.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/runtime_tests.rs @@ -602,3 +602,27 @@ fn provider_change_drops_stale_startup_auth_verdict() { assert!(!state.startup_auth.ai_unconfigured()); drop(sender); } + +#[test] +fn edit_identity_error_never_opens_the_immutable_name_field() { + let mut state = failed_edit_state(); + let auth = state.auth.state.as_mut().unwrap(); + + restore_after_failed_submission_at(auth, Some("provider_id")); + + assert_eq!(auth.current_field_info().unwrap().name, "base_url"); + assert_eq!(auth.collected_values["provider_id"], "qwen-prod"); +} + +#[test] +fn edit_retry_without_a_name_field_restores_the_first_credential() { + let mut state = failed_edit_state(); + let auth = state.auth.state.as_mut().unwrap(); + auth.providers[0].fields.remove(0); + + restore_after_failed_submission(auth); + + assert_eq!(auth.current_field, 0); + assert_eq!(auth.current_field_info().unwrap().name, "base_url"); + assert_eq!(auth.collected_values["provider_id"], "qwen-prod"); +} diff --git a/src/cosh-ng/crates/cosh-shell/src/auth/validation.rs b/src/cosh-ng/crates/cosh-shell/src/auth/validation.rs index 5c40c76629..2ea59eb2f0 100644 --- a/src/cosh-ng/crates/cosh-shell/src/auth/validation.rs +++ b/src/cosh-ng/crates/cosh-shell/src/auth/validation.rs @@ -7,7 +7,7 @@ use crate::runtime::prelude::AuthFieldInfo; -use super::runtime::RuntimeAuthState; +use super::runtime::{AuthBackend, RuntimeAuthState}; /// Hint rendered under the Provider ID prompt; states the character rule up front. pub(super) const PROVIDER_ID_HINT: &str = @@ -20,6 +20,9 @@ const PROVIDER_ID_EMPTY_ERROR: &str = "Provider ID cannot be empty."; const PROVIDER_ID_CHARSET_ERROR: &str = "Provider ID allows letters, digits, '-' and '_' only (no '.')."; +pub(super) const PROVIDER_ID_OCCUPIED_ERROR: &str = + "Provider ID is already in use. Choose a different name."; + /// Outcome of submitting the value of the field currently being filled. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum FieldSubmission { @@ -70,7 +73,14 @@ pub(super) fn record_field_submission( auth.field_error = None; return FieldSubmission::Accepted; }; - if let Some(error) = field_error(&field.name, &value) { + let error = field_error(&field.name, &value).or_else(|| { + (field.name == PROVIDER_ID_FIELD + && auth.backend == AuthBackend::CoreRegistry + && auth.editing_provider_name.is_none() + && auth.provider_name_is_taken(&value)) + .then_some(PROVIDER_ID_OCCUPIED_ERROR) + }); + if let Some(error) = error { auth.field_input = value; auth.field_error = Some(error.to_string()); auth.field_capture_revision = auth.field_capture_revision.wrapping_add(1); diff --git a/src/cosh-ng/crates/cosh-shell/src/i18n/en/auth.rs b/src/cosh-ng/crates/cosh-shell/src/i18n/en/auth.rs index bba63e39d0..c8d7290af1 100644 --- a/src/cosh-ng/crates/cosh-shell/src/i18n/en/auth.rs +++ b/src/cosh-ng/crates/cosh-shell/src/i18n/en/auth.rs @@ -5,6 +5,30 @@ pub(super) fn message(id: MessageId) -> Option<&'static str> { MessageId::AuthSelectProviderQuestion => { Some("\u{1f511} Authentication Required \u{2014} Select your AI provider:") } + MessageId::AuthEcsChecking => Some("Checking ECS RAM Role..."), + MessageId::AuthEcsWaiting => Some( + "Waiting for ECS RAM Role authorization. Configuration will continue automatically.", + ), + MessageId::AuthEcsRefreshing => Some( + "Waiting for ECS credentials to refresh. Configuration will continue automatically.", + ), + MessageId::AuthEcsRetry => Some("Check again"), + MessageId::AuthEcsCancelling => Some("Stopping ECS check and releasing resources..."), + MessageId::AuthEcsCleanupFailed => Some( + "ECS cleanup has not completed. New checks are disabled until resources are released.", + ), + MessageId::AuthEcsTimedOut => { + Some("ECS credential wait timed out. Automatic checks have stopped.") + } + MessageId::AuthEcsFailed => Some("ECS authentication check failed."), + MessageId::AuthEcsSaving => { + Some("Validating and saving ECS configuration. This submission cannot be cancelled.") + } + MessageId::AuthEcsUnknown => { + Some("Save result is unknown. Check provider management before submitting again.") + } + MessageId::AuthEcsReturn => Some("Return to provider management"), + MessageId::AuthEcsCancelHint => Some("Press Esc or Ctrl+C to cancel."), _ => None, } } diff --git a/src/cosh-ng/crates/cosh-shell/src/i18n/message_id.rs b/src/cosh-ng/crates/cosh-shell/src/i18n/message_id.rs index 9a7d54c691..5ba313a31e 100644 --- a/src/cosh-ng/crates/cosh-shell/src/i18n/message_id.rs +++ b/src/cosh-ng/crates/cosh-shell/src/i18n/message_id.rs @@ -113,4 +113,5 @@ collect_message_ids!([ enhanced_routing_mode_ids, managed_task_ids, composer_control_submit_ids, + auth_ecs_ids, ],); diff --git a/src/cosh-ng/crates/cosh-shell/src/i18n/message_id/auth.rs b/src/cosh-ng/crates/cosh-shell/src/i18n/message_id/auth.rs index 3d4bf42765..fa665a2b72 100644 --- a/src/cosh-ng/crates/cosh-shell/src/i18n/message_id/auth.rs +++ b/src/cosh-ng/crates/cosh-shell/src/i18n/message_id/auth.rs @@ -7,3 +7,24 @@ macro_rules! auth_ids { ); }; } + +macro_rules! auth_ecs_ids { + ($next:ident, $remaining:tt, $($ids:ident,)*) => { + $next!( + $remaining, + $($ids,)* + AuthEcsChecking, + AuthEcsWaiting, + AuthEcsRetry, + AuthEcsCancelling, + AuthEcsCleanupFailed, + AuthEcsTimedOut, + AuthEcsFailed, + AuthEcsSaving, + AuthEcsUnknown, + AuthEcsReturn, + AuthEcsCancelHint, + AuthEcsRefreshing, + ); + }; +} diff --git a/src/cosh-ng/crates/cosh-shell/src/i18n/mod.rs b/src/cosh-ng/crates/cosh-shell/src/i18n/mod.rs index 60f432addc..6656f9a68a 100644 --- a/src/cosh-ng/crates/cosh-shell/src/i18n/mod.rs +++ b/src/cosh-ng/crates/cosh-shell/src/i18n/mod.rs @@ -185,22 +185,22 @@ mod tests { ); assert_eq!( MessageId::ApprovalShellHandoffInputWaitTimeoutTitle as usize, - MessageId::ALL.len() - 46 + MessageId::AuthEcsChecking as usize - 46 ); assert_eq!( MessageId::ShellInputWaitHintTimeoutForecastBody as usize, - MessageId::ALL.len() - 37 + MessageId::AuthEcsChecking as usize - 37 ); // The #2068 startup auth-hint segment remains ahead of the appended // session-picker footer, Agent Composer, Trust-catalog, and hook-action // segments. assert_eq!( MessageId::StartupAuthHintLine as usize, - MessageId::ALL.len() - 36 + MessageId::AuthEcsChecking as usize - 36 ); assert_eq!( MessageId::SessionPickerMarkedFooter as usize, - MessageId::ALL.len() - 35 + MessageId::AuthEcsChecking as usize - 35 ); assert_eq!( MessageId::AgentComposerTitle as usize, @@ -208,7 +208,7 @@ mod tests { ); assert_eq!( MessageId::AgentComposerFooterEditing as usize, - MessageId::ALL.len() - 32 + MessageId::AuthEcsChecking as usize - 32 ); assert_eq!( MessageId::AgentComposerRejectedTitle as usize, @@ -220,53 +220,53 @@ mod tests { ); assert_eq!( MessageId::ApprovalTrustUnknownToolReason as usize, - MessageId::ALL.len() - 24 + MessageId::AuthEcsChecking as usize - 24 ); // The hook-action segment follows the Trust-catalog segment and remains // ahead of the appended Enhanced-routing segment. assert_eq!( MessageId::SlashHooksActionCancelledTitle as usize, - MessageId::ALL.len() - 23 + MessageId::AuthEcsChecking as usize - 23 ); assert_eq!( MessageId::SlashHooksActionCancelledBody as usize, - MessageId::ALL.len() - 22 + MessageId::AuthEcsChecking as usize - 22 ); assert_eq!( MessageId::SlashHooksActionVerbEnable as usize, - MessageId::ALL.len() - 21 + MessageId::AuthEcsChecking as usize - 21 ); assert_eq!( MessageId::SlashHooksActionVerbDisable as usize, - MessageId::ALL.len() - 20 + MessageId::AuthEcsChecking as usize - 20 ); assert_eq!( MessageId::SlashHooksActionQuestion as usize, - MessageId::ALL.len() - 19 + MessageId::AuthEcsChecking as usize - 19 ); assert_eq!( MessageId::SlashHooksActionOptionShell as usize, - MessageId::ALL.len() - 18 + MessageId::AuthEcsChecking as usize - 18 ); assert_eq!( MessageId::SlashHooksActionOptionAgent as usize, - MessageId::ALL.len() - 17 + MessageId::AuthEcsChecking as usize - 17 ); assert_eq!( MessageId::SlashHooksActionOptionBoth as usize, - MessageId::ALL.len() - 16 + MessageId::AuthEcsChecking as usize - 16 ); assert_eq!( MessageId::SlashHooksActionAgentEnabledBody as usize, - MessageId::ALL.len() - 15 + MessageId::AuthEcsChecking as usize - 15 ); assert_eq!( MessageId::SlashHooksActionAgentDisabledBody as usize, - MessageId::ALL.len() - 14 + MessageId::AuthEcsChecking as usize - 14 ); assert_eq!( MessageId::SlashHooksActionAgentErrorBody as usize, - MessageId::ALL.len() - 13 + MessageId::AuthEcsChecking as usize - 13 ); assert_eq!( MessageId::HelpSummaryModeRouting as usize, @@ -274,7 +274,7 @@ mod tests { ); assert_eq!( MessageId::RoutingModeShellOnlyFooter as usize, - MessageId::ALL.len() - 3 + MessageId::AuthEcsChecking as usize - 3 ); assert_eq!( MessageId::HelpSummaryTask as usize, @@ -282,7 +282,7 @@ mod tests { ); assert_eq!( MessageId::HelpSummaryTask as usize, - MessageId::ALL.len() - 2 + MessageId::AuthEcsChecking as usize - 2 ); assert_eq!( MessageId::PromptDraftFooterControlSubmitted as usize, @@ -290,7 +290,19 @@ mod tests { ); assert_eq!( MessageId::PromptDraftFooterControlSubmitted as usize, - MessageId::ALL.len() - 1 + MessageId::AuthEcsChecking as usize - 1 + ); + assert_eq!( + MessageId::AuthEcsChecking as usize, + MessageId::ALL.len() - 12 + ); + assert_eq!( + MessageId::AuthEcsCancelHint as usize, + MessageId::AuthEcsChecking as usize + 10 + ); + assert_eq!( + MessageId::AuthEcsRefreshing as usize + 1, + MessageId::ALL.len() ); } diff --git a/src/cosh-ng/crates/cosh-shell/src/i18n/zh/auth.rs b/src/cosh-ng/crates/cosh-shell/src/i18n/zh/auth.rs index 8c60f2cc28..5ac8a660da 100644 --- a/src/cosh-ng/crates/cosh-shell/src/i18n/zh/auth.rs +++ b/src/cosh-ng/crates/cosh-shell/src/i18n/zh/auth.rs @@ -3,6 +3,20 @@ use super::MessageId; pub(super) fn message(id: MessageId) -> Option<&'static str> { match id { MessageId::AuthSelectProviderQuestion => Some("\u{1f511} 需要认证 \u{2014} 选择 AI 服务:"), + MessageId::AuthEcsChecking => Some("正在检查 ECS RAM Role..."), + MessageId::AuthEcsWaiting => Some("正在等待 ECS RAM Role 授权,配置完成后将自动继续。"), + MessageId::AuthEcsRefreshing => Some("正在等待 ECS 凭据刷新,刷新后将自动继续配置。"), + MessageId::AuthEcsRetry => Some("重新检查"), + MessageId::AuthEcsCancelling => Some("正在停止 ECS 检查并回收资源..."), + MessageId::AuthEcsCleanupFailed => Some("ECS 资源尚未回收完成,暂不能发起新的检查。"), + MessageId::AuthEcsTimedOut => Some("等待 ECS 凭据已超时,自动检查已停止。"), + MessageId::AuthEcsFailed => Some("ECS 认证检查失败。"), + MessageId::AuthEcsSaving => Some("正在验证并保存 ECS 配置,此次提交不可取消。"), + MessageId::AuthEcsUnknown => { + Some("保存结果未确认,请先返回服务管理核实配置,不要重复提交。") + } + MessageId::AuthEcsReturn => Some("返回服务管理"), + MessageId::AuthEcsCancelHint => Some("按 Esc 或 Ctrl+C 取消。"), _ => None, } } diff --git a/src/cosh-ng/crates/cosh-shell/src/recommendation/personal_crypto.rs b/src/cosh-ng/crates/cosh-shell/src/recommendation/personal_crypto.rs index 8a8c4b8e6a..d5d0cf335a 100644 --- a/src/cosh-ng/crates/cosh-shell/src/recommendation/personal_crypto.rs +++ b/src/cosh-ng/crates/cosh-shell/src/recommendation/personal_crypto.rs @@ -63,7 +63,7 @@ pub(crate) fn sha256(input: &[u8]) -> [u8; SHA256_OUTPUT_BYTES] { 0x1f83d9ab, 0x5be0cd19, ]; - for chunk in padded.chunks_exact(SHA256_BLOCK_BYTES) { + for chunk in padded.as_chunks::().0 { compress(&mut state, chunk); } @@ -169,8 +169,8 @@ fn compress(state: &mut [u32; 8], block: &[u8]) { 0xc67178f2, ]; let mut words = [0u32; 64]; - for (index, bytes) in block.chunks_exact(4).enumerate() { - words[index] = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + for (index, bytes) in block.as_chunks::<4>().0.iter().enumerate() { + words[index] = u32::from_be_bytes(*bytes); } for index in 16..64 { let s0 = words[index - 15].rotate_right(7) diff --git a/src/cosh-ng/crates/cosh-shell/src/runtime/controller/bootstrap.rs b/src/cosh-ng/crates/cosh-shell/src/runtime/controller/bootstrap.rs index 82f84090ec..d7bbbae8d2 100644 --- a/src/cosh-ng/crates/cosh-shell/src/runtime/controller/bootstrap.rs +++ b/src/cosh-ng/crates/cosh-shell/src/runtime/controller/bootstrap.rs @@ -289,6 +289,7 @@ pub(crate) fn run_raw( } }; + crate::auth::ecs_poll::shutdown(&mut inline_state); config.clear_shell_environment_observer(); config.clear_shell_history_file_observer(); inline_state.personalization.poll_ready(); diff --git a/src/cosh-ng/crates/cosh-shell/src/runtime/dispatcher.rs b/src/cosh-ng/crates/cosh-shell/src/runtime/dispatcher.rs index 49f9a5eae6..f5a54e251f 100644 --- a/src/cosh-ng/crates/cosh-shell/src/runtime/dispatcher.rs +++ b/src/cosh-ng/crates/cosh-shell/src/runtime/dispatcher.rs @@ -85,6 +85,7 @@ impl RuntimeDispatcher { ) -> std::io::Result> { let batch = snapshot.batch_since(state.control.event_cursor()); render_inline_guidance_from_batch(snapshot, &batch, adapter, shell_label, state, output)?; + crate::auth::ecs_poll::poll(adapter, state, output)?; Ok(vec![RuntimeAction::AdvanceEventCursor(batch.to)]) } diff --git a/src/cosh-ng/crates/cosh-shell/src/runtime/evidence_requests.rs b/src/cosh-ng/crates/cosh-shell/src/runtime/evidence_requests.rs index 4354ee6898..573ad510b7 100644 --- a/src/cosh-ng/crates/cosh-shell/src/runtime/evidence_requests.rs +++ b/src/cosh-ng/crates/cosh-shell/src/runtime/evidence_requests.rs @@ -63,10 +63,7 @@ pub(crate) fn record_cosh_requests_from_active_run( card_ids: Vec::new(), notices: Vec::new(), }; - let parsed_requests = active_run - .pending_cosh_requests - .drain(..) - .collect::>(); + let parsed_requests = std::mem::take(&mut active_run.pending_cosh_requests); let first_parsed_audit_id = record_cosh_request_audits( state, &active_run.request.id, diff --git a/src/cosh-ng/crates/cosh-shell/tests/protocol.rs b/src/cosh-ng/crates/cosh-shell/tests/protocol.rs index a9d279729e..7d89f48ddf 100644 --- a/src/cosh-ng/crates/cosh-shell/tests/protocol.rs +++ b/src/cosh-ng/crates/cosh-shell/tests/protocol.rs @@ -2,6 +2,8 @@ mod claude_stream; #[path = "protocol/control.rs"] mod control; +#[path = "protocol/ecs_probe.rs"] +mod ecs_probe; #[path = "protocol/provider_lifecycle.rs"] mod provider_lifecycle; #[path = "protocol/session_management.rs"] diff --git a/src/cosh-ng/crates/cosh-shell/tests/protocol/ecs_probe.rs b/src/cosh-ng/crates/cosh-shell/tests/protocol/ecs_probe.rs new file mode 100644 index 0000000000..668d4d7efc --- /dev/null +++ b/src/cosh-ng/crates/cosh-shell/tests/protocol/ecs_probe.rs @@ -0,0 +1,299 @@ +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use cosh_shell::adapter::{CoshCoreAdapter, EcsProbeTask}; +use nix::libc; +use serde_json::Value; +use tempfile::TempDir; + +static PROBE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn fixture(mode: &str) -> (TempDir, CoshCoreAdapter, PathBuf) { + let home = tempfile::tempdir().unwrap(); + let program = home.path().join("core"); + let pid_file = home.path().join("pid"); + fs::write( + &program, + format!( + r#"#!/bin/sh +trap '' TERM +read -r request +request_id=${{request#*\"request_id\":\"}} +request_id=${{request_id%%\"*}} +mkfifo {:?} +printf '%s' "$$" > {:?} +if [ {:?} != silent ]; then + printf '{{"type":"registry_response","request_id":"%s","success":true,"data":{{"status":"ready"}}}}\n' "$request_id" +fi +if [ {:?} != ready ]; then + read -r unused < {:?} +fi +"#, + home.path().join("wait"), pid_file, mode, mode, home.path().join("wait") + ), + ) + .unwrap(); + fs::set_permissions(&program, fs::Permissions::from_mode(0o700)).unwrap(); + let adapter = CoshCoreAdapter::new(program.to_str().unwrap(), false); + (home, adapter, pid_file) +} + +fn started_pid(path: &Path) -> i32 { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(pid) = fs::read_to_string(path) + .ok() + .and_then(|value| value.parse().ok()) + { + return pid; + } + assert!( + Instant::now() < deadline, + "fixture never received the request" + ); + thread::sleep(Duration::from_millis(2)); + } +} + +fn finish(task: &mut EcsProbeTask) -> Result { + let deadline = Instant::now() + Duration::from_secs(8); + loop { + if let Some(result) = task.try_finish() { + return result; + } + assert!(Instant::now() < deadline, "probe did not finish and join"); + thread::sleep(Duration::from_millis(2)); + } +} + +fn assert_reaped(pid: i32) { + assert_eq!( + unsafe { libc::kill(pid, 0) }, + -1, + "owned child still exists" + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH) + ); +} + +fn sigchld_disposition() -> libc::sighandler_t { + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { libc::sigaction(libc::SIGCHLD, std::ptr::null(), &mut action) }, + 0, + "SIGCHLD disposition must be readable" + ); + action.sa_sigaction +} + +#[cfg(target_os = "linux")] +fn probe_threads() -> Vec { + fs::read_dir("/proc/self/task") + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| { + fs::read_to_string(path.join("comm")) + .is_ok_and(|name| name.starts_with("cosh-auth-ecs")) + }) + .collect() +} + +#[test] +fn probe_returns_only_after_its_child_is_reaped() { + let _guard = PROBE_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let (_home, adapter, pid_file) = fixture("ready"); + let mut task = adapter.start_ecs_probe("verify").expect("start probe"); + let result = finish(&mut task).expect("ready response"); + assert_eq!(result["status"], "ready"); + assert_reaped(started_pid(&pid_file)); + assert!(task.try_finish().is_none()); +} + +#[test] +fn fifty_cancellations_reap_each_owned_child() { + let _guard = PROBE_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let (_home, adapter, pid_file) = fixture("silent"); + for _ in 0..50 { + let _ = fs::remove_file(&pid_file); + let mut task = adapter.start_ecs_probe("verify").expect("start probe"); + let pid = started_pid(&pid_file); + #[cfg(target_os = "linux")] + let threads = probe_threads(); + #[cfg(target_os = "linux")] + assert_eq!(threads.len(), 1, "expected exactly one probe worker"); + let cancellation = Instant::now(); + task.cancel(); + let error = finish(&mut task).expect_err("cancelled probe must not succeed"); + assert!(error.contains("cancel"), "{error}"); + assert!(cancellation.elapsed() < Duration::from_secs(5)); + assert_reaped(pid); + #[cfg(target_os = "linux")] + for thread in threads { + assert!(!thread.exists(), "cancelled probe thread still exists"); + } + } +} + +#[test] +fn dropping_a_probe_cancels_and_joins_it() { + let _guard = PROBE_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let (_home, adapter, pid_file) = fixture("silent"); + let task = adapter.start_ecs_probe("prepare").expect("start probe"); + let pid = started_pid(&pid_file); + drop(task); + assert_reaped(pid); +} + +#[test] +fn response_without_process_exit_does_not_block_reaping() { + let _guard = PROBE_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let (_home, adapter, pid_file) = fixture("respond_hang"); + let mut task = adapter.start_ecs_probe("verify").expect("start probe"); + assert_eq!( + finish(&mut task).expect("ready response")["status"], + "ready" + ); + assert_reaped(started_pid(&pid_file)); +} + +#[test] +fn configure_response_does_not_wait_indefinitely_for_process_exit() { + let _guard = PROBE_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let (_home, adapter, pid_file) = fixture("respond_hang"); + let (done, finished) = std::sync::mpsc::channel(); + let watchdog_pid = pid_file.clone(); + let watchdog = thread::spawn(move || { + let pid = started_pid(&watchdog_pid); + if finished.recv_timeout(Duration::from_secs(2)).is_err() { + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + true + } else { + false + } + }); + let result = adapter.registry_query("auth", "configure", serde_json::json!({})); + let _ = done.send(()); + let forced_cleanup = watchdog.join().unwrap(); + assert_reaped(started_pid(&pid_file)); + assert!( + !forced_cleanup, + "configure waited for the fixture watchdog to kill its child" + ); + assert!(result.is_ok(), "{result:?}"); +} + +#[test] +fn probe_cleanup_with_ignored_sigchld() { + use std::os::unix::process::CommandExt; + use std::process::Command; + use wait_timeout::ChildExt; + + const CHILD_ENV: &str = "COSH_TEST_ECS_PROBE_IGNORED_SIGCHLD"; + if std::env::var_os(CHILD_ENV).is_some() { + assert_eq!( + sigchld_disposition(), + libc::SIG_IGN, + "this process must start from an inherited SIGCHLD ignore" + ); + for mode in ["ready", "silent"] { + let (_home, adapter, pid_file) = fixture(mode); + let mut task = adapter.start_ecs_probe("verify").unwrap(); + // Signalling the recorded PID/PGID is only safe while the kernel + // keeps the zombie for this process to wait for. + assert_ne!( + sigchld_disposition(), + libc::SIG_IGN, + "starting a probe must stop the kernel from reaping owned children" + ); + let pid = started_pid(&pid_file); + if mode == "silent" { + task.cancel(); + } + let result = finish(&mut task); + if mode == "ready" { + assert_eq!(result.unwrap()["status"], "ready"); + } else { + assert!(result.unwrap_err().contains("cancel")); + } + assert_reaped(pid); + assert!(task.try_finish().is_none()); + } + + // A caught disposition already retains zombies, so it must survive. + extern "C" fn count_child_signal(_signal: libc::c_int) {} + let caught = count_child_signal as *const () as libc::sighandler_t; + assert_ne!( + unsafe { libc::signal(libc::SIGCHLD, caught) }, + libc::SIG_ERR + ); + let (_home, adapter, pid_file) = fixture("ready"); + let mut task = adapter.start_ecs_probe("verify").unwrap(); + let pid = started_pid(&pid_file); + assert_eq!(finish(&mut task).unwrap()["status"], "ready"); + assert_reaped(pid); + assert_eq!( + sigchld_disposition(), + caught, + "a probe must not replace a handler installed by the host process" + ); + return; + } + + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "ecs_probe::probe_cleanup_with_ignored_sigchld", + "--nocapture", + ]) + .env(CHILD_ENV, "1"); + // Signal disposition must not leak into the other protocol tests. + unsafe { + command.pre_exec(|| { + if libc::signal(libc::SIGCHLD, libc::SIG_IGN) == libc::SIG_ERR { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command.spawn().unwrap(); + let status = child.wait_timeout(Duration::from_secs(12)).unwrap(); + if status.is_none() { + child.kill().unwrap(); + child.wait().unwrap(); + } + assert!( + status.is_some_and(|status| status.success()), + "probe cleanup must finish after the kernel has already reaped its child" + ); +} + +#[test] +fn silent_probe_times_out_and_reaps_its_child() { + let _guard = PROBE_TEST_LOCK + .lock() + .unwrap_or_else(|error| error.into_inner()); + let (_home, adapter, pid_file) = fixture("silent"); + let mut task = adapter.start_ecs_probe("prepare").expect("start probe"); + let pid = started_pid(&pid_file); + let error = finish(&mut task).expect_err("silent probe must time out"); + assert!(error.contains("timed out"), "{error}"); + assert_reaped(pid); +} diff --git a/src/cosh-ng/crates/cosh-shell/tests/raw_cli.rs b/src/cosh-ng/crates/cosh-shell/tests/raw_cli.rs index 30928416dd..5efe4f7560 100644 --- a/src/cosh-ng/crates/cosh-shell/tests/raw_cli.rs +++ b/src/cosh-ng/crates/cosh-shell/tests/raw_cli.rs @@ -20,6 +20,8 @@ mod approval; mod audit; #[path = "raw_cli/auth.rs"] mod auth; +#[path = "raw_cli/auth_ecs.rs"] +mod auth_ecs; #[path = "raw_cli/cancellation.rs"] mod cancellation; #[path = "raw_cli/compaction.rs"] diff --git a/src/cosh-ng/crates/cosh-shell/tests/raw_cli/auth.rs b/src/cosh-ng/crates/cosh-shell/tests/raw_cli/auth.rs index 30b2e7f58a..bf8c29d273 100644 --- a/src/cosh-ng/crates/cosh-shell/tests/raw_cli/auth.rs +++ b/src/cosh-ng/crates/cosh-shell/tests/raw_cli/auth.rs @@ -2,14 +2,14 @@ use super::*; /// Fake cosh-core exposing a single OpenAI Compatible template and logging registry traffic. /// -/// The template omits `provider_id` because slash auth injects it as the first field. +/// Slash auth asks for `provider_id` only when the same template is already configured. const AUTH_REGISTRY_CORE: &str = r#"#!/bin/sh if [ "$1" = "--registry" ]; then read -r request printf '%s\n' "$request" >> "$AUTH_REGISTRY_LOG" case "$request" in *'"action":"state"'*) - printf '%s\n' '{"type":"registry_response","request_id":"reg","success":true,"data":{"templates":[{"id":"openai_compat","label":"OpenAI Compatible","fields":[{"name":"base_url","label":"Base URL","hint":null,"secret":false,"required":true,"placeholder":null},{"name":"api_key","label":"API Key","hint":null,"secret":true,"required":true,"placeholder":null},{"name":"model","label":"Model","hint":null,"secret":false,"required":true,"placeholder":null}]}],"saved_providers":[]}}' + printf '%s%s%s\n' '{"type":"registry_response","request_id":"reg","success":true,"data":{"templates":[{"id":"openai_compat","label":"OpenAI Compatible","fields":[{"name":"base_url","label":"Base URL","hint":null,"secret":false,"required":true,"placeholder":null},{"name":"api_key","label":"API Key","hint":null,"secret":true,"required":true,"placeholder":null},{"name":"model","label":"Model","hint":null,"secret":false,"required":true,"placeholder":null}]}],"saved_providers":' "${AUTH_SAVED_PROVIDERS:-[]}" '}}' ;; *'"action":"configure"'*) if [ -n "$AUTH_CONFIGURE_ERROR" ]; then @@ -54,8 +54,7 @@ fn raw_cli_auth_failure_keeps_panel_and_never_claims_success() { Path::new(env!("CARGO_MANIFEST_DIR")), &[ ("cosh-osc$", b"/auth\n".as_slice()), - ("Left/Right move | Enter send", b"\n".as_slice()), - ("Enter Provider ID", b"test-provider\n".as_slice()), + ("Select your AI provider:", b"\n".as_slice()), ("Enter Base URL", b"http://127.0.0.1:1/v1\n".as_slice()), ("Enter API Key", b"sk-rejected\n".as_slice()), ("Enter Model", b"test-model\n".as_slice()), @@ -78,7 +77,16 @@ fn raw_cli_auth_failure_keeps_panel_and_never_claims_success() { assert!(compact[failure..].contains("Enter API Key"), "{output}"); assert!(!compact.contains("Auth configured"), "{output}"); assert!(!compact.contains("credentials saved"), "{output}"); + assert!(!compact.contains("Provider ID"), "{output}"); assert_eq!(action_count(&requests, "configure"), 1, "{requests}"); + let configure = requests + .lines() + .find(|line| line.contains(r#""action":"configure""#)) + .unwrap_or_else(|| panic!("expected configure request: {requests}")); + assert!( + configure.contains(r#""provider_id":"openai_compat""#), + "{configure}" + ); } /// A dotted Provider ID must be rejected on the spot instead of at the final `configure`. @@ -101,11 +109,13 @@ fn raw_cli_auth_dotted_provider_id_can_be_corrected() { ("HOME", &home_str), ("COSH_CORE_PATH", &core_str), ("AUTH_REGISTRY_LOG", &log_str), + ("AUTH_SAVED_PROVIDERS", SAVED_OPENAI_COMPAT), ], Path::new(env!("CARGO_MANIFEST_DIR")), &[ ("cosh-osc$", b"/auth\n".as_slice()), - ("Left/Right move | Enter send", b"\n".as_slice()), + ("+ Add new provider", b"\x1b[C\n".as_slice()), + ("Authentication Required", b"\n".as_slice()), ("Type answer | Enter send", b"qwen3.7-max\n".as_slice()), ("Provider ID allows letters", b"\x7f".as_slice()), ("> qwen3.7-ma", b"\x7f".as_slice()), @@ -172,17 +182,27 @@ const AUTH_MENU_CORE: &str = r#"#!/bin/sh if [ "$1" = "--registry" ]; then read -r request printf '%s\n' "$request" >> "$AUTH_REGISTRY_LOG" + request_id=${request#*'"request_id":"'} + request_id=${request_id%%'"'*} case "$request" in *'"action":"state"'*) - printf '%s\n' "$AUTH_STATE" + response=$AUTH_STATE ;; *'"action":"prepare"'*) - printf '%s\n' "$AUTH_PREPARE" + response=$AUTH_PREPARE + ;; + *'"action":"verify"'*) + response='{"type":"registry_response","request_id":"reg","success":true,"data":{"status":"ready"}}' ;; *) - printf '%s\n' '{"type":"registry_response","request_id":"reg","success":true,"data":{"authorized":true,"model":"main-model","configured":true}}' + response='{"type":"registry_response","request_id":"reg","success":true,"data":{"model":"main-model","configured":true}}' ;; esac + # Async probes require the caller's correlation ID, not the fixture's placeholder. + prefix=${response%%'"request_id":"'*} + suffix=${response#*'"request_id":"'} + suffix=${suffix#*'"'} + printf '%s"request_id":"%s"%s\n' "$prefix" "$request_id" "$suffix" exit 0 fi read -r init @@ -195,6 +215,10 @@ const AUTH_TEMPLATES: &str = r#"[{"id":"aliyun","label":"Aliyun Authentication", const SAVED_NONE: &str = "[]"; +const SAVED_OPENAI_COMPAT: &str = r#"[{"provider_id":"prod","provider_type":"openai_compat","source":"user","editable":true,"auth_source":null,"model":"test-model","base_url":"https://example.invalid/v1","api_key_len":8,"active":true}]"#; + +const SAVED_ALIYUN_MANUAL: &str = r#"[{"provider_id":"aliyun-manual","provider_type":"aliyun","source":"user","editable":true,"auth_source":"manual","model":"qwen3.7-plus","base_url":null,"active":true}]"#; + const SAVED_DASHSCOPE: &str = r#"[{"provider_id":"qwen-prod","provider_type":"dashscope","source":"user","editable":true,"auth_source":null,"model":"qwen3.7-plus","base_url":null,"api_key_len":8,"active":true}]"#; const SAVED_CODING_PLAN: &str = r#"[{"provider_id":"coding-prod","provider_type":"openai","source":"user","editable":true,"auth_source":null,"model":"qwen3.7-plus","base_url":"https://coding.dashscope.aliyuncs.com/v1","api_key_len":12,"active":true}]"#; @@ -426,7 +450,7 @@ fn raw_cli_auth_edits_coding_plan_with_its_original_template() { fn raw_cli_auth_sysom_shortcut_still_validates_provider_id() { let (output, requests) = run_auth_menu_flow( "auth-sysom-bad-id", - SAVED_NONE, + SAVED_ALIYUN_MANUAL, ECS_PREPARE, &[ ("cosh-osc$", b"/auth\n".as_slice()), @@ -441,10 +465,11 @@ fn raw_cli_auth_sysom_shortcut_still_validates_provider_id() { compact.contains("Provider ID allows letters, digits, '-' and '_' only (no '.')"), "{output}" ); - // A rejected id must not reach the challenge or the registry. + // A rejected id must not reach the challenge or start verification/configuration. assert!(!compact.contains("ECS Instance ID"), "{output}"); assert_eq!(action_count(&requests, "configure"), 0, "{requests}"); assert_eq!(action_count(&requests, "prepare"), 1, "{requests}"); + assert_eq!(action_count(&requests, "verify"), 0, "{requests}"); } /// The shortcut reuses the challenge `/auth` prefetched and configures aliyun + RAM role. @@ -457,17 +482,22 @@ fn raw_cli_auth_sysom_shortcut_reuses_prefetched_challenge() { &[ ("cosh-osc$", b"/auth\n".as_slice()), ("+ Add new provider", b"\n".as_slice()), - ("Enter Provider ID", b"sysom-trial\n".as_slice()), - ("ECS Instance ID", b"\n".as_slice()), ("Auth configured", b"".as_slice()), ], ); let compact = compact_terminal_words(&output); - assert!(compact.contains("i-fake-ecs-1"), "{output}"); + // The first verification is ready, so no naming or authorization instructions are shown. + assert!(!compact.contains("Provider ID"), "{output}"); + assert!(!compact.contains("ECS Instance ID"), "{output}"); + assert!(!compact.contains("i-fake-ecs-1"), "{output}"); + assert!(!compact.contains("alinux.console.aliyun.com"), "{output}"); + assert!(!compact.contains("QR"), "{output}"); assert!(compact.contains("Auth configured"), "{output}"); // The ECS metadata service is probed once, when `/auth` builds the menu. assert_eq!(action_count(&requests, "prepare"), 1, "{requests}"); + assert_eq!(action_count(&requests, "verify"), 1, "{requests}"); + assert_eq!(action_count(&requests, "configure"), 1, "{requests}"); let configure = requests .lines() .find(|line| line.contains(r#""action":"configure""#)) @@ -477,7 +507,7 @@ fn raw_cli_auth_sysom_shortcut_reuses_prefetched_challenge() { "{configure}" ); assert!( - configure.contains(r#""provider_id":"sysom-trial""#), + configure.contains(r#""provider_id":"aliyun""#), "{configure}" ); assert!( @@ -525,12 +555,10 @@ fn raw_cli_auth_esc_walks_back_through_the_form_before_cancelling() { "Authentication Required", b"\x1b[C\x1b[C\x1b[C\x1b[C\n".as_slice(), ), - ("Enter Provider ID", b"qwen-prod\n".as_slice()), ("Enter Base URL", b"https://example.invalid/v1\n".as_slice()), // ESC on API Key returns to Base URL, which still carries the value just submitted. ("Enter API Key", b"\x1b".as_slice()), ("Enter Base URL", b"\x1b".as_slice()), - ("Enter Provider ID", b"\x1b".as_slice()), // Back at the picker a further ESC is the one that ends the flow. ("Authentication Required", b"\x1b".as_slice()), ("Auth cancelled", b"".as_slice()), @@ -544,12 +572,12 @@ fn raw_cli_auth_esc_walks_back_through_the_form_before_cancelling() { "stepping back lost the submitted Base URL: {output}" ); assert!( - compact.contains("> qwen-prod"), - "stepping back lost the submitted Provider ID: {output}" + !compact.contains("Provider ID"), + "first-time setup must not step back onto the hidden Provider ID: {output}" ); // The picker reopens on the template the form belonged to. assert!(compact.contains("> [5] OpenAI Compatible"), "{output}"); - // Only the last ESC cancels; the three before it are back-navigation. + // Only the last ESC cancels; the two before it are back-navigation. assert_eq!( count_occurrences(&compact, "Auth cancelled"), 1, @@ -567,7 +595,7 @@ fn raw_cli_auth_esc_preserves_picker_focus_for_the_next_arrow() { &[ ("cosh-osc$", b"/auth\n".as_slice()), ("Authentication Required", b"\x1b[C\x1b[C\n".as_slice()), - ("Enter Provider ID", b"\x1b".as_slice()), + ("Enter API Key", b"\x1b".as_slice()), ("Authentication Required", b"\x1b[B".as_slice()), ("> [4] DashScope", b"\x1b".as_slice()), ("Auth cancelled", b"".as_slice()), @@ -577,11 +605,12 @@ fn raw_cli_auth_esc_preserves_picker_focus_for_the_next_arrow() { let compact = compact_terminal_words(&output); assert!(compact.contains("> [3] Token Plan"), "{output}"); assert!(compact.contains("> [4] DashScope"), "{output}"); + assert!(!compact.contains("Provider ID"), "{output}"); assert_eq!(action_count(&requests, "configure"), 0, "{requests}"); } /// Teaching ESC to step back must not take away the interrupt: Ctrl+C still abandons the form in -/// one keystroke, from a field the user is several prompts into. +/// one keystroke without advancing to the next field. #[test] fn raw_cli_auth_ctrl_c_mid_form_abandons_the_flow() { let (output, requests) = run_auth_menu_flow( @@ -594,7 +623,6 @@ fn raw_cli_auth_ctrl_c_mid_form_abandons_the_flow() { "Authentication Required", b"\x1b[C\x1b[C\x1b[C\x1b[C\n".as_slice(), ), - ("Enter Provider ID", b"qwen-prod\n".as_slice()), ("Enter Base URL", b"\x03".as_slice()), ("Auth cancelled", b"".as_slice()), ], @@ -602,6 +630,8 @@ fn raw_cli_auth_ctrl_c_mid_form_abandons_the_flow() { let compact = compact_terminal_words(&output); assert!(compact.contains("Auth cancelled"), "{output}"); + assert_eq!(count_occurrences(&compact, "Auth cancelled"), 1, "{output}"); + assert!(!compact.contains("Provider ID"), "{output}"); // A single Ctrl+C is enough: the form is gone, not one prompt further back. assert!( !compact.contains("Enter API Key"), @@ -653,7 +683,6 @@ fn raw_cli_auth_non_ecs_aliyun_falls_back_to_manual_keys() { &[ ("cosh-osc$", b"/auth\n".as_slice()), ("Authentication Required", b"\n".as_slice()), - ("Enter Provider ID", b"aliyun-manual\n".as_slice()), ("Enter Access Key ID", b"AK-TEST-VALUE\n".as_slice()), ("Enter Access Key Secret", b"".as_slice()), ], @@ -663,9 +692,10 @@ fn raw_cli_auth_non_ecs_aliyun_falls_back_to_manual_keys() { assert!(compact.contains("Enter Access Key ID"), "{output}"); assert!(compact.contains("Enter Access Key Secret"), "{output}"); assert!(!compact.contains("ECS Instance ID"), "{output}"); + assert!(!compact.contains("Provider ID"), "{output}"); // Secret fields are echoed as bullets, never as the typed key. assert!(!compact.contains("AK-TEST-VALUE"), "{output}"); assert!(compact.contains('\u{2022}'), "{output}"); - // The successful startup result is reused after the Provider ID is accepted. + // The successful startup result is reused after selecting the template. assert_eq!(action_count(&requests, "prepare"), 1, "{requests}"); } diff --git a/src/cosh-ng/crates/cosh-shell/tests/raw_cli/auth_ecs.rs b/src/cosh-ng/crates/cosh-shell/tests/raw_cli/auth_ecs.rs new file mode 100644 index 0000000000..e72b975c12 --- /dev/null +++ b/src/cosh-ng/crates/cosh-shell/tests/raw_cli/auth_ecs.rs @@ -0,0 +1,243 @@ +use super::*; + +const CORE: &str = r#"#!/bin/sh +if [ "$1" = --registry ]; then + read -r request + printf '%s\n' "$request" >> "$AUTH_REGISTRY_LOG" + request_id=${request#*\"request_id\":\"} + request_id=${request_id%%\"*} + case "$request" in + *'"action":"state"'*) + data='{"templates":[{"id":"aliyun","label":"Aliyun Authentication","fields":[{"name":"access_key_id","label":"Access Key ID","secret":true,"required":true}]}],"saved_providers":[]}' ;; + *'"action":"prepare"'*) + data='{"mode":"ecs_ram_role","instance_id":"i-fixture","console_url":"https://example.invalid/authorize","values":{"auth_source":"ecs_ram_role"}}' ;; + *'"action":"verify"'*) + if [ "$AUTH_WAIT_ONCE" = 1 ] && [ ! -f "$AUTH_READY_MARK" ]; then + : > "$AUTH_READY_MARK" + data='{"status":"not_ready","reason":"role_missing"}' + else + data='{"status":"ready"}' + fi ;; + *'"action":"configure"'*) data='{"provider_id":"aliyun"}' ;; + *) data='{"configured":true}' ;; + esac + printf '{"type":"registry_response","request_id":"%s","success":true,"data":%s}\n' "$request_id" "$data" + exit 0 +fi +read -r init +printf '%s\n' '{"type":"control_response","response":{"subtype":"success","request_id":"init-1","response":{"subtype":"initialize","capabilities":{}}}}' +printf '%s\n' '{"type":"system","subtype":"init","session_id":"ecs-test","model":"test-model","tools":[]}' +printf '%s\n' '{"type":"result","subtype":"success","session_id":"ecs-test","is_error":false,"result":"done"}' +"#; + +fn run_auth(wait_once: bool, inputs: &[(&str, &[u8])]) -> (String, String) { + let home = tempfile::tempdir().unwrap(); + let core = home.path().join("core"); + let log = home.path().join("requests"); + let mark = home.path().join("ready"); + write_executable(&core, CORE); + let output = run_raw_cli_with_args_env_current_dir_and_marker_input( + "cosh-core", + &[], + &[ + ("HOME", home.path().to_str().unwrap()), + ("COSH_CORE_PATH", core.to_str().unwrap()), + ("AUTH_REGISTRY_LOG", log.to_str().unwrap()), + ("AUTH_READY_MARK", mark.to_str().unwrap()), + ("AUTH_WAIT_ONCE", if wait_once { "1" } else { "0" }), + ], + Path::new(env!("CARGO_MANIFEST_DIR")), + inputs, + ); + ( + compact_terminal_words(&output), + fs::read_to_string(log).unwrap(), + ) +} + +#[test] +fn ecs_ready_configures_without_name_or_authorization_confirmation() { + let (output, requests) = run_auth( + false, + &[ + ("cosh-osc$", b"/auth\n"), + ("Select your AI provider:", b"\n"), + ("Auth configured", b""), + ], + ); + assert!(output.contains("Auth configured"), "{output}"); + assert!(!output.contains("Enter Provider ID"), "{output}"); + assert!( + !output.contains("https://example.invalid/authorize"), + "{output}" + ); + assert!(!output.contains("I have authorized"), "{output}"); + assert_eq!( + requests.matches("\"action\":\"configure\"").count(), + 1, + "{requests}" + ); +} + +#[test] +fn ecs_waiting_becomes_ready_without_another_keypress() { + let (output, requests) = run_auth( + true, + &[ + ("cosh-osc$", b"/auth\n"), + ("Select your AI provider:", b"\n"), + ("Auth configured", b""), + ], + ); + assert!(output.contains("Waiting for ECS RAM Role"), "{output}"); + assert!( + output.contains("https://example.invalid/authorize"), + "{output}" + ); + assert!(output.contains("Auth configured"), "{output}"); + assert_eq!( + requests.matches("\"action\":\"verify\"").count(), + 2, + "{requests}" + ); + assert_eq!( + requests.matches("\"action\":\"configure\"").count(), + 1, + "{requests}" + ); +} + +#[test] +fn ecs_cancel_returns_shell_input_and_allows_a_new_auth_flow() { + let (output, requests) = run_auth( + true, + &[ + ("cosh-osc$", b"/auth\n"), + ("Select your AI provider:", b"\n"), + ("Waiting for ECS RAM Role", b"\x03"), + ("Auth cancelled", b"printf 'AUTH-CANCEL-OK\\n'\n"), + ("AUTH-CANCEL-OK", b""), + ("cosh-osc$", b"/auth\n"), + ("Select your AI provider:", b"\n"), + ("Auth configured", b""), + ], + ); + assert!(output.contains("Auth cancelled"), "{output}"); + assert!(output.contains("AUTH-CANCEL-OK"), "{output}"); + assert!(output.contains("Auth configured"), "{output}"); + assert_eq!( + requests.matches("\"action\":\"configure\"").count(), + 1, + "{requests}" + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn initial_menu_prepare_ctrl_c_reaps_probe_and_worker_in_live_shell() { + let home = tempfile::tempdir().unwrap(); + let core = home.path().join("core"); + let log = home.path().join("requests"); + // Check recorded PIDs/TIDs before the harness closes the live shell's stdin. + let script = CORE.replacen( + "#!/bin/sh\n", + r#"#!/bin/sh +if [ "$1" = --assert-menu-prepare-reaped ]; then + read -r owner < "$AUTH_PROBE_DIR/owner" + read -r probe < "$AUTH_PROBE_DIR/probe" + result=ok + kill -0 "$owner" 2>/dev/null || result=shell-exited + [ -d "/proc/$owner/task" ] || result=shell-exited + [ ! -e "/proc/$probe" ] || result=probe-not-reaped + [ -s "$AUTH_PROBE_DIR/workers" ] || result=worker-never-started + while read -r worker; do + [ ! -e "$worker" ] || result=worker-not-reaped + done < "$AUTH_PROBE_DIR/workers" + for comm in /proc/"$owner"/task/*/comm; do + [ -r "$comm" ] || continue + read -r name < "$comm" || continue + case "$name" in cosh-auth-ecs*) result=ecs-worker-still-running ;; esac + done + printf '%s\n' "$result" > "$AUTH_PROBE_DIR/check-result" + printf '%s\n' 'AUTH-MENU-CANCEL-CHECKS-DONE' + exit 0 +fi +"#, + 1, + ); + let script = script.replace( + "*'\"action\":\"prepare\"'*)", + r#"*'"action":"prepare"'*) + mkfifo "$AUTH_PROBE_DIR/block" || exit 1 + printf '%s\n' "$PPID" > "$AUTH_PROBE_DIR/owner" + printf '%s\n' "$$" > "$AUTH_PROBE_DIR/probe" + : > "$AUTH_PROBE_DIR/workers" + for comm in /proc/"$PPID"/task/*/comm; do + [ -r "$comm" ] || continue + read -r name < "$comm" || continue + case "$name" in + cosh-auth-ecs*) printf '%s\n' "${comm%/comm}" >> "$AUTH_PROBE_DIR/workers" ;; + esac + done + # Registry stdout is private; synchronize through the recorded owner's stdout. + printf '%s\n' 'AUTH-MENU-PREPARE-STARTED' > "/proc/$PPID/fd/1" + # No writer exists: only cancellation/timeout can end this request. + read -r unused < "$AUTH_PROBE_DIR/block" + exit 1 +"#, + ); + write_executable(&core, &script); + let output = run_raw_cli_with_args_env_current_dir_and_marker_input( + "cosh-core", + &[], + &[ + ("HOME", home.path().to_str().unwrap()), + ("COSH_CORE_PATH", core.to_str().unwrap()), + ("COSH_SHELL_STARTUP_BANNER", "0"), + ("AUTH_REGISTRY_LOG", log.to_str().unwrap()), + ("AUTH_PROBE_DIR", home.path().to_str().unwrap()), + ], + Path::new(env!("CARGO_MANIFEST_DIR")), + &[ + ("cosh-osc$", b"/auth\n"), + ("AUTH-MENU-PREPARE-STARTED", b"\x03"), + ( + "Auth cancelled", + b"\"$COSH_CORE_PATH\" --assert-menu-prepare-reaped\n", + ), + ( + "AUTH-MENU-CANCEL-CHECKS-DONE", + b"printf 'AUTH-%s\\n' 'MENU-COMMAND-OK'\n", + ), + ("AUTH-MENU-COMMAND-OK", b""), + ], + ); + let output = compact_terminal_words(&output); + assert!(output.contains("AUTH-MENU-PREPARE-STARTED"), "{output}"); + assert!(output.contains("Auth cancelled"), "{output}"); + assert!(output.contains("AUTH-MENU-COMMAND-OK"), "{output}"); + assert_eq!( + fs::read_to_string(home.path().join("check-result")) + .unwrap() + .trim(), + "ok", + "probe PID and named worker must be gone while the same shell is alive: {output}" + ); + // A timed-out fallback or late menu must not impersonate cancellation of prepare. + assert!(!output.contains("Select your AI provider:"), "{output}"); + assert!(!output.contains("Auth configured"), "{output}"); + let requests = fs::read_to_string(log).unwrap(); + let auth_calls: Vec = requests + .lines() + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|call| call["domain"] == "auth") + .collect(); + assert_eq!( + auth_calls + .iter() + .map(|call| call["action"].as_str().unwrap()) + .collect::>(), + vec!["state", "prepare"], + "cancel must not prepare again, verify, or configure: {requests}" + ); +} diff --git a/src/cosh-ng/crates/cosh-shell/tests/raw_cli/provider_handoff/foreground.rs b/src/cosh-ng/crates/cosh-shell/tests/raw_cli/provider_handoff/foreground.rs index 65560f06bf..328e28a1cd 100644 --- a/src/cosh-ng/crates/cosh-shell/tests/raw_cli/provider_handoff/foreground.rs +++ b/src/cosh-ng/crates/cosh-shell/tests/raw_cli/provider_handoff/foreground.rs @@ -288,8 +288,13 @@ printf '%s\n' '{"type":"result","subtype":"success","session_id":"sess-claude-na "{output}" ); assert!(!output.contains("host_executed_shell"), "{output}"); - assert!(output.contains("\rCLAUDE_NATIVE\r\n"), "{output}"); let visible = strip_ansi_escape(&output); + assert!( + visible + .split(['\r', '\n']) + .any(|line| line == "CLAUDE_NATIVE"), + "{output}" + ); assert_eq!( count_occurrences(&visible, "cosh-osc$ echo CLAUDE_NATIVE"), 1, diff --git a/src/cosh-ng/docs/design/ecs-auth-provisioning.md b/src/cosh-ng/docs/design/ecs-auth-provisioning.md new file mode 100644 index 0000000000..eb3abb1edd --- /dev/null +++ b/src/cosh-ng/docs/design/ecs-auth-provisioning.md @@ -0,0 +1,211 @@ +# cosh-ng ECS Auth Provisioning Design + +Date: 2026-09-17 + +Related documents: [runtime contracts](runtime-contracts.md) + +## Summary + +First-time provider configuration and ECS RAM Role authentication run over the +`cosh-core` registry request/response wire, driven by a state machine in +`cosh-shell`. `cosh-shell` reads one JSON request/response per registry call. +For the cancellable read-only operations (`prepare` / `verify`) it spawns a +dedicated short-lived `cosh-core --registry` process it exclusively owns; the +`configure` save prefers the shared live core and only falls back to a +short-lived `cosh-core --registry` process when no live runtime exists. This +document fixes the parts of that surface that are a cross-component contract: the +`auth` domain `prepare` / `verify` / `configure` wire shapes, the shell-side +polling lifecycle and its time budgets, and the signal-safety invariant the +isolated probe relies on. shell and core must be upgraded together, because the +`verify` response shape changed in a non-backward-compatible way. + +## Registry `auth` wire contract + +The transport is JSONL: each request and response is exactly one JSON object +terminated by a newline. Every action uses the same correlation envelope: + +```json +{ + "type": "registry_request", + "request_id": "reg-42", + "domain": "auth", + "action": "prepare", + "params": { "provider_type": "aliyun" } +} +``` + +```json +{ + "type": "registry_response", + "request_id": "reg-42", + "success": true, + "data": { "mode": "manual" } +} +``` + +Every request sets `type: "registry_request"` with a `request_id`, and cosh-core +echoes that same `request_id` on the matching `registry_response`; input lines +that are not a `registry_request` are ignored, so a malformed request fails +closed as a shell-side timeout. How strictly the shell enforces the response +envelope depends on the path: the live core transport and the isolated ECS probe +reject a response whose `type` or `request_id` does not match the pending request +(`cosh_core_service/process.rs`, `adapter/ecs_probe.rs`), while the short-lived +fallback used by `configure` when no live runtime exists parses the first +non-empty stdout line on `success` / `data` / `error` alone and does not re-check +the correlation fields (`adapter/cosh_core_registry.rs`); its dedicated child +carries exactly one request, so there is no second response to confuse it. + +`params` is action-specific; the core deserializes it with `#[serde(default)]`, +so an omitted or `null` value is accepted and read the same way as an empty +object. A failed response sets `success: false`, may still carry structured +`data` (for example `error_code`), and carries the developer-facing `error` +string. Optional `data` / `error` fields are omitted when absent. + +### `prepare` — decide the entry mode + +Input: `{ "provider_type": "" }`. Only `aliyun` triggers ECS detection; +every other type returns manual mode. + +Output `data`: + +- Manual providers, or aliyun without an ECS challenge: + `{ "mode": "manual" }`. +- aliyun on an ECS instance with a RAM Role challenge: + `{ "mode": "ecs_ram_role", "instance_id": "...", "console_url": "...", + "values": { "auth_source": "ecs_ram_role" } }`. +- Detection distinguishes "not an ECS instance" from "ECS but probe failed" by + where the metadata request fails. If the IMDSv2 token fetch is `Unreachable` + or times out, detection returns `{ "mode": "manual" }` — that is the expected + fallback in non-ECS environments, not a protocol violation. Only after a valid + token has established ECS identity does a subsequent instance-id/zone GET + failure propagate as the `verify` error shape below (`success: false`, + `error_code`); such a confirmed-ECS failure is never downgraded to manual. + +`prepare` is a read-only detection. It never verifies credentials or saves +configuration. + +### `verify` — classify credential readiness + +Input: `{ "provider_type": "", "auth_source": "" }`. Only +`provider_type == "aliyun"` with `auth_source == "ecs_ram_role"` performs an +IMDSv2 metadata probe; other combinations return `{ "authorized": true }` +unchanged. + +For the ECS path the response replaces the former `authorized: bool` with a +three-way classification: + +| Result | `success` | `data` | `error` | +|--------|-----------|--------|---------| +| Complete unexpired credentials | `true` | `{ "status": "ready" }` | absent | +| Reachable but not usable yet | `true` | `{ "status": "not_ready", "reason": "" }` | absent | +| Probe failed | `false` | `{ "error_code": "" }` | developer message | + +`reason` ∈ `role_missing`, `credentials_expired`. `error_code` ∈ +`metadata_access_denied`, `invalid_metadata_response`, `metadata_unreachable`, +`metadata_timeout`, `metadata_http_error`. The probe verifies only that the +named role currently exposes complete, unexpired credentials; it does not claim +to validate SysOM service permissions, quota, or the inference path, and never +returns credential material or upstream response bodies. + +### `configure` — persist one provider + +Input: `{ "provider_id": "...", "provider_type": "...", "values": { ... } }`. +Rejects an empty id/type (`missing provider_id or provider_type`) and a +non-editable provider (`provider is not editable`). On success returns +`{ "provider_id": "..." }`; on preflight failure returns `success: false` with +`{ "error_code": "" }`. + +## Shell polling lifecycle + +`cosh-shell` owns an `EcsFlow` state machine (`auth/ecs_poll.rs`) whose stages +are `Preparing`, `Checking`, `Waiting`, `Submitting`, `Cancelling`, `TimedOut`, +`Failed`, `Unknown`, `Editing`. Its `Operation` is one of two kinds with +different lifecycles: + +- `Operation::Probe` (`prepare` / `verify`) is an exclusive, cancellable + `cosh-core --registry` probe owned by `EcsProbeTask` (`adapter/ecs_probe.rs`); + the shared live core is never borrowed to carry a cancellable probe. +- `Operation::Configure` (the save) is **not** a cancellable probe. It runs a + `cosh-auth-ecs-save` worker calling `core_auth_configure` → + `registry_query_classified`, which prefers the live core and only spawns a + short-lived `cosh-core --registry` process when no live runtime exists. + `cancel()` returns early while an `Operation::Configure` is in flight or the + stage is `Submitting`: a save in progress is not interrupted. + +Normal flow: after confirming an ECS challenge the shell checks credentials +first; only when they are not ready does it show a cancellable waiting panel and +poll. A single `ready` result auto-submits once (`configure`); it does not ask +for a name or an "I have authorized" confirmation. `not_ready` schedules the +next check about one interval after the current operation is fully reaped. + +### Time budgets + +| Budget | Value | Scope | +|--------|-------|-------| +| Poll interval | 2 s (`INTERVAL`) | Gap between checks after a `not_ready`, measured after reap | +| Operation budget | 5 s (`OPERATION_LIMIT`) | One prepare/verify probe: spawn, write, read, reap | +| Authorization wait | 200 s (`WAIT_LIMIT`) | Total budget from the first role check; expiry ⇒ `TimedOut` | +| Configure observation | 12 s | One save operation; on expiry the result is unknown | +| Cancellation reap | 5 s | From a cancel request; not a reset of an expired operation budget | + +The main event pump independently decides operation failure on budget expiry and +starts cancellation reap; it is never blocked on a worker `join`. A `metadata` +request (token + GET) has its own 3 s total deadline inside the probe. + +### Cancellation completion (zero residue) + +Cancellation has two phases that must not be conflated: *request cancel* and +*cancel complete*. On ESC/Ctrl+C the flow stops further scheduling, revokes +auto-submit eligibility, and signals cancellation; a same-batch cancel takes +priority over a `ready`. "Cancel complete" requires all of: the round's worker +and reader threads joined; the round's exclusive child reaped and its pipes +closed; no queued operation or future probe; and no result of the round can +still trigger a submit. Only then does the shell show `Cancelled` and restore +the prompt. Detach, dropping the `JoinHandle`/`Receiver`, or spawning another +background reclaimer are not acceptable cancellation implementations. If reap +overruns or fails, the shell shows a resource-reclaim failure and keeps +ownership rather than reporting success. + +### Unknown save outcome + +If the save worker does not report a confirmed result within the 12 s +observation budget, the flow enters `Unknown`: it does not auto-resend the save, +and only after the operation's resources are reclaimed does it offer "Return to +provider management", which re-reads configuration. `ActiveRun` never claims a +save landed on disk that it could not confirm. + +## Probe signal-safety invariant + +The isolated probe child runs in its own process group (`process_group(0)`) and +is reclaimed by `ProbeChild` with `try_wait()` followed, only while the child is +still unreaped, by `kill(-pid, SIGKILL)` on the group. + +Signalling a recorded numeric PID/PGID during cleanup is sound only while this +process is the **sole reaper** of the probe child: once the child is reaped the +number is freed and can name an unrelated group. That invariant rests on two +production constraints: + +1. cosh-shell installs no `SIGCHLD` reaping handler and performs no wildcard + `waitpid(-1)` — every wait targets a specific `Child`. +2. A caught `SIGCHLD` handler cannot be inherited across `execve`, so the + inherited disposition is only ever `SIG_IGN` or `SIG_DFL`. + +The only competing reaper is therefore the kernel under an inherited +`SIGCHLD=SIG_IGN`, which auto-reaps with no zombie. `EcsProbeTask` normalizes +that one case back to `SIG_DFL` before spawning any child, so terminated probe +children stay waitable until `ProbeChild` reaps them. A non-ignore disposition +is left untouched so an in-process handler (for example a test's `wait-timeout`) +is not clobbered. Introducing any reaping `SIGCHLD` handler or wildcard wait in +production would break the sole-reaper invariant and require a pidfd-based path +instead (`pidfd_send_signal` process-group signalling requires Linux 6.9+ and a +pidfd does not by itself prevent PID-number reuse). + +The normalization itself reads the disposition and then writes `SIG_DFL`, so the +two calls are not atomic. That window cannot affect a probe child: the +normalization runs synchronously on the caller thread of `start_ecs_probe` and +returns before the worker thread is spawned, so no probe child can exist while it +is in progress. Whether a child is auto-reaped depends on the parent's +disposition when that child exits, not on a value captured at spawn time, so a +concurrently spawned unrelated child is only ever reclaimed under the host's own +pre-existing semantics. Locking the normalization would not strengthen this +argument, because unrelated `Command::spawn` callers do not take that lock.