- Added functionality to build and manage MCP configuration for sandbox environments, allowing for dynamic tool execution. - Enhanced the Assistant's Stream method to skip MCP tool calls in sandbox mode, with internal handling by Claude CLI. - Introduced unit tests for MCP configuration building and skills directory resolution, ensuring robust integration. - Updated sandbox manager to create IPC sessions and manage tool exposure dynamically, improving interaction with external agents. - Enhanced documentation to reflect new features and integration points for MCP and skills within the sandbox.
395 lines
12 KiB
Go
395 lines
12 KiB
Go
package claude
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/yaoapp/yao/config"
|
|
infraSandbox "github.com/yaoapp/yao/sandbox"
|
|
"github.com/yaoapp/yao/test"
|
|
)
|
|
|
|
// createTestManager creates a sandbox manager for testing with proper configuration
|
|
func createTestManager(t *testing.T) *infraSandbox.Manager {
|
|
// Get data root from environment or use temp directory
|
|
dataRoot := os.Getenv("YAO_ROOT")
|
|
if dataRoot == "" {
|
|
dataRoot = t.TempDir()
|
|
}
|
|
|
|
// Create config with proper paths
|
|
cfg := infraSandbox.DefaultConfig()
|
|
cfg.Init(dataRoot)
|
|
|
|
manager, err := infraSandbox.NewManager(cfg)
|
|
if err != nil {
|
|
t.Skipf("Skipping test: Docker not available: %v", err)
|
|
return nil
|
|
}
|
|
|
|
return manager
|
|
}
|
|
|
|
func TestNewClaudeExecutor(t *testing.T) {
|
|
test.Prepare(t, config.Conf)
|
|
defer test.Clean()
|
|
|
|
manager := createTestManager(t)
|
|
if manager == nil {
|
|
return
|
|
}
|
|
defer manager.Close()
|
|
|
|
opts := &Options{
|
|
Command: "claude",
|
|
Image: "yaoapp/sandbox-claude:latest",
|
|
UserID: "test-user",
|
|
ChatID: fmt.Sprintf("test-chat-claude-%d", time.Now().UnixNano()),
|
|
ConnectorHost: "https://api.example.com",
|
|
ConnectorKey: "key123",
|
|
Model: "test-model",
|
|
}
|
|
|
|
exec, err := NewExecutor(manager, opts)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, exec)
|
|
|
|
// Verify executor was created
|
|
assert.Equal(t, "/workspace", exec.GetWorkDir())
|
|
assert.NoError(t, exec.Close())
|
|
}
|
|
|
|
func TestClaudeExecutorMissingRequiredFields(t *testing.T) {
|
|
test.Prepare(t, config.Conf)
|
|
defer test.Clean()
|
|
|
|
manager := createTestManager(t)
|
|
if manager == nil {
|
|
return
|
|
}
|
|
defer manager.Close()
|
|
|
|
// Missing UserID
|
|
_, err := NewExecutor(manager, &Options{
|
|
Command: "claude",
|
|
ChatID: "test-chat",
|
|
})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "UserID is required")
|
|
|
|
// Missing ChatID
|
|
_, err = NewExecutor(manager, &Options{
|
|
Command: "claude",
|
|
UserID: "test-user",
|
|
})
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "ChatID is required")
|
|
}
|
|
|
|
func TestClaudeExecutorFileOperations(t *testing.T) {
|
|
test.Prepare(t, config.Conf)
|
|
defer test.Clean()
|
|
|
|
manager := createTestManager(t)
|
|
if manager == nil {
|
|
return
|
|
}
|
|
defer manager.Close()
|
|
|
|
opts := &Options{
|
|
Command: "claude",
|
|
Image: "alpine:latest", // Use alpine for simpler testing
|
|
UserID: "test-user",
|
|
ChatID: fmt.Sprintf("test-chat-file-ops-%d", time.Now().UnixNano()),
|
|
}
|
|
|
|
exec, err := NewExecutor(manager, opts)
|
|
require.NoError(t, err)
|
|
defer exec.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Test WriteFile
|
|
content := []byte("Hello, World!")
|
|
err = exec.WriteFile(ctx, "test-file.txt", content)
|
|
require.NoError(t, err)
|
|
|
|
// Test ReadFile
|
|
readContent, err := exec.ReadFile(ctx, "test-file.txt")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, content, readContent)
|
|
|
|
// Test ListDir
|
|
files, err := exec.ListDir(ctx, ".")
|
|
require.NoError(t, err)
|
|
assert.True(t, len(files) > 0, "Expected at least one file in directory")
|
|
|
|
// Find our test file
|
|
var found bool
|
|
for _, f := range files {
|
|
if f.Name == "test-file.txt" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.True(t, found, "Expected to find test-file.txt in directory listing")
|
|
}
|
|
|
|
func TestClaudeExecutorExec(t *testing.T) {
|
|
test.Prepare(t, config.Conf)
|
|
defer test.Clean()
|
|
|
|
manager := createTestManager(t)
|
|
if manager == nil {
|
|
return
|
|
}
|
|
defer manager.Close()
|
|
|
|
opts := &Options{
|
|
Command: "claude",
|
|
Image: "alpine:latest", // Use alpine for simpler testing
|
|
UserID: "test-user",
|
|
ChatID: fmt.Sprintf("test-chat-exec-%d", time.Now().UnixNano()),
|
|
}
|
|
|
|
exec, err := NewExecutor(manager, opts)
|
|
require.NoError(t, err)
|
|
defer exec.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Test simple echo command
|
|
output, err := exec.Exec(ctx, []string{"echo", "hello-world"})
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "hello-world")
|
|
}
|
|
|
|
// TestClaudeExecutorMCPConfigWrite tests that MCP config is correctly written to container
|
|
func TestClaudeExecutorMCPConfigWrite(t *testing.T) {
|
|
test.Prepare(t, config.Conf)
|
|
defer test.Clean()
|
|
|
|
manager := createTestManager(t)
|
|
if manager == nil {
|
|
return
|
|
}
|
|
defer manager.Close()
|
|
|
|
// Create MCP config JSON
|
|
mcpConfig := []byte(`{"mcpServers":{"echo":{"command":"yao-mcp-proxy","args":["echo"],"tools":["ping","echo"]}}}`)
|
|
|
|
opts := &Options{
|
|
Command: "claude",
|
|
Image: "alpine:latest",
|
|
UserID: "test-user",
|
|
ChatID: fmt.Sprintf("test-chat-mcp-write-%d", time.Now().UnixNano()),
|
|
MCPConfig: mcpConfig,
|
|
}
|
|
|
|
exec, err := NewExecutor(manager, opts)
|
|
require.NoError(t, err)
|
|
defer exec.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Call prepareEnvironment to write configs
|
|
err = exec.prepareEnvironment(ctx)
|
|
require.NoError(t, err, "prepareEnvironment should succeed")
|
|
|
|
// Verify MCP config was written by reading it back
|
|
readContent, err := exec.ReadFile(ctx, ".mcp.json")
|
|
require.NoError(t, err, "Should be able to read .mcp.json")
|
|
require.NotEmpty(t, readContent, "MCP config should not be empty")
|
|
|
|
t.Logf("MCP config in container: %s", string(readContent))
|
|
|
|
// Verify content matches
|
|
assert.JSONEq(t, string(mcpConfig), string(readContent), "MCP config content should match")
|
|
|
|
t.Log("✓ MCP config verified in container")
|
|
}
|
|
|
|
// TestClaudeExecutorSkillsCopy tests that skills directory is correctly copied to container
|
|
// Uses real test application skills directory
|
|
func TestClaudeExecutorSkillsCopy(t *testing.T) {
|
|
test.Prepare(t, config.Conf)
|
|
defer test.Clean()
|
|
|
|
manager := createTestManager(t)
|
|
if manager == nil {
|
|
return
|
|
}
|
|
defer manager.Close()
|
|
|
|
// Use real skills directory from test application
|
|
appRoot := os.Getenv("YAO_ROOT")
|
|
require.NotEmpty(t, appRoot, "YAO_ROOT should be set")
|
|
|
|
skillsDir := appRoot + "/assistants/tests/sandbox/full/skills"
|
|
|
|
// Verify skills directory exists on host
|
|
info, err := os.Stat(skillsDir)
|
|
require.NoError(t, err, "Skills directory should exist: %s", skillsDir)
|
|
require.True(t, info.IsDir(), "Skills path should be a directory")
|
|
t.Logf("Using real skills directory: %s", skillsDir)
|
|
|
|
opts := &Options{
|
|
Command: "claude",
|
|
Image: "alpine:latest",
|
|
UserID: "test-user",
|
|
ChatID: fmt.Sprintf("test-chat-skills-%d", time.Now().UnixNano()),
|
|
SkillsDir: skillsDir,
|
|
}
|
|
|
|
exec, err := NewExecutor(manager, opts)
|
|
require.NoError(t, err)
|
|
defer exec.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Call prepareEnvironment to copy skills
|
|
err = exec.prepareEnvironment(ctx)
|
|
require.NoError(t, err, "prepareEnvironment should succeed")
|
|
|
|
// Verify .claude directory was created
|
|
output, err := exec.Exec(ctx, []string{"ls", "-la", ".claude"})
|
|
require.NoError(t, err, ".claude directory should exist")
|
|
t.Logf(".claude directory contents:\n%s", output)
|
|
|
|
// Verify skills directory exists in container
|
|
output, err = exec.Exec(ctx, []string{"ls", "-la", ".claude/skills"})
|
|
require.NoError(t, err, "skills directory should exist in container")
|
|
t.Logf("skills directory contents:\n%s", output)
|
|
assert.Contains(t, output, "echo-test", "echo-test skill should exist")
|
|
|
|
// Verify echo-test skill was copied correctly
|
|
output, err = exec.Exec(ctx, []string{"ls", "-la", ".claude/skills/echo-test"})
|
|
require.NoError(t, err, "echo-test skill directory should exist")
|
|
assert.Contains(t, output, "SKILL.md", "SKILL.md should exist in echo-test")
|
|
assert.Contains(t, output, "scripts", "scripts directory should exist in echo-test")
|
|
t.Logf("echo-test skill contents:\n%s", output)
|
|
|
|
// Read SKILL.md content to verify
|
|
readContent, err := exec.ReadFile(ctx, ".claude/skills/echo-test/SKILL.md")
|
|
require.NoError(t, err, "Should be able to read SKILL.md from container")
|
|
require.NotEmpty(t, readContent, "SKILL.md content should not be empty")
|
|
// Verify content contains expected strings from the real SKILL.md
|
|
assert.Contains(t, string(readContent), "name: echo-test", "SKILL.md should contain skill name")
|
|
assert.Contains(t, string(readContent), "# Echo Test", "SKILL.md should contain the title")
|
|
t.Logf("✓ SKILL.md content verified (%d bytes)", len(readContent))
|
|
|
|
t.Log("✓ Skills directory verified in container with real test data")
|
|
}
|
|
|
|
// TestClaudeExecutorPrepareEnvironmentIntegration tests full environment preparation
|
|
// Uses real test application data
|
|
func TestClaudeExecutorPrepareEnvironmentIntegration(t *testing.T) {
|
|
test.Prepare(t, config.Conf)
|
|
defer test.Clean()
|
|
|
|
manager := createTestManager(t)
|
|
if manager == nil {
|
|
return
|
|
}
|
|
defer manager.Close()
|
|
|
|
// Use real skills directory from test application
|
|
appRoot := os.Getenv("YAO_ROOT")
|
|
require.NotEmpty(t, appRoot, "YAO_ROOT should be set")
|
|
skillsDir := appRoot + "/assistants/tests/sandbox/full/skills"
|
|
|
|
// Verify skills directory exists
|
|
_, err := os.Stat(skillsDir)
|
|
require.NoError(t, err, "Skills directory should exist")
|
|
|
|
// Create MCP config (simulating what buildMCPConfigForSandbox produces)
|
|
mcpConfig := []byte(`{"mcpServers":{"echo":{"command":"yao-mcp-proxy","args":["echo"],"tools":["ping","echo","status"]}}}`)
|
|
|
|
opts := &Options{
|
|
Command: "claude",
|
|
Image: "alpine:latest",
|
|
UserID: "test-user",
|
|
ChatID: fmt.Sprintf("test-chat-full-env-%d", time.Now().UnixNano()),
|
|
ConnectorHost: "https://api.test.com",
|
|
ConnectorKey: "test-key",
|
|
Model: "test-model",
|
|
MCPConfig: mcpConfig,
|
|
SkillsDir: skillsDir,
|
|
}
|
|
|
|
exec, err := NewExecutor(manager, opts)
|
|
require.NoError(t, err)
|
|
defer exec.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Call prepareEnvironment
|
|
err = exec.prepareEnvironment(ctx)
|
|
require.NoError(t, err, "prepareEnvironment should succeed")
|
|
|
|
// Verify all files exist
|
|
// 1. Check CCR config
|
|
ccrContent, err := exec.Exec(ctx, []string{"cat", "/home/sandbox/.claude-code-router/config.json"})
|
|
require.NoError(t, err, "CCR config should exist")
|
|
assert.Contains(t, ccrContent, "api_base_url", "CCR config should contain api_base_url")
|
|
t.Logf("✓ CCR config verified: %d bytes", len(ccrContent))
|
|
|
|
// 2. Check MCP config
|
|
mcpContent, err := exec.ReadFile(ctx, ".mcp.json")
|
|
require.NoError(t, err, "MCP config should exist in container")
|
|
assert.JSONEq(t, string(mcpConfig), string(mcpContent), "MCP config content should match")
|
|
t.Logf("✓ MCP config verified: %s", string(mcpContent))
|
|
|
|
// 3. Check Skills directory structure
|
|
output, err := exec.Exec(ctx, []string{"ls", "-la", ".claude/skills"})
|
|
require.NoError(t, err, "Skills directory should exist in container")
|
|
assert.Contains(t, output, "echo-test", "echo-test skill should exist")
|
|
t.Logf("✓ Skills directory contents:\n%s", output)
|
|
|
|
// 4. Check skill content
|
|
skillContent, err := exec.ReadFile(ctx, ".claude/skills/echo-test/SKILL.md")
|
|
require.NoError(t, err, "SKILL.md should exist in container")
|
|
require.NotEmpty(t, skillContent, "SKILL.md should not be empty")
|
|
assert.Contains(t, string(skillContent), "name: echo-test", "SKILL.md should contain skill name")
|
|
assert.Contains(t, string(skillContent), "# Echo Test", "SKILL.md should contain the title")
|
|
t.Logf("✓ SKILL.md verified: %d bytes", len(skillContent))
|
|
|
|
t.Log("✓ Full environment preparation verified with real test data")
|
|
}
|
|
|
|
// TestClaudeExecutorIPCSocketMount verifies that IPC socket is bind mounted to container
|
|
func TestClaudeExecutorIPCSocketMount(t *testing.T) {
|
|
manager := createTestManager(t)
|
|
|
|
opts := &Options{
|
|
Command: "claude",
|
|
Image: "alpine:latest",
|
|
UserID: "test-user",
|
|
ChatID: "test-ipc-socket-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
|
ConnectorHost: "https://api.test.com",
|
|
ConnectorKey: "test-key",
|
|
Model: "test-model",
|
|
}
|
|
|
|
exec, err := NewExecutor(manager, opts)
|
|
require.NoError(t, err)
|
|
defer exec.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Check if IPC socket exists in container
|
|
output, err := exec.Exec(ctx, []string{"ls", "-la", "/tmp/yao.sock"})
|
|
require.NoError(t, err, "IPC socket should exist in container")
|
|
assert.Contains(t, output, "yao.sock", "Should find yao.sock file")
|
|
t.Logf("✓ IPC socket mounted: %s", strings.TrimSpace(output))
|
|
|
|
// Verify it's a socket file (starts with 's' in ls output)
|
|
assert.Contains(t, output, "srw", "Should be a socket file (starts with 's')")
|
|
t.Log("✓ IPC socket is correctly bind mounted to container")
|
|
}
|