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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/staged/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 10 additions & 3 deletions apps/staged/src-tauri/src/actions/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,20 @@ struct DetectingActionsEvent {
/// Falls back to [`AcpAiProvider::new`] (first installed agent) only when no
/// provider can be resolved at all — i.e. no agents are installed, in which
/// case construction would fail regardless.
pub(crate) fn build_action_provider(
pub(crate) async fn build_action_provider(
provider_id: Option<&str>,
working_dir: PathBuf,
) -> Result<AcpAiProvider> {
match crate::session_commands::discover_preferred_provider_id(provider_id) {
let provider = match crate::session_commands::discover_preferred_provider_id(provider_id) {
Some(id) => AcpAiProvider::with_agent(&id, working_dir),
None => AcpAiProvider::new(working_dir),
}
}?;
let home_snapshot = crate::shell_env::home_env_vars_with_extended_path(
crate::session_runner::shell_env_cache().as_ref(),
)
.await;

Ok(provider.with_interpreter_env_snapshot(home_snapshot))
}

pub(crate) async fn detect_actions_for_repo_context(
Expand Down Expand Up @@ -92,6 +98,7 @@ pub(crate) async fn detect_actions_for_repo_context(
};

let provider = build_action_provider(provider_id, provider_dir.clone())
.await
.map_err(|e| format!("Failed to create AI provider: {e}"))?;

let detector = ActionDetector::new(Box::new(provider));
Expand Down
3 changes: 2 additions & 1 deletion apps/staged/src-tauri/src/actions/run_detector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,8 @@ If still building, set regex and has_endpoint_capture to null/false."#,
let provider_result = super::commands::build_action_provider(
provider_id.as_deref(),
working_dir.clone(),
);
)
.await;
let provider = match provider_result {
Ok(p) => p,
Err(e) => {
Expand Down
15 changes: 12 additions & 3 deletions apps/staged/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1460,10 +1460,19 @@ async fn ai_generate_short_names(
let agent = find_badge_agent(provider)?;
let prompt = build_badge_prompt(existing_badges, new_repos);
let working_dir = std::env::temp_dir();
let interpreter_env_snapshot = crate::shell_env::home_env_vars_with_extended_path(
crate::session_runner::shell_env_cache().as_ref(),
)
.await;

let response = acp_client::run_acp_prompt(&agent, &working_dir, &prompt)
.await
.ok()?;
let response = acp_client::run_acp_prompt_with_interpreter_env_snapshot(
&agent,
&working_dir,
&prompt,
interpreter_env_snapshot,
)
.await
.ok()?;

// Extract JSON from response (may be wrapped in markdown code fences)
let json_str = response
Expand Down
1 change: 1 addition & 0 deletions crates/acp-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ anyhow = "1"
async-trait = "0.1"
log = "0.4"
blox-cli = { path = "../blox-cli" }
doctor = { path = "../doctor" }

[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["signal"] }
27 changes: 8 additions & 19 deletions crates/acp-client/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,25 +360,14 @@ fn path_contains_executable(path_value: &str, executable: &str) -> bool {
std::env::split_paths(path_value).any(|dir| is_executable_file(&dir.join(executable)))
}

fn path_value_from_snapshot(snapshot: &[(String, String)]) -> Option<&str> {
snapshot
.iter()
.find_map(|(key, value)| (key == "PATH").then_some(value.as_str()))
}

fn resolve_executable_from_path(path_value: &str, executable: &str) -> Option<PathBuf> {
std::env::split_paths(path_value)
.map(|dir| dir.join(executable))
.find(|path| is_executable_file(path))
}

fn env_shebang_interpreter_from_snapshot(
binary_path: &Path,
snapshot: Option<&[(String, String)]>,
) -> Option<PathBuf> {
let interpreter = env_shebang_interpreter(binary_path)?;
let path_value = path_value_from_snapshot(snapshot?)?;
resolve_executable_from_path(path_value, &interpreter)
let env = doctor::DoctorEnv::new(snapshot?.to_vec());
let path_value = env.get("PATH")?;
doctor::resolve::resolve_executable_from_path(&interpreter, path_value)
}

fn is_broad_toolchain_dir(path: &Path) -> bool {
Expand Down Expand Up @@ -474,13 +463,13 @@ fn shell_exec_line(
}

#[derive(Debug)]
struct AcpSpawnCommand {
program: PathBuf,
args: Vec<OsString>,
uses_explicit_interpreter: bool,
pub(crate) struct AcpSpawnCommand {
pub(crate) program: PathBuf,
pub(crate) args: Vec<OsString>,
pub(crate) uses_explicit_interpreter: bool,
}

fn acp_spawn_command(
pub(crate) fn acp_spawn_command(
binary_path: &Path,
acp_args: &[String],
interpreter_env_snapshot: Option<&[(String, String)]>,
Expand Down
2 changes: 1 addition & 1 deletion crates/acp-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ pub use agent_client_protocol::{
pub use driver::{
strip_code_fences, AcpDriver, AgentDriver, BasicMessageWriter, MessageWriter, Store,
};
pub use simple::run_acp_prompt;
pub use simple::{run_acp_prompt, run_acp_prompt_with_interpreter_env_snapshot};
pub use types::{
discover_providers, find_acp_agent, find_acp_agent_by_id, find_command, known_agent_commands,
AcpAgent, AcpProviderInfo,
Expand Down
130 changes: 126 additions & 4 deletions crates/acp-client/src/simple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use anyhow::{Context, Result};
use async_trait::async_trait;
use tokio_util::sync::CancellationToken;

use crate::driver::{AgentDriver, BasicMessageWriter, MessageWriter};
use crate::driver::{acp_spawn_command, AgentDriver, BasicMessageWriter, MessageWriter};
use crate::types::AcpAgent;

/// Minimal store implementation for simple prompting (no persistence).
Expand All @@ -36,6 +36,7 @@ struct SimpleDriverWrapper {
binary_path: std::path::PathBuf,
acp_args: Vec<String>,
agent_label: String,
interpreter_env_snapshot: Option<Vec<(String, String)>>,
}

impl SimpleDriverWrapper {
Expand All @@ -44,8 +45,22 @@ impl SimpleDriverWrapper {
binary_path: agent.binary_path.clone(),
acp_args: agent.acp_args.clone(),
agent_label: agent.label.clone(),
interpreter_env_snapshot: None,
}
}

fn with_interpreter_env_snapshot(mut self, vars: Vec<(String, String)>) -> Self {
self.interpreter_env_snapshot = Some(vars);
self
}

fn spawn_command(&self) -> crate::driver::AcpSpawnCommand {
acp_spawn_command(
&self.binary_path,
&self.acp_args,
self.interpreter_env_snapshot.as_deref(),
)
}
}

#[async_trait(?Send)]
Expand Down Expand Up @@ -81,8 +96,9 @@ impl AgentDriver for SimpleDriverWrapper {
use tokio::sync::Mutex;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};

let mut child = Command::new(&self.binary_path)
.args(&self.acp_args)
let spawn_command = self.spawn_command();
let mut child = Command::new(&spawn_command.program)
.args(&spawn_command.args)
.current_dir(working_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
Expand Down Expand Up @@ -293,9 +309,37 @@ impl AgentDriver for SimpleDriverWrapper {
///
/// The agent's text response
pub async fn run_acp_prompt(agent: &AcpAgent, working_dir: &Path, prompt: &str) -> Result<String> {
run_acp_prompt_with_options(agent, working_dir, prompt, None).await
}

/// Run a one-shot prompt through ACP using a separate environment snapshot
/// only for env-shebang interpreter resolution.
///
/// The spawned agent process still inherits the caller's environment. The
/// snapshot is consulted only to turn launchers such as `#!/usr/bin/env node`
/// into `<resolved-node> <launcher>` so repo-local PATH entries do not choose
/// the ACP bridge interpreter.
pub async fn run_acp_prompt_with_interpreter_env_snapshot(
agent: &AcpAgent,
working_dir: &Path,
prompt: &str,
interpreter_env_snapshot: Vec<(String, String)>,
) -> Result<String> {
run_acp_prompt_with_options(agent, working_dir, prompt, Some(interpreter_env_snapshot)).await
}

async fn run_acp_prompt_with_options(
agent: &AcpAgent,
working_dir: &Path,
prompt: &str,
interpreter_env_snapshot: Option<Vec<(String, String)>>,
) -> Result<String> {
let working_dir = working_dir.to_path_buf();
let prompt = prompt.to_string();
let driver = SimpleDriverWrapper::from_agent(agent);
let mut driver = SimpleDriverWrapper::from_agent(agent);
if let Some(snapshot) = interpreter_env_snapshot {
driver = driver.with_interpreter_env_snapshot(snapshot);
}

// Run the ACP session in a blocking task with its own runtime
// This is needed because ACP uses !Send futures (LocalSet)
Expand Down Expand Up @@ -334,3 +378,81 @@ pub async fn run_acp_prompt(agent: &AcpAgent, working_dir: &Path, prompt: &str)
.await
.context("Task join error")?
}

#[cfg(test)]
mod tests {
use super::*;

use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

fn unique_test_dir(prefix: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock must be after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("{prefix}-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create test dir");
dir
}

fn write_executable(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create executable parent");
}
std::fs::write(path, content).expect("write executable");

#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).expect("chmod executable");
}
}

fn join_path_entries(entries: &[PathBuf]) -> String {
std::env::join_paths(entries)
.expect("join path entries")
.into_string()
.expect("path entries should be utf8")
}

#[test]
fn simple_driver_uses_interpreter_snapshot_for_env_shebang_bridge() {
let dir = unique_test_dir("acp-simple-home-interpreter");
let home_bin = dir.join("home-bin");
let project_bin = dir.join("project-bin");
let agent_bin = dir.join("agent-bin");
let launcher = agent_bin.join("claude-agent-acp");
let home_node = home_bin.join("node");
write_executable(&home_node, "#!/bin/sh\n");
write_executable(&project_bin.join("node"), "#!/bin/sh\n");
write_executable(&launcher, "#!/usr/bin/env node\n");

let agent = AcpAgent {
binary_path: launcher.clone(),
acp_args: vec![String::from("--stdio")],
label: String::from("Claude Code"),
};
let home_snapshot = vec![(
String::from("PATH"),
join_path_entries(std::slice::from_ref(&home_bin)),
)];

let command = SimpleDriverWrapper::from_agent(&agent)
.with_interpreter_env_snapshot(home_snapshot)
.spawn_command();

assert_eq!(command.program, home_node);
assert_eq!(
command.args,
vec![
launcher.as_os_str().to_os_string(),
std::ffi::OsString::from("--stdio"),
]
);

std::fs::remove_dir_all(dir).expect("cleanup test dir");
}
}
Loading