diff --git a/agent-models/spring-ai-agent-model/src/main/java/org/springaicommunity/agents/model/sandbox/LocalSandbox.java b/agent-models/spring-ai-agent-model/src/main/java/org/springaicommunity/agents/model/sandbox/LocalSandbox.java index 8c08d17..178a390 100644 --- a/agent-models/spring-ai-agent-model/src/main/java/org/springaicommunity/agents/model/sandbox/LocalSandbox.java +++ b/agent-models/spring-ai-agent-model/src/main/java/org/springaicommunity/agents/model/sandbox/LocalSandbox.java @@ -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; @@ -72,6 +74,18 @@ public LocalSandbox(Path workingDirectory) { public LocalSandbox(Path workingDirectory, List 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."); } @@ -98,11 +112,7 @@ public ExecResult exec(ExecSpec spec) { List 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()); @@ -117,11 +127,12 @@ public ExecResult exec(ExecSpec spec) { } } - // Apply environment variables + Map 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) { @@ -156,17 +167,47 @@ private List processCommand(List 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) { diff --git a/agent-models/spring-ai-claude-agent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/agent-models/spring-ai-claude-agent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..23b0463 --- /dev/null +++ b/agent-models/spring-ai-claude-agent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +org.springaicommunity.agents.claude.autoconfigure.ClaudeAgentAutoConfiguration +org.springaicommunity.agents.claude.autoconfigure.SandboxAutoConfiguration diff --git a/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/ClaudeCliDiscovery.java b/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/ClaudeCliDiscovery.java index 41a8c48..7011085 100644 --- a/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/ClaudeCliDiscovery.java +++ b/provider-sdks/claude-agent-sdk/src/main/java/org/springaicommunity/agents/claude/sdk/config/ClaudeCliDiscovery.java @@ -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 }; @@ -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; } } @@ -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.