From 93ed364cb44243f38b36543bf29b5ec5601409ff Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:55:05 +0000 Subject: [PATCH] Share the recovered login PATH with task tools on macOS Settings detected Codex through the runtime's recovered interactive login-shell PATH, but task tools (list_agent_providers, agent_start, and the shell tool) independently probed Goose's default bash shell. Where the user's login shell is zsh and Codex lives on a zsh-only PATH entry such as ~/.local/bin, Settings showed Codex installed and signed in while tasks reported it missing and fell back to the internal delegate. Thread the recovered host PATH through SessionAgentConfiguration into MapleDeveloperClient so integration discovery, the shell tool, and external-agent launch all search the same PATH. The previous bash probe remains as the fallback when no PATH was recovered, and non-macOS behavior is unchanged. A regression test verifies Settings detection, shell lookup, and the spawned child's PATH agree using a temporary fake Codex install, and that the process-global PATH is untouched. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../crates/maple-agent/src/agent.rs | 17 ++-- .../maple-agent/src/agent/developer_tools.rs | 88 ++++++++++++++++++- apps/maple-agent/docs/external-agents.md | 6 ++ 3 files changed, 105 insertions(+), 6 deletions(-) diff --git a/apps/maple-agent/crates/maple-agent/src/agent.rs b/apps/maple-agent/crates/maple-agent/src/agent.rs index e7bcb87dd..cc57ce5fb 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent.rs @@ -2478,15 +2478,16 @@ impl AgentRuntimeHandle { normalize_mcp_servers(config.mcp_servers) } - /// The PATH to look on for external agent executables. A macOS GUI + /// The PATH shared by integration discovery and task tools. A macOS GUI /// launch inherits a short PATH; the login shell's, once recovered for /// the runtime, is the one the user's terminal has. - fn codex_search_path(&self) -> Option { + fn tool_search_path(&self) -> Option { #[cfg(target_os = "macos")] { self.service .login_shell_search_paths .get() + .filter(|paths| !paths.is_empty()) .and_then(|paths| std::env::join_paths(paths).ok()) .and_then(|joined| joined.into_string().ok()) } @@ -2503,7 +2504,7 @@ impl AgentRuntimeHandle { /// from publishing or mutating device-local state after logout. pub async fn list_integrations(&self) -> Result, String> { self.verify_generation().await?; - let detected = detect_integrations(self.codex_search_path().as_deref()).await; + let detected = detect_integrations(self.tool_search_path().as_deref()).await; let state = &self.service; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; self.verify_generation().await?; @@ -2518,7 +2519,7 @@ impl AgentRuntimeHandle { ) -> Result, String> { require_known_integration(&request.id)?; self.verify_generation().await?; - let detected = detect_integrations(self.codex_search_path().as_deref()).await; + let detected = detect_integrations(self.tool_search_path().as_deref()).await; let state = &self.service; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; self.verify_generation().await?; @@ -2543,7 +2544,7 @@ impl AgentRuntimeHandle { { cua::install_desktop_helper().await?; } - let detected = detect_integrations(self.codex_search_path().as_deref()).await; + let detected = detect_integrations(self.tool_search_path().as_deref()).await; let state = &self.service; let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; self.verify_generation().await?; @@ -3147,6 +3148,7 @@ impl AgentRuntimeHandle { tool_context: &tool_context, allow_embedded_cua: !has_external_tool_context, external_agents: external_agents.as_ref(), + host_search_path: self.tool_search_path(), }, ) .await?; @@ -3527,6 +3529,7 @@ impl AgentRuntimeHandle { tool_context: &tool_context, allow_embedded_cua: false, external_agents: external_agents.as_ref(), + host_search_path: self.tool_search_path(), }, ) .await?; @@ -5245,6 +5248,7 @@ impl AgentRuntimeHandle { tool_context: &tool_context, allow_embedded_cua: permission_routing == AgentPermissionRouting::Desktop, external_agents: external_agents.as_ref(), + host_search_path: self.tool_search_path(), }, ) .await?; @@ -8191,6 +8195,7 @@ struct SessionAgentConfiguration<'a> { /// The runtime's external agents, offered to desktop tasks when the /// integration is enabled. external_agents: Option<&'a Arc>, + host_search_path: Option, } fn maple_model_config( @@ -8542,6 +8547,7 @@ async fn finish_session_agent( tool_context, allow_embedded_cua, external_agents, + host_search_path, } = configuration; let PreparedSessionAgent { agent, @@ -8586,6 +8592,7 @@ async fn finish_session_agent( tool_context.clone(), ) .map_err(|e| format!("Failed to create Maple developer tools: {e}"))? + .with_host_search_path(host_search_path) .with_attachment_store(attachment_store) .with_web_enabled(session_web_enabled(session)) .with_desktop_ui_tools(session.session_type != SessionType::Acp) diff --git a/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs b/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs index dbffdf841..1cd3a81f3 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs @@ -164,6 +164,8 @@ pub(crate) struct MapleDeveloperClient { /// The external agents (Codex) this session may delegate to. `None` /// leaves the `agent_*` tools out of the catalog. external_agents: Option>, + /// The host's recovered search path, shared with integration discovery. + host_search_path: Option, #[cfg(not(windows))] login_path_probe: ShellTool, #[cfg(not(windows))] @@ -198,6 +200,7 @@ impl MapleDeveloperClient { web_enabled: true, desktop_ui_tools: true, external_agents: None, + host_search_path: None, #[cfg(not(windows))] login_path_probe: ShellTool::new(true)?, #[cfg(not(windows))] @@ -210,6 +213,11 @@ impl MapleDeveloperClient { self } + pub(super) fn with_host_search_path(mut self, path: Option) -> Self { + self.host_search_path = path; + self + } + pub(super) fn with_desktop_ui_tools(mut self, enabled: bool) -> Self { self.desktop_ui_tools = enabled; self @@ -230,7 +238,7 @@ impl MapleDeveloperClient { #[cfg(windows)] async fn login_path(&self) -> Option { - None + self.host_search_path.clone() } fn external_agent_tools() -> [Tool; 5] { @@ -350,6 +358,9 @@ Call {LIST_AGENT_PROVIDERS_TOOL} first when unsure what is installed." #[cfg(not(windows))] async fn login_path(&self) -> Option { + if let Some(path) = &self.host_search_path { + return Some(path.clone()); + } self.login_path .get_or_init(|| async { let probe = match shell_display_name().to_ascii_lowercase().as_str() { @@ -4122,6 +4133,81 @@ mod tests { ); } + #[cfg(unix)] + #[tokio::test] + async fn host_search_path_agrees_for_settings_shell_and_external_agents() { + use std::os::unix::fs::PermissionsExt; + + let temp = TestDir::new(); + let bin = temp.path().join("login-bin"); + fs::create_dir_all(&bin).unwrap(); + let executable = bin.join("codex"); + fs::write( + &executable, + "#!/bin/sh\nif [ \"$1\" = --version ]; then printf 'codex-cli 0.154.0\\n'; else printf '%s' \"$PATH\"; fi\n", + ) + .unwrap(); + fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap(); + let host_path = + std::env::join_paths([bin.as_path(), Path::new("/usr/bin"), Path::new("/bin")]) + .unwrap() + .into_string() + .unwrap(); + let inherited_path = std::env::var_os("PATH"); + let client = test_client(temp.path().join("sessions"), true) + .with_host_search_path(Some(host_path.clone())); + // A GUI process's shell-tool probe may know only bash's minimal PATH. + client + .login_path + .set(Some("/usr/bin:/bin".to_string())) + .unwrap(); + let context = ToolCallContext::new( + "host-path-test".to_string(), + Some(temp.path().to_path_buf()), + None, + ); + let settings = super::super::external_agents::codex::detect(Some(&host_path)).await; + assert_eq!(settings.executable.as_ref(), Some(&executable)); + assert!(settings.problem.is_none()); + + let call = client + .external_agent_call(&context, CancellationToken::new()) + .await; + assert_eq!(call.login_path.as_deref(), Some(host_path.as_str())); + let detected = + super::super::external_agents::codex::detect(call.login_path.as_deref()).await; + assert_eq!(detected, settings); + let output = build_external_agent_command( + detected.executable.as_ref().unwrap(), + &["app-server"], + temp.path(), + call.login_path.as_deref(), + Some(&call.session_id), + &call.tool_context, + ) + .unwrap() + .output() + .await + .unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8(output.stdout).unwrap(), host_path); + + let result = client + .call_tool( + &context, + "shell", + Some(object!({"command": "command -v codex", "timeout_secs": 2})), + CancellationToken::new(), + ) + .await + .unwrap(); + let output: ShellOutput = + serde_json::from_value(result.structured_content.unwrap()).unwrap(); + assert_eq!(result.is_error, Some(false)); + assert_eq!(Path::new(output.stdout.trim()), executable); + assert_eq!(std::env::var_os("PATH"), inherited_path); + } + #[cfg(unix)] #[tokio::test] async fn shell_tool_forwards_the_current_agent_session_id() { diff --git a/apps/maple-agent/docs/external-agents.md b/apps/maple-agent/docs/external-agents.md index 45d881c34..090b417bd 100644 --- a/apps/maple-agent/docs/external-agents.md +++ b/apps/maple-agent/docs/external-agents.md @@ -71,6 +71,12 @@ so teardown reaches every descendant. It is killed when the runtime stops, on logout, and when its task is deleted. Threads are not ephemeral, so `codex resume` works from a terminal afterwards. +On macOS, Settings, external-agent tools, and the task's shell tool share the +runtime's recovered interactive login-shell PATH. The shell tool can still +execute commands with bash; it must not substitute bash's startup PATH for +the user's login-shell search path. Maple does not change the process-global +PATH or the user's shell configuration. + The handshake reports the reserved client name `codex_app_server_daemon`, the same non-originating name Paseo uses.