diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 8f1d182e43..552b54b00e 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -257,9 +257,9 @@ pub fn get_baked_build_env() -> Vec { /// patch to one exact saved agent. Projection reuses the production create, /// update, effective-config, harness, and readiness owners. #[tauri::command] -pub async fn evaluate_agent_readiness_draft( +pub async fn evaluate_agent_readiness_draft( draft: AgentReadinessDraft, - app: AppHandle, + app: tauri::AppHandle, _state: State<'_, AppState>, ) -> Result { tokio::task::spawn_blocking(move || { diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index aa9d2ea9f3..c39a116b30 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -12,6 +12,52 @@ use std::sync::Mutex; static GOOSE_PATH_ROOT_LOCK: Mutex<()> = Mutex::new(()); +struct EnvVarGuard { + key: &'static str, + prior: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &std::path::Path) -> Self { + let prior = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, prior } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.prior { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} + +struct ReadinessIpcFixture { + app: tauri::App, + _temp: tempfile::TempDir, + _home: EnvVarGuard, + _xdg: EnvVarGuard, +} + +fn readiness_ipc_fixture() -> ReadinessIpcFixture { + let temp = tempfile::tempdir().expect("temp app data root"); + let home = EnvVarGuard::set("HOME", temp.path()); + let xdg = EnvVarGuard::set("XDG_DATA_HOME", temp.path()); + let app = tauri::test::mock_builder() + .manage(crate::app_state::build_app_state()) + .invoke_handler(tauri::generate_handler![evaluate_agent_readiness_draft]) + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app builds"); + ReadinessIpcFixture { + app, + _temp: temp, + _home: home, + _xdg: xdg, + } +} + /// Run a test body with GOOSE_PATH_ROOT set to a non-existent path so that the /// goose config file read returns `None`. Restores the prior value on exit. fn with_no_goose_config(body: impl FnOnce() -> T) -> T { @@ -162,6 +208,100 @@ fn persona_without_model() -> AgentDefinition { } } +#[test] +fn production_handler_registers_readiness_command() { + let lib = include_str!("../lib.rs"); + let handler_start = lib + .find(".invoke_handler(tauri::generate_handler![") + .expect("production invoke handler exists"); + let handler = &lib[handler_start..]; + assert!( + handler.contains("evaluate_agent_readiness_draft,"), + "removing readiness from the production Tauri handler must fail this test" + ); +} + +#[test] +fn readiness_command_is_registered_and_never_persists_its_draft() { + let _env_guard = crate::managed_agents::lock_env_mutex(); + let fixture = readiness_ipc_fixture(); + let mut saved = agent_record(); + saved.pubkey = "a".repeat(64); + saved.persona_id = None; + saved.agent_command = "buzz-agent".to_string(); + saved.model = None; + saved.provider = None; + crate::managed_agents::save_managed_agents(fixture.app.handle(), &[saved.clone()]) + .expect("seed saved agent"); + let global = GlobalAgentConfig { + provider: Some("openrouter".to_string()), + model: Some("openrouter/model".to_string()), + env_vars: std::collections::BTreeMap::from([( + "OPENROUTER_API_KEY".to_string(), + "sk-global".to_string(), + )]), + preferred_runtime: None, + }; + let global_path = crate::managed_agents::managed_agents_base_dir(fixture.app.handle()) + .expect("managed agents dir") + .join("global-agent-config.json"); + std::fs::write( + global_path, + serde_json::to_vec_pretty(&global).expect("serialize global config"), + ) + .expect("seed global config"); + // Stabilize the store's ordinary built-in-definition reconciliation before + // measuring the readiness command's own persistence behavior. + crate::managed_agents::load_personas(fixture.app.handle()).expect("warm built-in definitions"); + let before = std::fs::read( + crate::managed_agents::storage::managed_agents_store_path(fixture.app.handle()) + .expect("store path"), + ) + .expect("seeded store bytes"); + + let webview = tauri::WebviewWindowBuilder::new( + &fixture.app, + "main", + tauri::WebviewUrl::App("index.html".into()), + ) + .build() + .expect("mock webview"); + let response = tauri::test::get_ipc_response( + &webview, + tauri::webview::InvokeRequest { + cmd: "evaluate_agent_readiness_draft".into(), + callback: tauri::ipc::CallbackFn(0), + error: tauri::ipc::CallbackFn(1), + url: "tauri://localhost".parse().expect("invoke url"), + body: tauri::ipc::InvokeBody::Json(serde_json::json!({ + "draft": { + "kind": "existing", + "config": { + "pubkey": saved.pubkey + } + } + })), + headers: Default::default(), + invoke_key: tauri::test::INVOKE_KEY.to_string(), + }, + ) + .expect("registered readiness command succeeds") + .deserialize::() + .expect("structured readiness response"); + + assert_eq!(response["ready"], true); + assert_eq!(response["requirements"], serde_json::json!([])); + let after = std::fs::read( + crate::managed_agents::storage::managed_agents_store_path(fixture.app.handle()) + .expect("store path"), + ) + .expect("store bytes after preview"); + assert_eq!( + after, before, + "readiness preview must not persist the patch" + ); +} + /// A post-spawn session cache whose live model is `current_model` and whose /// `model_overridden` flag records whether a `SwitchModel` control signal set /// it (the live-switch signal). diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 1a48846944..8f181224a3 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -208,6 +208,29 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn login_probe_fails_closed_when_success_output_exceeds_bound() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let script_path = temp.path().join("noisy-login-probe"); + fs::write( + &script_path, + "#!/bin/sh\nhead -c 1100000 /dev/zero\nexit 0\n", + ) + .expect("write noisy probe"); + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)) + .expect("chmod noisy probe"); + + assert_eq!( + super::login_probe(&script_path, &["noisy-login-probe"], None), + ProbeOutcome::LoggedOut, + "login readiness must stay bound even when a successful CLI floods output" + ); + } + #[cfg(unix)] #[test] fn login_probe_logged_out_on_nonzero_without_config_signal() { diff --git a/desktop/src-tauri/src/managed_agents/types/requests.rs b/desktop/src-tauri/src/managed_agents/types/requests.rs index 824ca4ccf3..d32248536b 100644 --- a/desktop/src-tauri/src/managed_agents/types/requests.rs +++ b/desktop/src-tauri/src/managed_agents/types/requests.rs @@ -211,7 +211,7 @@ pub struct UpdateManagedAgentRequest { #[serde(default)] pub name: Option, /// Absent = don't touch. null = clear to agent default. "id" = set. - #[serde(default)] + #[serde(default, deserialize_with = "crate::util::double_option")] pub model: Option>, #[serde(default)] pub system_prompt: Option>, diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 0918ab2c65..df166e3af5 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -209,6 +209,20 @@ fn validate_respond_to_allowlist_accepts_empty() { assert!(result.is_empty()); } +#[test] +fn update_request_model_tristate_matches_provider_contract() { + let absent: super::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"agent"}"#).expect("absent model"); + let clear: super::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"agent","model":null}"#).expect("null model"); + let set: super::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey":"agent","model":"model-id"}"#).expect("model value"); + + assert_eq!(absent.model, None); + assert_eq!(clear.model, Some(None)); + assert_eq!(set.model, Some(Some("model-id".to_string()))); +} + #[test] fn update_request_provider_tristate_absent_means_no_touch() { // A JSON payload with no "provider" key deserialized with `None` —