diff --git a/src/agentic_runtime.rs b/src/agentic_runtime.rs new file mode 100644 index 00000000..5bcdd298 --- /dev/null +++ b/src/agentic_runtime.rs @@ -0,0 +1,483 @@ +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +#[cfg(unix)] +use std::ffi::CString; +use std::fs::{self, File}; +use std::io::{ErrorKind, Read}; +#[cfg(unix)] +use std::os::unix::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +const HASH_BUFFER_BYTES: usize = 64 * 1024; +pub(crate) const MAX_EXECUTABLE_BYTES: u64 = 512 * 1024 * 1024; +const MAX_VERSION_BYTES: usize = 256; + +pub(crate) type RuntimeDiscoveryResult = std::result::Result; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum RuntimeKind { + Codex, + Claude, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum RuntimeCapability { + StructuredControl, + NativeContinuation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum CapabilitySupport { + Supported, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum DeclarationSource { + Vendor, + Catalog, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EvidenceSource { + WindsLocallyObserved, + VendorDeclared, + CatalogDeclared, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DeclaredCapability { + pub capability: RuntimeCapability, + pub support: CapabilitySupport, + pub source: DeclarationSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LocalCapabilityObservation { + pub capability: RuntimeCapability, + pub support: CapabilitySupport, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeCapabilityEvidence { + pub capability: RuntimeCapability, + pub support: Option, + pub source: EvidenceSource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SafeVersionObservation { + Observed(String), + Unsupported(String), + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuntimeVersionState { + Observed, + Unsupported, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeVersionEvidence { + pub state: RuntimeVersionState, + pub value: Option, + pub source: EvidenceSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AuthReadiness { + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AuthReadinessEvidence { + pub readiness: AuthReadiness, + pub source: EvidenceSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AgentExecutionObservation { + NotPerformed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuntimeDiscoveryState { + Unavailable, + Present, + UnsupportedVersion, + VersionUnavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeExecutableIdentity { + pub observed_path: PathBuf, + pub canonical_path: PathBuf, + pub byte_len: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuntimeDiscovery { + pub runtime: RuntimeKind, + pub state: RuntimeDiscoveryState, + pub executable: Option, + pub version: RuntimeVersionEvidence, + pub capabilities: Vec, + pub auth_readiness: AuthReadinessEvidence, + pub agent_execution: AgentExecutionObservation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuntimeIdentityRevalidation { + Match, + Changed, + Unavailable, +} + +pub(crate) fn discover_runtime_from_safe_observations( + runtime: RuntimeKind, + executable_path: &Path, + version: SafeVersionObservation, + declarations: &[DeclaredCapability], + local_observations: &[LocalCapabilityObservation], +) -> RuntimeDiscoveryResult { + let executable = inspect_runtime_executable(executable_path)?; + let capabilities = build_capability_evidence(declarations, local_observations)?; + + let Some(executable) = executable else { + if !matches!(&version, SafeVersionObservation::Unavailable) { + return Err("version evidence cannot be attached to an unavailable runtime".to_owned()); + } + if !local_observations.is_empty() { + return Err( + "local capability evidence cannot be attached to an unavailable runtime".to_owned(), + ); + } + return Ok(RuntimeDiscovery { + runtime, + state: RuntimeDiscoveryState::Unavailable, + executable: None, + version: RuntimeVersionEvidence { + state: RuntimeVersionState::Unavailable, + value: None, + source: EvidenceSource::Unavailable, + }, + capabilities, + auth_readiness: unknown_auth_readiness(), + agent_execution: AgentExecutionObservation::NotPerformed, + }); + }; + + let version = build_version_evidence(version)?; + let state = match version.state { + RuntimeVersionState::Observed => RuntimeDiscoveryState::Present, + RuntimeVersionState::Unsupported => RuntimeDiscoveryState::UnsupportedVersion, + RuntimeVersionState::Unavailable => RuntimeDiscoveryState::VersionUnavailable, + }; + + Ok(RuntimeDiscovery { + runtime, + state, + executable: Some(executable), + version, + capabilities, + auth_readiness: unknown_auth_readiness(), + agent_execution: AgentExecutionObservation::NotPerformed, + }) +} + +pub(crate) fn revalidate_runtime_identity( + expected: &RuntimeExecutableIdentity, +) -> RuntimeDiscoveryResult { + match inspect_runtime_executable(&expected.observed_path)? { + None => Ok(RuntimeIdentityRevalidation::Unavailable), + Some(current) if current == *expected => Ok(RuntimeIdentityRevalidation::Match), + Some(_) => Ok(RuntimeIdentityRevalidation::Changed), + } +} + +fn build_version_evidence( + observation: SafeVersionObservation, +) -> RuntimeDiscoveryResult { + match observation { + SafeVersionObservation::Observed(value) => Ok(RuntimeVersionEvidence { + state: RuntimeVersionState::Observed, + value: Some(validate_version_text(value)?), + source: EvidenceSource::WindsLocallyObserved, + }), + SafeVersionObservation::Unsupported(value) => Ok(RuntimeVersionEvidence { + state: RuntimeVersionState::Unsupported, + value: Some(validate_version_text(value)?), + source: EvidenceSource::WindsLocallyObserved, + }), + SafeVersionObservation::Unavailable => Ok(RuntimeVersionEvidence { + state: RuntimeVersionState::Unavailable, + value: None, + source: EvidenceSource::Unavailable, + }), + } +} + +fn validate_version_text(value: String) -> RuntimeDiscoveryResult { + if value.is_empty() { + return Err("runtime version observation must not be empty".to_owned()); + } + if value.len() > MAX_VERSION_BYTES { + return Err(format!( + "runtime version observation exceeds {MAX_VERSION_BYTES} bytes" + )); + } + if value.trim() != value || value.chars().any(char::is_control) { + return Err("runtime version observation must be one trimmed printable line".to_owned()); + } + Ok(value) +} + +fn build_capability_evidence( + declarations: &[DeclaredCapability], + local_observations: &[LocalCapabilityObservation], +) -> RuntimeDiscoveryResult> { + let mut declared_keys = BTreeSet::new(); + for declaration in declarations { + if !declared_keys.insert((declaration.capability, declaration.source)) { + return Err("duplicate declared runtime capability evidence".to_owned()); + } + } + + let mut local_keys = BTreeSet::new(); + for observation in local_observations { + if !local_keys.insert(observation.capability) { + return Err("duplicate local runtime capability evidence".to_owned()); + } + } + + let mut evidence = Vec::new(); + for capability in [ + RuntimeCapability::StructuredControl, + RuntimeCapability::NativeContinuation, + ] { + let mut found = false; + for source in [DeclarationSource::Vendor, DeclarationSource::Catalog] { + if let Some(declaration) = declarations + .iter() + .find(|item| item.capability == capability && item.source == source) + { + evidence.push(RuntimeCapabilityEvidence { + capability, + support: Some(declaration.support), + source: match source { + DeclarationSource::Vendor => EvidenceSource::VendorDeclared, + DeclarationSource::Catalog => EvidenceSource::CatalogDeclared, + }, + }); + found = true; + } + } + if let Some(observation) = local_observations + .iter() + .find(|item| item.capability == capability) + { + evidence.push(RuntimeCapabilityEvidence { + capability, + support: Some(observation.support), + source: EvidenceSource::WindsLocallyObserved, + }); + found = true; + } + if !found { + evidence.push(RuntimeCapabilityEvidence { + capability, + support: None, + source: EvidenceSource::Unavailable, + }); + } + } + Ok(evidence) +} + +fn unknown_auth_readiness() -> AuthReadinessEvidence { + AuthReadinessEvidence { + readiness: AuthReadiness::Unknown, + source: EvidenceSource::Unavailable, + } +} + +fn inspect_runtime_executable( + observed_path: &Path, +) -> RuntimeDiscoveryResult> { + if !observed_path.is_absolute() { + return Err("runtime executable path must be absolute".to_owned()); + } + + let canonical_path = match fs::canonicalize(observed_path) { + Ok(path) => path, + Err(error) if expected_unavailable_path_error(&error) => return Ok(None), + Err(error) => { + return Err(format!( + "runtime executable cannot be canonicalized ({}): {error}", + observed_path.display() + )); + } + }; + + let Some(first) = snapshot_executable(&canonical_path)? else { + return Ok(None); + }; + let canonical_after = match fs::canonicalize(observed_path) { + Ok(path) => path, + Err(error) if expected_unavailable_path_error(&error) => { + return Err("runtime executable changed during discovery".to_owned()); + } + Err(error) => { + return Err(format!( + "runtime executable cannot be re-canonicalized ({}): {error}", + observed_path.display() + )); + } + }; + if canonical_after != canonical_path { + return Err("runtime executable target changed during discovery".to_owned()); + } + let Some(second) = snapshot_executable(&canonical_after)? else { + return Err("runtime executable became unusable during discovery".to_owned()); + }; + if first != second { + return Err("runtime executable bytes changed during discovery".to_owned()); + } + + Ok(Some(RuntimeExecutableIdentity { + observed_path: observed_path.to_path_buf(), + canonical_path, + byte_len: first.byte_len, + sha256: first.sha256, + })) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExecutableSnapshot { + byte_len: u64, + sha256: String, +} + +fn snapshot_executable(path: &Path) -> RuntimeDiscoveryResult> { + let initial_metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if expected_unavailable_path_error(&error) => return Ok(None), + Err(error) => { + return Err(format!( + "runtime executable metadata cannot be read ({}): {error}", + path.display() + )); + } + }; + if !initial_metadata.is_file() || !has_platform_launch_permission(path, &initial_metadata) { + return Ok(None); + } + if initial_metadata.len() > MAX_EXECUTABLE_BYTES { + return Err(format!( + "runtime executable exceeds bounded discovery size of {MAX_EXECUTABLE_BYTES} bytes" + )); + } + + let mut file = match File::open(path) { + Ok(file) => file, + Err(error) if expected_unavailable_path_error(&error) => return Ok(None), + Err(error) => { + return Err(format!( + "runtime executable cannot be opened ({}): {error}", + path.display() + )); + } + }; + let metadata = file.metadata().map_err(|error| { + format!( + "runtime executable metadata cannot be read after open ({}): {error}", + path.display() + ) + })?; + if !metadata.is_file() || !has_platform_launch_permission(path, &metadata) { + return Ok(None); + } + if metadata.len() > MAX_EXECUTABLE_BYTES { + return Err(format!( + "runtime executable exceeds bounded discovery size of {MAX_EXECUTABLE_BYTES} bytes" + )); + } + + let mut digest = Sha256::new(); + let mut buffer = [0_u8; HASH_BUFFER_BYTES]; + let mut total_read = 0_u64; + loop { + let read = file.read(&mut buffer).map_err(|error| { + format!( + "runtime executable bytes cannot be read ({}): {error}", + path.display() + ) + })?; + if read == 0 { + break; + } + total_read = total_read + .checked_add(read as u64) + .ok_or_else(|| "runtime executable byte count overflowed".to_owned())?; + if total_read > MAX_EXECUTABLE_BYTES { + return Err(format!( + "runtime executable exceeded bounded discovery size of {MAX_EXECUTABLE_BYTES} bytes while reading" + )); + } + digest.update(&buffer[..read]); + } + if total_read != metadata.len() { + return Err("runtime executable size changed during snapshot".to_owned()); + } + + let sha256: String = digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + + Ok(Some(ExecutableSnapshot { + byte_len: metadata.len(), + sha256, + })) +} + +fn expected_unavailable_path_error(error: &std::io::Error) -> bool { + matches!( + error.kind(), + ErrorKind::NotFound | ErrorKind::PermissionDenied + ) +} + +#[cfg(unix)] +fn has_platform_launch_permission(path: &Path, _metadata: &fs::Metadata) -> bool { + let Ok(path) = CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + + // SAFETY: `path` is a NUL-terminated CString whose pointer remains valid for the duration + // of this call; `faccessat` does not retain the pointer. `AT_EACCESS` makes the check use + // the process effective credentials and platform ACL/access rules without launching code. + unsafe { libc::faccessat(libc::AT_FDCWD, path.as_ptr(), libc::X_OK, libc::AT_EACCESS) == 0 } +} + +#[cfg(windows)] +fn has_platform_launch_permission(path: &Path, _metadata: &fs::Metadata) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| { + matches!( + extension.to_ascii_lowercase().as_str(), + "exe" | "com" | "cmd" | "bat" + ) + }) +} + +#[cfg(not(any(unix, windows)))] +fn has_platform_launch_permission(_path: &Path, _metadata: &fs::Metadata) -> bool { + false +} diff --git a/src/main.rs b/src/main.rs index 3e4ccc60..dc0c74b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,8 @@ +#[allow( + dead_code, + reason = "Spec 006 T072 fixture-only runtime discovery; real Agent work remains blocked" +)] +mod agentic_runtime; mod check; mod cli_workspace; #[allow( @@ -15,6 +20,8 @@ mod git; mod store; #[cfg(test)] mod t068_store_regression_tests; +#[cfg(test)] +mod t072_agentic_runtime_discovery_tests; use crate::check::run_check; use crate::domain::{CheckEvidence, CheckStatus, Eligibility, EvidenceReport, PromotionReport}; diff --git a/src/t072_agentic_runtime_discovery_tests.rs b/src/t072_agentic_runtime_discovery_tests.rs new file mode 100644 index 00000000..bb50f0f9 --- /dev/null +++ b/src/t072_agentic_runtime_discovery_tests.rs @@ -0,0 +1,450 @@ +#[cfg(unix)] +use crate::agentic_runtime::MAX_EXECUTABLE_BYTES; +use crate::agentic_runtime::{ + AgentExecutionObservation, AuthReadiness, CapabilitySupport, DeclarationSource, + DeclaredCapability, EvidenceSource, LocalCapabilityObservation, RuntimeCapability, + RuntimeDiscoveryState, RuntimeIdentityRevalidation, RuntimeKind, RuntimeVersionState, + SafeVersionObservation, discover_runtime_from_safe_observations, revalidate_runtime_identity, +}; +use std::ffi::OsStr; +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_ROOT: AtomicU64 = AtomicU64::new(0); + +fn test_root(name: &str) -> PathBuf { + let sequence = NEXT_ROOT.fetch_add(1, Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "winds-t072-{name}-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&root).unwrap(); + root +} + +fn cleanup_owned_root(root: &Path) { + let canonical_root = root.canonicalize().unwrap(); + let canonical_temp = std::env::temp_dir().canonicalize().unwrap(); + let owned_name = canonical_root + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.starts_with("winds-t072-")); + assert!(canonical_root.starts_with(&canonical_temp)); + assert!(owned_name); + fs::remove_dir_all(&canonical_root).unwrap(); +} + +fn fake_executable_path(root: &Path, stem: &str) -> PathBuf { + #[cfg(windows)] + { + root.join(format!("{stem}.exe")) + } + #[cfg(not(windows))] + { + root.join(stem) + } +} + +fn create_fake_executable(root: &Path, stem: &str, bytes: &[u8]) -> PathBuf { + let path = fake_executable_path(root, stem); + fs::write(&path, bytes).unwrap(); + #[cfg(unix)] + { + let mut permissions = fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&path, permissions).unwrap(); + } + path +} + +#[test] +fn absent_runtime_is_unavailable_without_agent_execution() { + let root = test_root("absent"); + let missing = root.join("missing-codex"); + + let discovery = discover_runtime_from_safe_observations( + RuntimeKind::Codex, + &missing, + SafeVersionObservation::Unavailable, + &[], + &[], + ) + .unwrap(); + + assert_eq!(discovery.runtime, RuntimeKind::Codex); + assert_eq!(discovery.state, RuntimeDiscoveryState::Unavailable); + assert!(discovery.executable.is_none()); + assert_eq!(discovery.version.state, RuntimeVersionState::Unavailable); + assert_eq!(discovery.version.source, EvidenceSource::Unavailable); + assert_eq!(discovery.auth_readiness.readiness, AuthReadiness::Unknown); + assert_eq!(discovery.auth_readiness.source, EvidenceSource::Unavailable); + assert_eq!( + discovery.agent_execution, + AgentExecutionObservation::NotPerformed + ); + assert!(discovery.capabilities.iter().all(|evidence| { + evidence.support.is_none() && evidence.source == EvidenceSource::Unavailable + })); + + cleanup_owned_root(&root); +} + +#[test] +fn non_file_runtime_path_is_unavailable_instead_of_aborting_discovery() { + let root = test_root("non-file"); + let runtime_dir = root.join("runtime-dir"); + fs::create_dir(&runtime_dir).unwrap(); + + let discovery = discover_runtime_from_safe_observations( + RuntimeKind::Codex, + &runtime_dir, + SafeVersionObservation::Unavailable, + &[], + &[], + ) + .unwrap(); + + assert_eq!(discovery.state, RuntimeDiscoveryState::Unavailable); + assert!(discovery.executable.is_none()); + assert_eq!( + discovery.agent_execution, + AgentExecutionObservation::NotPerformed + ); + + cleanup_owned_root(&root); +} + +#[cfg(unix)] +#[test] +fn unix_execute_bits_do_not_override_effective_user_access() { + // SAFETY: `geteuid` has no preconditions and only reads the process effective UID. + if unsafe { libc::geteuid() } == 0 { + return; + } + + let root = test_root("unix-effective-exec"); + let executable = fake_executable_path(&root, "fixture-codex"); + fs::write(&executable, b"readable-but-not-owner-executable\n").unwrap(); + let mut permissions = fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o401); + fs::set_permissions(&executable, permissions).unwrap(); + + let discovery = discover_runtime_from_safe_observations( + RuntimeKind::Codex, + &executable, + SafeVersionObservation::Unavailable, + &[], + &[], + ) + .unwrap(); + + assert_eq!(discovery.state, RuntimeDiscoveryState::Unavailable); + assert!(discovery.executable.is_none()); + assert_eq!( + discovery.agent_execution, + AgentExecutionObservation::NotPerformed + ); + + cleanup_owned_root(&root); +} + +#[cfg(unix)] +#[test] +fn oversized_runtime_fails_closed_before_unbounded_hashing() { + let root = test_root("oversized"); + let executable = fake_executable_path(&root, "fixture-codex"); + let file = fs::File::create(&executable).unwrap(); + file.set_len(MAX_EXECUTABLE_BYTES + 1).unwrap(); + drop(file); + let mut permissions = fs::metadata(&executable).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&executable, permissions).unwrap(); + + let error = discover_runtime_from_safe_observations( + RuntimeKind::Codex, + &executable, + SafeVersionObservation::Unavailable, + &[], + &[], + ) + .unwrap_err(); + + assert!(error.contains("bounded discovery size")); + cleanup_owned_root(&root); +} + +#[cfg(windows)] +#[test] +fn windows_regular_file_without_launch_extension_is_unavailable() { + let root = test_root("windows-extension"); + let non_executable = root.join("fixture-codex.txt"); + fs::write(&non_executable, b"not-a-windows-launch-file\n").unwrap(); + + let discovery = discover_runtime_from_safe_observations( + RuntimeKind::Codex, + &non_executable, + SafeVersionObservation::Unavailable, + &[], + &[], + ) + .unwrap(); + + assert_eq!(discovery.state, RuntimeDiscoveryState::Unavailable); + assert!(discovery.executable.is_none()); + cleanup_owned_root(&root); +} + +#[test] +fn present_runtime_keeps_declared_and_locally_observed_capabilities_distinct() { + let root = test_root("present"); + let executable = create_fake_executable(&root, "fixture-codex", b"fixture-codex-v1\n"); + + let declarations = [ + DeclaredCapability { + capability: RuntimeCapability::StructuredControl, + support: CapabilitySupport::Supported, + source: DeclarationSource::Vendor, + }, + DeclaredCapability { + capability: RuntimeCapability::NativeContinuation, + support: CapabilitySupport::Supported, + source: DeclarationSource::Catalog, + }, + ]; + let observations = [LocalCapabilityObservation { + capability: RuntimeCapability::StructuredControl, + support: CapabilitySupport::Supported, + }]; + + let discovery = discover_runtime_from_safe_observations( + RuntimeKind::Codex, + &executable, + SafeVersionObservation::Observed("codex-cli 1.2.3-fixture".to_owned()), + &declarations, + &observations, + ) + .unwrap(); + + assert_eq!(discovery.state, RuntimeDiscoveryState::Present); + let identity = discovery.executable.as_ref().unwrap(); + assert_eq!(identity.observed_path, executable); + assert_eq!(identity.canonical_path, executable.canonicalize().unwrap()); + assert_eq!(identity.byte_len, b"fixture-codex-v1\n".len() as u64); + assert_eq!(identity.sha256.len(), 64); + assert_eq!(discovery.version.state, RuntimeVersionState::Observed); + assert_eq!( + discovery.version.value.as_deref(), + Some("codex-cli 1.2.3-fixture") + ); + assert_eq!( + discovery.version.source, + EvidenceSource::WindsLocallyObserved + ); + + let structured: Vec<_> = discovery + .capabilities + .iter() + .filter(|evidence| evidence.capability == RuntimeCapability::StructuredControl) + .collect(); + assert_eq!(structured.len(), 2); + assert_eq!(structured[0].source, EvidenceSource::VendorDeclared); + assert_eq!(structured[1].source, EvidenceSource::WindsLocallyObserved); + + let continuation: Vec<_> = discovery + .capabilities + .iter() + .filter(|evidence| evidence.capability == RuntimeCapability::NativeContinuation) + .collect(); + assert_eq!(continuation.len(), 1); + assert_eq!(continuation[0].source, EvidenceSource::CatalogDeclared); + + assert_eq!(discovery.auth_readiness.readiness, AuthReadiness::Unknown); + assert_eq!( + discovery.agent_execution, + AgentExecutionObservation::NotPerformed + ); + + cleanup_owned_root(&root); +} + +#[test] +fn present_runtime_with_unavailable_version_is_explicitly_version_unavailable() { + let root = test_root("version-unavailable"); + let executable = create_fake_executable(&root, "fixture-codex", b"fixture-codex-v1\n"); + + let discovery = discover_runtime_from_safe_observations( + RuntimeKind::Codex, + &executable, + SafeVersionObservation::Unavailable, + &[], + &[], + ) + .unwrap(); + + assert_eq!(discovery.state, RuntimeDiscoveryState::VersionUnavailable); + assert!(discovery.executable.is_some()); + assert_eq!(discovery.version.state, RuntimeVersionState::Unavailable); + assert_eq!(discovery.version.source, EvidenceSource::Unavailable); + assert_eq!( + discovery.agent_execution, + AgentExecutionObservation::NotPerformed + ); + + cleanup_owned_root(&root); +} + +#[test] +fn unsupported_version_is_explicit_and_does_not_invent_auth_readiness() { + let root = test_root("unsupported-version"); + let executable = create_fake_executable(&root, "fixture-claude", b"fixture-claude-old\n"); + + let discovery = discover_runtime_from_safe_observations( + RuntimeKind::Claude, + &executable, + SafeVersionObservation::Unsupported("claude-code 0.0-fixture".to_owned()), + &[], + &[], + ) + .unwrap(); + + assert_eq!(discovery.runtime, RuntimeKind::Claude); + assert_eq!(discovery.state, RuntimeDiscoveryState::UnsupportedVersion); + assert_eq!(discovery.version.state, RuntimeVersionState::Unsupported); + assert_eq!( + discovery.version.source, + EvidenceSource::WindsLocallyObserved + ); + assert_eq!(discovery.auth_readiness.readiness, AuthReadiness::Unknown); + assert_eq!(discovery.auth_readiness.source, EvidenceSource::Unavailable); + assert_eq!( + discovery.agent_execution, + AgentExecutionObservation::NotPerformed + ); + + cleanup_owned_root(&root); +} + +#[test] +fn unobservable_capability_stays_unavailable_instead_of_becoming_observed() { + let root = test_root("unobservable-capability"); + let executable = create_fake_executable(&root, "fixture-claude", b"fixture-claude-v1\n"); + + let declarations = [DeclaredCapability { + capability: RuntimeCapability::StructuredControl, + support: CapabilitySupport::Supported, + source: DeclarationSource::Vendor, + }]; + let discovery = discover_runtime_from_safe_observations( + RuntimeKind::Claude, + &executable, + SafeVersionObservation::Observed("claude-code 1.0-fixture".to_owned()), + &declarations, + &[], + ) + .unwrap(); + + let structured: Vec<_> = discovery + .capabilities + .iter() + .filter(|evidence| evidence.capability == RuntimeCapability::StructuredControl) + .collect(); + assert_eq!(structured.len(), 1); + assert_eq!(structured[0].source, EvidenceSource::VendorDeclared); + + let continuation: Vec<_> = discovery + .capabilities + .iter() + .filter(|evidence| evidence.capability == RuntimeCapability::NativeContinuation) + .collect(); + assert_eq!(continuation.len(), 1); + assert_eq!(continuation[0].support, None); + assert_eq!(continuation[0].source, EvidenceSource::Unavailable); + + cleanup_owned_root(&root); +} + +#[test] +fn revalidation_detects_same_length_replacement_before_use() { + let root = test_root("replacement"); + let executable = create_fake_executable(&root, "fixture-codex", b"fixture-codex-v1\n"); + + let discovery = discover_runtime_from_safe_observations( + RuntimeKind::Codex, + &executable, + SafeVersionObservation::Observed("codex-cli 1.2.3-fixture".to_owned()), + &[], + &[], + ) + .unwrap(); + let identity = discovery.executable.as_ref().unwrap(); + + assert_eq!( + revalidate_runtime_identity(identity).unwrap(), + RuntimeIdentityRevalidation::Match + ); + + let replacement = b"fixture-codex-x1\n"; + assert_eq!(replacement.len() as u64, identity.byte_len); + fs::write(&executable, replacement).unwrap(); + assert_eq!( + revalidate_runtime_identity(identity).unwrap(), + RuntimeIdentityRevalidation::Changed + ); + + fs::remove_file(&executable).unwrap(); + assert_eq!( + revalidate_runtime_identity(identity).unwrap(), + RuntimeIdentityRevalidation::Unavailable + ); + + cleanup_owned_root(&root); +} + +#[test] +fn unavailable_runtime_rejects_fabricated_local_observation() { + let root = test_root("fabricated-local-observation"); + let missing = root.join("missing-claude"); + let observations = [LocalCapabilityObservation { + capability: RuntimeCapability::StructuredControl, + support: CapabilitySupport::Supported, + }]; + + let error = discover_runtime_from_safe_observations( + RuntimeKind::Claude, + &missing, + SafeVersionObservation::Unavailable, + &[], + &observations, + ) + .unwrap_err(); + + assert!(error.contains("unavailable runtime")); + cleanup_owned_root(&root); +} + +#[test] +fn runtime_identity_is_explicit_and_not_inferred_from_version_text() { + let root = test_root("runtime-model-separation"); + let executable = create_fake_executable(&root, "fixture-codex", b"fixture-codex-model-text\n"); + + let discovery = discover_runtime_from_safe_observations( + RuntimeKind::Codex, + &executable, + SafeVersionObservation::Observed("vendor text mentioning claude and model-x".to_owned()), + &[], + &[], + ) + .unwrap(); + + assert_eq!(discovery.runtime, RuntimeKind::Codex); + assert_eq!(discovery.state, RuntimeDiscoveryState::Present); + assert_eq!( + discovery.agent_execution, + AgentExecutionObservation::NotPerformed + ); + + cleanup_owned_root(&root); +}