Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions apps/maple-agent/crates/maple-agent/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
fn tool_search_path(&self) -> Option<String> {
#[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())
}
Expand All @@ -2503,7 +2504,7 @@ impl AgentRuntimeHandle {
/// from publishing or mutating device-local state after logout.
pub async fn list_integrations(&self) -> Result<Vec<AgentIntegration>, 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?;
Expand All @@ -2518,7 +2519,7 @@ impl AgentRuntimeHandle {
) -> Result<Vec<AgentIntegration>, 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?;
Expand All @@ -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?;
Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -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<ExternalAgentRegistry>>,
host_search_path: Option<String>,
}

fn maple_model_config(
Expand Down Expand Up @@ -8542,6 +8547,7 @@ async fn finish_session_agent(
tool_context,
allow_embedded_cua,
external_agents,
host_search_path,
} = configuration;
let PreparedSessionAgent {
agent,
Expand Down Expand Up @@ -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)
Expand Down
88 changes: 87 additions & 1 deletion apps/maple-agent/crates/maple-agent/src/agent/developer_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<ExternalAgentRegistry>>,
/// The host's recovered search path, shared with integration discovery.
host_search_path: Option<String>,
#[cfg(not(windows))]
login_path_probe: ShellTool,
#[cfg(not(windows))]
Expand Down Expand Up @@ -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))]
Expand All @@ -210,6 +213,11 @@ impl MapleDeveloperClient {
self
}

pub(super) fn with_host_search_path(mut self, path: Option<String>) -> 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
Expand All @@ -230,7 +238,7 @@ impl MapleDeveloperClient {

#[cfg(windows)]
async fn login_path(&self) -> Option<String> {
None
self.host_search_path.clone()
}

fn external_agent_tools() -> [Tool; 5] {
Expand Down Expand Up @@ -350,6 +358,9 @@ Call {LIST_AGENT_PROVIDERS_TOOL} first when unsure what is installed."

#[cfg(not(windows))]
async fn login_path(&self) -> Option<String> {
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() {
Expand Down Expand Up @@ -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() {
Expand Down
6 changes: 6 additions & 0 deletions apps/maple-agent/docs/external-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading