diff --git a/crates/ai/src/cli_agents/mod.rs b/crates/ai/src/cli_agents/mod.rs index d5d02036..52e9aeb7 100644 --- a/crates/ai/src/cli_agents/mod.rs +++ b/crates/ai/src/cli_agents/mod.rs @@ -64,6 +64,11 @@ pub fn program_of(config: &BackendConfig, options: &BackendOptions) -> String { let usual = program_for(&config.model); let Some(chosen) = options.command.as_deref().map(str::trim).filter(|c| !c.is_empty()) else { return usual }; let local = config.ssh_host.as_deref().is_none_or(|h| h.trim().is_empty()); + // A folder or app bundle saved before set_command refused it (#467) can't be started: the usual + // program is used, as when none is chosen. + if local && Path::new(chosen).is_dir() { + return usual; + } let path = Path::new(chosen); if !local || path.parent().is_none_or(|p| p.as_os_str().is_empty()) || path.exists() { return chosen.to_string(); @@ -1118,6 +1123,12 @@ mod tests { assert_eq!(program_of(&config(None), &chosen(&new)), new.display().to_string(), "a path that's there is used as it is"); assert_eq!(program_of(&config(Some("box")), &chosen(&gone)), gone.display().to_string(), "another computer's path isn't checked here"); assert_eq!(program_of(&config(None), &BackendOptions::default()), "codex", "nothing chosen: the usual name"); + // #467: a saved app bundle is never started; the usual name is looked up instead. + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("Codex.app"); + std::fs::create_dir_all(&bundle).unwrap(); + assert_eq!(program_of(&config(None), &chosen(&bundle)), "codex", "an app bundle is not the program"); + assert_eq!(program_of(&config(Some("box")), &chosen(&bundle)), bundle.display().to_string(), "another computer's path isn't checked here"); let bare = BackendOptions { command: Some("my-codex".into()), ..Default::default() }; assert_eq!(program_of(&config(None), &bare), "my-codex", "a bare name is left to PATH"); } diff --git a/crates/ai/src/lib.rs b/crates/ai/src/lib.rs index d4a6b231..39a2e90e 100644 --- a/crates/ai/src/lib.rs +++ b/crates/ai/src/lib.rs @@ -522,9 +522,20 @@ impl AiHub { /// The program an agent app runs (`None` or empty: found by its usual name, on PATH and in the /// usual install folders). Returns false when there was no such backend. pub fn set_command(&self, name: &str, command: Option<&str>) -> Result { + let command = command.map(str::trim).filter(|c| !c.is_empty()); let mut cfg = self.config_to_change()?; let Some(b) = cfg.backends.iter_mut().find(|b| b.config.name == name) else { return Ok(false) }; - b.options.command = command.map(str::trim).filter(|c| !c.is_empty()).map(str::to_string); + // A folder can't be started, and on a Mac an app is one (#467: the file picker returned + // `Antigravity.app`). Another computer's path isn't looked at here. + let local = b.config.ssh_host.as_deref().is_none_or(|h| h.trim().is_empty()); + if let Some(c) = command + && local + && std::path::Path::new(c).is_dir() + { + return Err(ToolError::invalid(format!("{c} is a folder, so it can't be the agent's program. On a Mac an app is a folder.")) + .with_remediation("Pick the agent's command-line program instead, such as `agy`, or clear the program to use the usual one.")); + } + b.options.command = command.map(str::to_string); self.save_config(&cfg)?; Ok(true) } diff --git a/crates/ai/src/tests.rs b/crates/ai/src/tests.rs index 466e7e1e..53e9a19c 100644 --- a/crates/ai/src/tests.rs +++ b/crates/ai/src/tests.rs @@ -304,6 +304,27 @@ fn an_agent_apps_program_is_set_and_cleared() { assert_eq!(program(&h), None, "blank finds it by its usual name again"); } +/// #467: a folder or app bundle can't be an agent's program (the file picker returns +/// `Antigravity.app` on a Mac); it's refused and the saved program stays as it was. +#[test] +fn an_app_bundle_is_refused_as_an_agents_program() { + let (h, d) = hub(); + add(&h, "OpenCode", BackendKind::AgentCli, "http://127.0.0.1:9", None); + let program = |h: &AiHub| h.options().get("OpenCode").and_then(|o| o.command.clone()); + let bundle = d.path().join("Antigravity.app"); + std::fs::create_dir_all(bundle.join("Contents")).unwrap(); + let err = h.set_command("OpenCode", Some(&bundle.display().to_string())).unwrap_err(); + assert_eq!(err.code, ErrorCode::BadInput, "{err:?}"); + assert!(err.message.contains("is a folder"), "{}", err.message); + assert_eq!(program(&h), None, "nothing is saved for a refused folder"); + // A file, and a bare name, are still accepted. + let file = d.path().join("agy"); + std::fs::write(&file, "#!/bin/sh\n").unwrap(); + assert!(h.set_command("OpenCode", Some(&file.display().to_string())).unwrap()); + assert!(h.set_command("OpenCode", Some("agy")).unwrap()); + assert_eq!(program(&h).as_deref(), Some("agy")); +} + /// Claude Code's folder (`CLAUDE_CONFIG_DIR`) is chosen per AI, so two can be two accounts; only /// a full path, and only for an agent app that keeps one. #[test] diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9db43ebe..fd0d311c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -543,10 +543,11 @@ Owner: open ยท Status: in progress and add search paths only for a new report that has that output - [ ] Say plainly when what's found is the Claude desktop app rather than the `claude` program (#259, a guess at its cause) -- [ ] macOS: `/Applications/Antigravity.app` ended up as the agent's command (#467). Found: the "Where is +- [x] macOS: `/Applications/Antigravity.app` ended up as the agent's command (#467). Found: the "Where is the program?" picker (`pick_file`) returns the bundle, and `set_command` saved any path. Refuse a - folder or app bundle when it's saved, and don't run one that was saved before โ€” claimed: fabox-os, - branch bugfix/467-agent-program-app-bundle + folder or app bundle when it's saved (`set_command`), and don't run one that was saved before + (`program_of` uses the usual name). Tests: `an_app_bundle_is_refused_as_an_agents_program`, `program_of` + cases. โ€” claimed: fabox-os, branch bugfix/467-agent-program-app-bundle - [x] Windows: look in `%LOCALAPPDATA%\agy\bin` too (#62) โ€” claimed: Afloat16, branch bugfix/r2.2-antigravity-search-20261103 - [x] Windows: `usual_windows_dirs_come_after_path` counts Antigravity's second folder (#748 added it; the test runs only on Windows, which CI doesn't)