Fix ensure_cli for Daytona: rootless Node.js install, better errors

- Replace apt-get/NodeSource install (requires root) with direct Node.js
  binary download to ~/.local (works as non-root daytona user)
- Run node install + npm install in single shell so PATH persists
- Add ~/.local/bin to PATH in env file and version check
- Fall back to stdout for error details when stderr is empty (Daytona
  always returns empty stderr)
- Add e2e assertion that cli_stdout.log is written during poll
- Verified on Daytona with haiku: ensure_cli installs in 2s, full
  workflow succeeds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-07 02:01:47 -05:00
parent 50aaf22f8c
commit 4d9b0828c1
3 changed files with 61 additions and 112 deletions

24
Cargo.lock generated
View file

@ -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]]

View file

@ -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::<Vec<_>>().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::<Vec<_>>().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::<Vec<_>>().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<String> = 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<String> = 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<dyn Sandbox> = 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<dyn Sandbox> = 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<dyn Sandbox> = 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<dyn Sandbox> = 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 --

View file

@ -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]