diff --git a/Cargo.lock b/Cargo.lock index e0fad3e00..90af8fdf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -806,7 +806,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1125,7 +1125,7 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "daytona-api-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust#b94db55e8bdd8b30b596038e554be0dcac19b40b" +source = "git+https://github.com/brynary/daytona-sdk-rust#31f01e80875f615bb9e26fe6f857df2465ae6b56" dependencies = [ "reqwest 0.12.28", "reqwest-middleware", @@ -1139,7 +1139,7 @@ dependencies = [ [[package]] name = "daytona-sdk" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust#b94db55e8bdd8b30b596038e554be0dcac19b40b" +source = "git+https://github.com/brynary/daytona-sdk-rust#31f01e80875f615bb9e26fe6f857df2465ae6b56" dependencies = [ "daytona-api-client", "daytona-toolbox-client", @@ -1158,7 +1158,7 @@ dependencies = [ [[package]] name = "daytona-toolbox-client" version = "0.1.0" -source = "git+https://github.com/brynary/daytona-sdk-rust#b94db55e8bdd8b30b596038e554be0dcac19b40b" +source = "git+https://github.com/brynary/daytona-sdk-rust#31f01e80875f615bb9e26fe6f857df2465ae6b56" dependencies = [ "reqwest 0.12.28", "reqwest-middleware", @@ -1273,7 +1273,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1360,7 +1360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2672,7 +2672,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3217,7 +3217,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -3606,7 +3606,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3674,7 +3674,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4484,7 +4484,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5363,7 +5363,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/crates/arc-workflows/src/cli/cli_backend.rs b/crates/arc-workflows/src/cli/cli_backend.rs index 1554760be..44ec22ae8 100644 --- a/crates/arc-workflows/src/cli/cli_backend.rs +++ b/crates/arc-workflows/src/cli/cli_backend.rs @@ -70,9 +70,9 @@ async fn ensure_cli( provider: provider_str.to_string(), }); - // Check if the CLI is already installed + // Check if the CLI is already installed (include ~/.local/bin for npm-installed CLIs) let version_check = sandbox - .exec_command(&format!("{cli_name} --version"), 30_000, None, None, None) + .exec_command(&format!("PATH=\"$HOME/.local/bin:$PATH\" {cli_name} --version"), 30_000, None, None, None) .await .map_err(|e| ArcError::handler(format!("Failed to check {cli_name} version: {e}")))?; @@ -88,47 +88,25 @@ async fn ensure_cli( return Ok(()); } - // Check if Node.js is available - let node_check = sandbox - .exec_command("node --version", 30_000, None, None, None) - .await - .map_err(|e| ArcError::handler(format!("Failed to check node version: {e}")))?; - - let mut node_installed = false; - if node_check.exit_code != 0 { - // Install Node.js via NodeSource - let node_install_cmd = "apt-get update -qq && apt-get install -y -qq curl ca-certificates && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y -qq nodejs"; - let node_result = sandbox - .exec_command(node_install_cmd, 120_000, None, None, None) - .await - .map_err(|e| ArcError::handler(format!("Failed to install Node.js: {e}")))?; - - if node_result.exit_code != 0 { - let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - let stderr: String = node_result.stderr.chars().rev().take(500).collect::>().into_iter().rev().collect(); - let error_msg = format!("Node.js install exited with code {}: {stderr}", node_result.exit_code); - emitter.emit(&WorkflowRunEvent::CliEnsureFailed { - cli_name: cli_name.to_string(), - provider: provider_str.to_string(), - error: error_msg.clone(), - duration_ms, - }); - return Err(ArcError::handler(error_msg)); - } - node_installed = true; - } - - // Install the CLI via npm - let npm_cmd = format!("npm install -g {}", cli.npm_package()); - let npm_result = sandbox - .exec_command(&npm_cmd, 120_000, None, None, None) + // Install Node.js (if needed) and the CLI in a single shell so PATH persists + let install_cmd = format!( + "export PATH=\"$HOME/.local/bin:$PATH\" && \ + (node --version >/dev/null 2>&1 || \ + (mkdir -p ~/.local && curl -fsSL https://nodejs.org/dist/v22.14.0/node-v22.14.0-linux-x64.tar.gz | tar -xz --strip-components=1 -C ~/.local)) && \ + npm install -g {}", + cli.npm_package() + ); + let install_result = sandbox + .exec_command(&install_cmd, 180_000, None, None, None) .await .map_err(|e| ArcError::handler(format!("Failed to install {cli_name}: {e}")))?; - if npm_result.exit_code != 0 { + let node_installed = true; + if install_result.exit_code != 0 { let duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX); - let stderr: String = npm_result.stderr.chars().rev().take(500).collect::>().into_iter().rev().collect(); - let error_msg = format!("{cli_name} npm install exited with code {}: {stderr}", npm_result.exit_code); + let output = if install_result.stderr.is_empty() { &install_result.stdout } else { &install_result.stderr }; + let detail: String = output.chars().rev().take(500).collect::>().into_iter().rev().collect(); + let error_msg = format!("{cli_name} install exited with code {}: {detail}", install_result.exit_code); emitter.emit(&WorkflowRunEvent::CliEnsureFailed { cli_name: cli_name.to_string(), provider: provider_str.to_string(), @@ -470,16 +448,16 @@ impl CodergenBackend for AgentCliBackend { // Forward provider API key so the CLI tool can authenticate. // Written to a temp file and sourced, since Daytona's exec API doesn't // support env vars and inline export would leak keys in logs. - let env_lines: Vec = provider - .api_key_env_vars() - .iter() - .filter_map(|name| { - std::env::var(name) - .ok() - .map(|val| format!("export {name}='{val}'")) - }) - .collect(); - if !env_lines.is_empty() { + let mut env_lines: Vec = vec![ + // Ensure npm-installed CLIs in ~/.local/bin are on PATH + "export PATH=\"$HOME/.local/bin:$PATH\"".to_string(), + ]; + env_lines.extend(provider.api_key_env_vars().iter().filter_map(|name| { + std::env::var(name) + .ok() + .map(|val| format!("export {name}='{val}'")) + })); + { sandbox .write_file(&env_path, &env_lines.join("\n")) .await @@ -491,12 +469,8 @@ impl CodergenBackend for AgentCliBackend { tracing::warn!("Failed to disable sandbox auto-stop: {e}"); } - // 3b. Launch CLI command in background - let inner_command = if env_lines.is_empty() { - command.clone() - } else { - format!(". {env_path} && {command}") - }; + // 3b. Launch CLI command in background (env file is always written) + let inner_command = format!(". {env_path} && {command}"); // Use setsid (if available) to create a new session so the child process is // fully detached from the shell. Without this, Daytona's POST /process/execute // blocks until ALL descendant processes exit, causing a 60s HTTP timeout. @@ -820,12 +794,11 @@ mod tests { } #[tokio::test] - async fn ensure_cli_installs_when_missing_with_node() { - // version check fails, node check succeeds, npm install succeeds + async fn ensure_cli_installs_when_missing() { + // version check fails, combined install succeeds let sandbox: Arc = Arc::new(CliMockSandbox::new(vec![ fail_result(127), // claude --version - ok_result(), // node --version - ok_result(), // npm install -g + ok_result(), // combined node + npm install ])); let emitter = Arc::new(EventEmitter::new()); @@ -834,57 +807,21 @@ mod tests { let mock = sandbox.as_ref() as *const dyn Sandbox as *const CliMockSandbox; let commands = unsafe { &*mock }.commands(); - assert_eq!(commands.len(), 3); - assert!(commands[2].contains("npm install -g @anthropic-ai/claude-code")); + assert_eq!(commands.len(), 2); + assert!(commands[1].contains("npm install -g @anthropic-ai/claude-code")); } #[tokio::test] - async fn ensure_cli_installs_node_and_cli() { - // version check fails, node check fails, node install succeeds, npm install succeeds + async fn ensure_cli_fails_on_install_failure() { let sandbox: Arc = Arc::new(CliMockSandbox::new(vec![ fail_result(127), // claude --version - fail_result(127), // node --version - ok_result(), // apt-get ... nodejs - ok_result(), // npm install -g - ])); - let emitter = Arc::new(EventEmitter::new()); - - let result = ensure_cli(AgentCli::Claude, Provider::Anthropic, &sandbox, &emitter).await; - assert!(result.is_ok()); - - let mock = sandbox.as_ref() as *const dyn Sandbox as *const CliMockSandbox; - let commands = unsafe { &*mock }.commands(); - assert_eq!(commands.len(), 4); - assert!(commands[2].contains("nodesource")); - assert!(commands[3].contains("npm install -g")); - } - - #[tokio::test] - async fn ensure_cli_fails_on_node_install_failure() { - let sandbox: Arc = Arc::new(CliMockSandbox::new(vec![ - fail_result(127), // claude --version - fail_result(127), // node --version - fail_result(1), // node install fails + fail_result(1), // combined install fails ])); let emitter = Arc::new(EventEmitter::new()); let result = ensure_cli(AgentCli::Claude, Provider::Anthropic, &sandbox, &emitter).await; assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Node.js install")); - } - - #[tokio::test] - async fn ensure_cli_fails_on_npm_install_failure() { - let sandbox: Arc = Arc::new(CliMockSandbox::new(vec![ - fail_result(127), // claude --version - ok_result(), // node --version - fail_result(1), // npm install fails - ])); - let emitter = Arc::new(EventEmitter::new()); - - let result = ensure_cli(AgentCli::Claude, Provider::Anthropic, &sandbox, &emitter).await; - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("npm install")); + assert!(result.unwrap_err().to_string().contains("install exited with code")); } // -- Cycle 1: cli_command_for_provider -- diff --git a/crates/arc-workflows/tests/integration.rs b/crates/arc-workflows/tests/integration.rs index e5ff77279..aa52f10ca 100644 --- a/crates/arc-workflows/tests/integration.rs +++ b/crates/arc-workflows/tests/integration.rs @@ -9699,6 +9699,18 @@ async fn run_real_cli_test(provider: Provider, model: &str) { serde_json::from_str(&std::fs::read_to_string(&provider_path).unwrap()).unwrap(); assert_eq!(provider_json["mode"], "cli"); assert_eq!(provider_json["provider"], provider.as_str()); + + // Verify CLI output was streamed to stage_dir during poll + let stdout_log = dir.path().join("cli_stdout.log"); + assert!( + stdout_log.exists(), + "{provider}/{model}: cli_stdout.log should be written during poll" + ); + let stdout_content = std::fs::read_to_string(&stdout_log).unwrap(); + assert!( + !stdout_content.is_empty(), + "{provider}/{model}: cli_stdout.log should not be empty" + ); } #[tokio::test]