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.