From d25bb75cd7c206589e570e698761b1d10ea003e5 Mon Sep 17 00:00:00 2001 From: cpage-pivotal Date: Fri, 24 Oct 2025 12:00:33 -0500 Subject: [PATCH 1/2] fix: add Spring Boot 3.x autoconfiguration support Add AutoConfiguration.imports file to support Spring Boot 3.x's new autoconfiguration discovery mechanism. This ensures ClaudeAgentAutoConfiguration and SandboxAutoConfiguration are properly discovered in Spring Boot 3.x applications. The existing spring.factories file is retained for Spring Boot 2.x compatibility. Fixes the "No qualifying bean of type 'AgentClient$Builder' available" error when running samples with Spring Boot 3.5.0. --- ...springframework.boot.autoconfigure.AutoConfiguration.imports | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 agent-models/spring-ai-claude-agent/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 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 From 6956eb05b4bd05db59e50cb579ca0a1762fb4673 Mon Sep 17 00:00:00 2001 From: cpage-pivotal Date: Fri, 31 Oct 2025 10:14:53 +0900 Subject: [PATCH 2/2] Fixed issues in LocalSandbox execution of Claude CLI --- .../agents/model/sandbox/LocalSandbox.java | 65 +++++++++++++++---- .../claude/sdk/config/ClaudeCliDiscovery.java | 63 ++++-------------- 2 files changed, 64 insertions(+), 64 deletions(-) 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/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.