diff --git a/.facts b/.facts index b1090388..bb14a554 100644 --- a/.facts +++ b/.facts @@ -421,6 +421,7 @@ - satelle run supports --quiet to suppress the preflight summary for automation @implemented @mvp - satelle run supports a verbose option that expands preflight output into detailed host, readiness, transport, and provider diagnostics @implemented @mvp - satelle run and steer automatically run the native Computer Use readiness smoke test during preflight when no valid matching native readiness cache entry exists, including first use after setup @spec @mvp @implemented +- satelle Host capabilities reports native readiness unavailable without resolving native runtime authentication when authoritative storage contains no unexpired successful readiness candidate; a candidate still requires the full authenticated current cache key before reuse @spec @mvp @implemented - satelle run and steer do not require the user to run satelle doctor --scope computer-use --refresh before first prompt execution @spec @mvp @implemented - satelle run and steer do not start the prompt turn until native Computer Use readiness passes through a valid cache entry or a live harmless readiness smoke test @spec @mvp @implemented - satelle native Computer Use readiness checks distinguish pointer click support from click-and-drag support instead of treating a successful click as full pointer readiness @spec @mvp @implemented diff --git a/crates/satelle/src/host/runtime-tests.rs b/crates/satelle/src/host/runtime-tests.rs index 6ab98c4b..d72ce6cd 100644 --- a/crates/satelle/src/host/runtime-tests.rs +++ b/crates/satelle/src/host/runtime-tests.rs @@ -796,6 +796,79 @@ fn host_default_cache_lookup_requires_one_desktop_without_blocking_startup() { } } +#[test] +fn host_default_cache_lookup_authenticates_only_live_success_candidates() { + for condition in [ + "empty", + "expired", + "future", + "failed", + "mismatch", + "matching", + "invalidated", + ] { + let state = crate::host::TestStateDir::new().unwrap(); + let adapter = ProviderProbeRecoveryAdapter::new([]); + let runtime = RuntimeHandle::new(Ok(state.path().to_path_buf()), adapter.clone()); + let engine = runtime.engine().unwrap(); + if condition != "empty" { + let key = ProviderProbeRecoveryAdapter::key(); + let now = time::OffsetDateTime::now_utc(); + let observed_at = match condition { + "expired" => now - time::Duration::minutes(10), + "future" => now + time::Duration::minutes(10), + _ => now, + }; + let evidence = key + .evidence( + "capabilities-candidate", + observed_at, + observed_at + time::Duration::minutes(5), + ) + .unwrap(); + let mut storage = engine.lock_storage().unwrap(); + storage + .store_preflight_successes( + key.adapter(), + key.desktop_binding(), + key.execution_policy(), + &evidence, + None, + ) + .unwrap(); + let change = match condition { + "failed" => Some( + "UPDATE native_readiness_results SET status = 'failed', failure_reason = 'native_action_failed'", + ), + "mismatch" => Some( + "UPDATE native_readiness_results SET native_runtime_version = 'changed-runtime'", + ), + _ => None, + }; + if let Some(sql) = change { + storage.connection_for_test().execute(sql, []).unwrap(); + } + if condition == "invalidated" { + storage.invalidate_all_native_readiness().unwrap(); + } + } + + assert_eq!( + runtime.has_reusable_readiness(LOCAL_DEMO_HOST).unwrap(), + condition == "matching", + "{condition}" + ); + assert_eq!( + adapter.readiness_key_calls.load(Ordering::SeqCst), + usize::from(matches!(condition, "matching" | "mismatch")), + "{condition}" + ); + assert_eq!(adapter.native_probe_calls.load(Ordering::SeqCst), 0); + assert_eq!(adapter.provider_probe_calls.load(Ordering::SeqCst), 0); + assert_eq!(runtime.snapshot().unwrap().session_count(), 0); + } +} + #[test] fn host_default_cache_lookup_treats_provider_opt_in_as_unavailable() { let state = crate::host::TestStateDir::new().expect("temporary state directory should exist"); @@ -810,6 +883,22 @@ fn host_default_cache_lookup_treats_provider_opt_in_as_unavailable() { Ok(state.path().to_path_buf()), BlockedComputerUseAdapter::new(error), ); + // Exercise the adapter error path, rather than the empty-store shortcut. + let key = ProviderProbeRecoveryAdapter::key(); + let evidence = ProviderProbeRecoveryAdapter::new([]).readiness(); + runtime + .engine() + .unwrap() + .lock_storage() + .unwrap() + .store_preflight_successes( + key.adapter(), + key.desktop_binding(), + key.execution_policy(), + &evidence, + None, + ) + .unwrap(); assert!( !runtime @@ -2724,6 +2813,7 @@ struct TerminalRecoveryAdapter { #[derive(Clone)] struct ProviderProbeRecoveryAdapter { + readiness_key_calls: Arc, observations: Arc>>, observation_calls: Arc, native_results: Arc>>, @@ -2751,6 +2841,7 @@ enum NativeProbeBehavior { impl ProviderProbeRecoveryAdapter { fn new(observations: impl IntoIterator) -> Self { Self { + readiness_key_calls: Arc::new(AtomicUsize::new(0)), observations: Arc::new(Mutex::new(observations.into_iter().collect())), observation_calls: Arc::new(AtomicUsize::new(0)), native_results: Arc::new(Mutex::new(VecDeque::new())), @@ -2923,6 +3014,7 @@ impl ComputerUseAdapter for ProviderProbeRecoveryAdapter { _host: &str, provider_intent: &ProviderComputerUseIntent, ) -> Result, SatelleError> { + self.readiness_key_calls.fetch_add(1, Ordering::SeqCst); if self.require_resolved_explicit_binding && provider_intent.model().is_some() && provider_intent.provider().is_some() diff --git a/crates/satelle/src/host/runtime.rs b/crates/satelle/src/host/runtime.rs index 0812ed66..b843cfff 100644 --- a/crates/satelle/src/host/runtime.rs +++ b/crates/satelle/src/host/runtime.rs @@ -2417,6 +2417,15 @@ impl RuntimeEngine { if self.provider_policy.desktop_bindings.len() != 1 { return Ok(false); } + // First use and setup invalidation leave no evidence to authenticate. + // Do not launch native runtime discovery for this status-only miss. + if !self + .lock_storage()? + .has_native_readiness_candidate(time::OffsetDateTime::now_utc()) + .map_err(model::storage_failure)? + { + return Ok(false); + } let intent = ProviderComputerUseIntent::host_default(); let key = match self.adapter.readiness_cache_key(host, &intent) { Ok(Some(key)) => key, diff --git a/crates/satelle/src/host/storage/operational.rs b/crates/satelle/src/host/storage/operational.rs index 7d7363a2..e7f64f6a 100644 --- a/crates/satelle/src/host/storage/operational.rs +++ b/crates/satelle/src/host/storage/operational.rs @@ -1075,6 +1075,29 @@ impl Storage { require_idempotent_write(changed) } + /// A cheap negative lookup before resolving the authenticated runtime key. + /// A candidate never authorizes work; load_reusable_readiness still checks + /// every current key field before evidence can be reused. + pub(crate) fn has_native_readiness_candidate( + &self, + now: time::OffsetDateTime, + ) -> Result { + let host_identity = self.host_identity()?; + self.connection + .query_row( + "SELECT EXISTS( + SELECT 1 FROM native_readiness_results + WHERE host_identity_ref = ?1 + AND status = 'passed' + AND observed_at <= ?2 + AND expires_at > ?2 + )", + params![host_identity.as_str(), unix_timestamp_nanos(now)?], + |row| row.get(0), + ) + .map_err(operation_failed) + } + /// Returns only a matching, unexpired success. Failed results remain in /// the authoritative store for diagnostics but never authorize execution. pub(crate) fn load_reusable_readiness(