Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
package org.springaicommunity.agents.model.sandbox;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
Expand Down Expand Up @@ -72,6 +74,18 @@ public LocalSandbox(Path workingDirectory) {
public LocalSandbox(Path workingDirectory, List<ExecSpecCustomizer> customizers) {
this.workingDirectory = workingDirectory;
this.customizers = List.copyOf(customizers);

// Ensure working directory exists
try {
if (!Files.exists(workingDirectory)) {
Files.createDirectories(workingDirectory);
logger.info("Created working directory: {}", workingDirectory);
}
}
catch (IOException e) {
logger.warn("Failed to create working directory: {}", workingDirectory, e);
}

logger.warn("LocalSandbox created - NO ISOLATION PROVIDED. Commands execute directly on host system.");
}

Expand All @@ -98,11 +112,7 @@ public ExecResult exec(ExecSpec spec) {
List<String> finalCommand = processCommand(command);

try {
// Use zt-exec for robust process execution
ProcessExecutor executor = new ProcessExecutor().command(finalCommand)
.directory(workingDirectory.toFile())
.readOutput(true)
.destroyOnExit();
ProcessExecutor executor = new ProcessExecutor().command(finalCommand).readOutput(true).destroyOnExit();

logger.info("LocalSandbox executing command in directory: {}", workingDirectory);
logger.info("LocalSandbox command size: {} args", finalCommand.size());
Expand All @@ -117,11 +127,12 @@ public ExecResult exec(ExecSpec spec) {
}
}

// Apply environment variables
Map<String, String> mergedEnv = new HashMap<>(System.getenv());
if (!customizedSpec.env().isEmpty()) {
logger.info("LocalSandbox environment variables: {}", customizedSpec.env().keySet());
executor.environment(customizedSpec.env());
logger.info("LocalSandbox custom environment variables: {}", customizedSpec.env().keySet());
mergedEnv.putAll(customizedSpec.env());
}
executor.environment(mergedEnv);

// Handle timeout
if (customizedSpec.timeout() != null) {
Expand Down Expand Up @@ -156,17 +167,47 @@ private List<String> processCommand(List<String> command) {
// Handle special shell command marker
if (command.size() >= 2 && "__SHELL_COMMAND__".equals(command.get(0))) {
String shellCmd = command.get(1);
// Use platform-appropriate shell
// Use platform-appropriate shell with cd to working directory
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
return List.of("cmd", "/c", shellCmd);
// Use cd /d to change drive if needed on Windows
return List.of("cmd", "/c", "cd /d " + escapePath(workingDirectory.toString()) + " && " + shellCmd);
}
else {
return List.of("bash", "-c", shellCmd);
return List.of("/bin/sh", "-c", "cd " + escapePath(workingDirectory.toString()) + " && " + shellCmd);
}
}

if (!command.isEmpty()) {
// Build the shell command with proper escaping
String shellCmd = String.join(" ", command.stream().map(arg -> {
// Escape arguments that contain spaces or special characters
if (arg.contains(" ") || arg.contains("\"") || arg.contains("'") || arg.contains("\n")
|| arg.contains("$") || arg.contains("`")) {
return "'" + arg.replace("'", "'\\''") + "'";
}
return arg;
}).toList());

// Prepend cd to working directory
if (System.getProperty("os.name").toLowerCase().contains("windows")) {
// Use cd /d to change drive if needed on Windows
String fullCmd = "cd /d " + escapePath(workingDirectory.toString()) + " && " + shellCmd;
return List.of("cmd", "/c", fullCmd);
}
else {
String fullCmd = "cd " + escapePath(workingDirectory.toString()) + " && " + shellCmd;
return List.of("/bin/sh", "-c", fullCmd);
}
}

return command;
}

private String escapePath(String path) {
// Escape single quotes in paths for shell
return "'" + path.replace("'", "'\\''") + "'";
}

private ExecSpec applyCustomizers(ExecSpec spec) {
ExecSpec customizedSpec = spec;
for (ExecSpecCustomizer customizer : customizers) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
org.springaicommunity.agents.claude.autoconfigure.ClaudeAgentAutoConfiguration
org.springaicommunity.agents.claude.autoconfigure.SandboxAutoConfiguration
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,13 @@ public static synchronized String discoverClaudePath() throws ClaudeCliNotFoundE
String[] candidates = { "claude", // In PATH
"claude-code", // Alternative name in PATH
System.getProperty("user.home") + "/.local/bin/claude", // Local
// installation
// installation
"/usr/local/bin/claude", // System-wide installation
"/opt/claude/bin/claude", // Alternative system location
System.getProperty("user.home") + "/.nvm/versions/node/v22.15.0/bin/claude", // NVM
// installation
// installation
System.getProperty("user.home") + "/.nvm/versions/node/latest/bin/claude", // Latest
// NVM
// NVM
"/usr/bin/claude", // Standard system path
FALLBACK_PATH // Development fallback
};
Expand Down Expand Up @@ -128,17 +128,14 @@ private static String testAndResolveClaudeExecutable(String path) {
String version = result.outputUTF8().trim();
logger.debug("Found Claude CLI at {} with version: {}", path, version);

// If this is just a command name (no path separators), resolve to full
// path
if (!path.contains("/") && !path.contains("\\")) {
String resolvedPath = resolveCommandPath(path);
if (resolvedPath != null) {
logger.debug("Resolved command '{}' to full path: {}", path, resolvedPath);
return resolvedPath;
}
}

// Return the original path (already a full path)
// IMPORTANT FIX: Do NOT resolve command names to absolute paths
// Java's ProcessBuilder has issues with shebang scripts (#!/usr/bin/env
// node)
// when using absolute paths, especially on macOS. Let the shell resolve
// via
// PATH instead, which properly handles shebangs and symlinks.
// Just return the working path as-is (whether it's "claude" or a full
// path)
return path;
}
}
Expand All @@ -148,44 +145,6 @@ private static String testAndResolveClaudeExecutable(String path) {
return null;
}

/**
* Resolves a command name to its full path using platform-appropriate commands. Uses
* 'which' on Unix/Linux/macOS and 'where' on Windows.
*/
private static String resolveCommandPath(String commandName) {
try {
String osName = System.getProperty("os.name").toLowerCase();
String[] command;

if (osName.contains("win")) {
// Windows uses 'where'
command = new String[] { "where", commandName };
}
else {
// Unix/Linux/macOS use 'which'
command = new String[] { "which", commandName };
}

ProcessResult result = new ProcessExecutor().command(command)
.timeout(3, TimeUnit.SECONDS)
.readOutput(true)
.execute();

if (result.getExitValue() == 0) {
String output = result.outputUTF8().trim();
// Windows 'where' can return multiple paths, take the first one
if (osName.contains("win") && output.contains("\n")) {
output = output.split("\n")[0].trim();
}
return output;
}
}
catch (Exception e) {
logger.debug("Failed to resolve command path for '{}': {}", commandName, e.getMessage());
}
return null;
}

/**
* Gets the discovered Claude CLI path without performing discovery. Used for cases
* where discovery has already been performed.
Expand Down