- 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.
876 lines
22 KiB
Go
876 lines
22 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/yaoapp/yao/config"
|
|
"github.com/yaoapp/yao/test"
|
|
)
|
|
|
|
// getTestDirs returns workspace and IPC directories for testing.
|
|
// Uses environment variables if set (for CI), otherwise creates temp directories.
|
|
// Returns workspaceRoot, ipcDir, tmpDir (empty if using env vars), error
|
|
func getTestDirs(prefix string) (string, string, string, error) {
|
|
workspaceRoot := os.Getenv("YAO_SANDBOX_WORKSPACE")
|
|
ipcDir := os.Getenv("YAO_SANDBOX_IPC")
|
|
|
|
var tmpDir string
|
|
var err error
|
|
|
|
if workspaceRoot == "" || ipcDir == "" {
|
|
// Create temporary directories for test
|
|
// Use /tmp directly to avoid long paths (Unix socket path limit ~104 bytes)
|
|
tmpDir, err = os.MkdirTemp("/tmp", prefix)
|
|
if err != nil {
|
|
return "", "", "", err
|
|
}
|
|
if workspaceRoot == "" {
|
|
workspaceRoot = filepath.Join(tmpDir, "ws")
|
|
}
|
|
if ipcDir == "" {
|
|
ipcDir = filepath.Join(tmpDir, "ipc")
|
|
}
|
|
}
|
|
|
|
return workspaceRoot, ipcDir, tmpDir, nil
|
|
}
|
|
|
|
// getContainerUser returns the container user from environment variable
|
|
func getContainerUser() string {
|
|
return os.Getenv("YAO_SANDBOX_CONTAINER_USER")
|
|
}
|
|
|
|
// skipIfNoDocker skips the test if Docker is not available
|
|
func skipIfNoDocker(t *testing.T) *Manager {
|
|
t.Helper()
|
|
|
|
workspaceRoot, ipcDir, tmpDir, err := getTestDirs("sandbox-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
|
|
cfg := &Config{
|
|
Image: "yaoapp/sandbox-claude:latest",
|
|
WorkspaceRoot: workspaceRoot,
|
|
IPCDir: ipcDir,
|
|
MaxContainers: 5,
|
|
IdleTimeout: 1 * time.Minute,
|
|
MaxMemory: "512m",
|
|
MaxCPU: 0.5,
|
|
ContainerUser: getContainerUser(),
|
|
}
|
|
|
|
m, err := NewManager(cfg)
|
|
if err != nil {
|
|
// Clean up temp dir
|
|
if tmpDir != "" {
|
|
os.RemoveAll(tmpDir)
|
|
}
|
|
if strings.Contains(err.Error(), "Docker not available") ||
|
|
strings.Contains(err.Error(), "Cannot connect to the Docker daemon") {
|
|
t.Skipf("Skipping test: %v", err)
|
|
}
|
|
t.Fatalf("Failed to create manager: %v", err)
|
|
}
|
|
|
|
// Store tmpDir in test cleanup
|
|
t.Cleanup(func() {
|
|
m.Close()
|
|
if tmpDir != "" {
|
|
os.RemoveAll(tmpDir)
|
|
}
|
|
})
|
|
|
|
return m
|
|
}
|
|
|
|
// TestNewManager tests manager creation
|
|
func TestNewManager(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
if m.dockerClient == nil {
|
|
t.Error("Docker client should not be nil")
|
|
}
|
|
|
|
if m.ipcManager == nil {
|
|
t.Error("IPC manager should not be nil")
|
|
}
|
|
|
|
if m.config == nil {
|
|
t.Error("Config should not be nil")
|
|
}
|
|
}
|
|
|
|
// TestNewManagerWithNilConfig tests manager creation with nil config
|
|
func TestNewManagerWithNilConfig(t *testing.T) {
|
|
tmpDir, err := os.MkdirTemp("", "sandbox-test-nil-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
// Set environment variables for default paths
|
|
os.Setenv("YAO_SANDBOX_WORKSPACE", filepath.Join(tmpDir, "workspace"))
|
|
os.Setenv("YAO_SANDBOX_IPC", filepath.Join(tmpDir, "ipc"))
|
|
defer os.Unsetenv("YAO_SANDBOX_WORKSPACE")
|
|
defer os.Unsetenv("YAO_SANDBOX_IPC")
|
|
|
|
cfg := DefaultConfig()
|
|
cfg.Init(tmpDir)
|
|
|
|
m, err := NewManager(cfg)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "Docker not available") {
|
|
t.Skip("Docker not available")
|
|
}
|
|
t.Fatalf("Failed to create manager: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
if m.config.MaxContainers != 100 {
|
|
t.Errorf("Expected MaxContainers 100, got %d", m.config.MaxContainers)
|
|
}
|
|
}
|
|
|
|
// TestGetOrCreate tests container creation
|
|
func TestGetOrCreate(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
ctx := context.Background()
|
|
userID := "test-user"
|
|
chatID := "test-chat-" + time.Now().Format("20060102150405")
|
|
|
|
// Create container
|
|
container, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed: %v", err)
|
|
}
|
|
|
|
// Verify container properties
|
|
if container.UserID != userID {
|
|
t.Errorf("Expected UserID %s, got %s", userID, container.UserID)
|
|
}
|
|
|
|
if container.ChatID != chatID {
|
|
t.Errorf("Expected ChatID %s, got %s", chatID, container.ChatID)
|
|
}
|
|
|
|
expectedName := containerName(userID, chatID)
|
|
if container.Name != expectedName {
|
|
t.Errorf("Expected Name %s, got %s", expectedName, container.Name)
|
|
}
|
|
|
|
if container.Status != StatusCreated {
|
|
t.Errorf("Expected Status %s, got %s", StatusCreated, container.Status)
|
|
}
|
|
|
|
// Get same container again (should return existing)
|
|
container2, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate (second call) failed: %v", err)
|
|
}
|
|
|
|
if container.ID != container2.ID {
|
|
t.Error("Expected same container on second GetOrCreate call")
|
|
}
|
|
|
|
// Cleanup
|
|
if err := m.Remove(ctx, container.Name); err != nil {
|
|
t.Logf("Warning: failed to remove container: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestContainerStartStopRemove tests container lifecycle
|
|
func TestContainerStartStopRemove(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
ctx := context.Background()
|
|
userID := "lifecycle-user"
|
|
chatID := "lifecycle-chat-" + time.Now().Format("20060102150405")
|
|
|
|
// Create container
|
|
container, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed: %v", err)
|
|
}
|
|
|
|
// Start container
|
|
if err := m.Start(ctx, container.Name); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
|
|
// Verify status is running
|
|
c, ok := m.containers.Load(container.Name)
|
|
if !ok {
|
|
t.Fatal("Container not found in map")
|
|
}
|
|
if c.(*Container).Status != StatusRunning {
|
|
t.Errorf("Expected status %s, got %s", StatusRunning, c.(*Container).Status)
|
|
}
|
|
|
|
// Stop container
|
|
if err := m.Stop(ctx, container.Name); err != nil {
|
|
t.Fatalf("Stop failed: %v", err)
|
|
}
|
|
|
|
// Verify status is stopped
|
|
c, ok = m.containers.Load(container.Name)
|
|
if !ok {
|
|
t.Fatal("Container not found in map after stop")
|
|
}
|
|
if c.(*Container).Status != StatusStopped {
|
|
t.Errorf("Expected status %s, got %s", StatusStopped, c.(*Container).Status)
|
|
}
|
|
|
|
// Remove container
|
|
if err := m.Remove(ctx, container.Name); err != nil {
|
|
t.Fatalf("Remove failed: %v", err)
|
|
}
|
|
|
|
// Verify container is removed from map
|
|
if _, ok := m.containers.Load(container.Name); ok {
|
|
t.Error("Container should be removed from map")
|
|
}
|
|
}
|
|
|
|
// TestExec tests command execution
|
|
func TestExec(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
ctx := context.Background()
|
|
userID := "exec-user"
|
|
chatID := "exec-chat-" + time.Now().Format("20060102150405")
|
|
|
|
// Create and start container
|
|
container, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed: %v", err)
|
|
}
|
|
defer m.Remove(ctx, container.Name)
|
|
|
|
// Execute simple command
|
|
result, err := m.Exec(ctx, container.Name, []string{"echo", "hello world"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec failed: %v", err)
|
|
}
|
|
|
|
// Note: Docker multiplexed stream includes header bytes
|
|
if !strings.Contains(result.Stdout, "hello world") {
|
|
t.Errorf("Expected stdout to contain 'hello world', got: %s", result.Stdout)
|
|
}
|
|
}
|
|
|
|
// TestExecWithEnv tests command execution with environment variables
|
|
func TestExecWithEnv(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
ctx := context.Background()
|
|
userID := "exec-env-user"
|
|
chatID := "exec-env-chat-" + time.Now().Format("20060102150405")
|
|
|
|
container, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed: %v", err)
|
|
}
|
|
defer m.Remove(ctx, container.Name)
|
|
|
|
result, err := m.Exec(ctx, container.Name, []string{"sh", "-c", "echo $TEST_VAR"}, &ExecOptions{
|
|
Env: map[string]string{
|
|
"TEST_VAR": "test_value_123",
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Exec with env failed: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(result.Stdout, "test_value_123") {
|
|
t.Errorf("Expected stdout to contain 'test_value_123', got: %s", result.Stdout)
|
|
}
|
|
}
|
|
|
|
// TestExecWithTimeout tests command execution timeout
|
|
func TestExecWithTimeout(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
ctx := context.Background()
|
|
userID := "exec-timeout-user"
|
|
chatID := "exec-timeout-chat-" + time.Now().Format("20060102150405")
|
|
|
|
container, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed: %v", err)
|
|
}
|
|
defer m.Remove(ctx, container.Name)
|
|
|
|
// Execute command with short timeout (sleep 10s but timeout after 500ms)
|
|
start := time.Now()
|
|
_, err = m.Exec(ctx, container.Name, []string{"sleep", "10"}, &ExecOptions{
|
|
Timeout: 500 * time.Millisecond,
|
|
})
|
|
elapsed := time.Since(start)
|
|
|
|
// Should timeout with context deadline exceeded
|
|
if err == nil {
|
|
t.Error("Expected timeout error, but command completed without error")
|
|
} else if err != context.DeadlineExceeded {
|
|
t.Logf("Got error (expected context.DeadlineExceeded): %v", err)
|
|
}
|
|
|
|
// Verify it didn't wait the full 10 seconds
|
|
if elapsed > 5*time.Second {
|
|
t.Errorf("Timeout took too long: %v (expected < 5s)", elapsed)
|
|
}
|
|
|
|
t.Logf("Timeout completed in %v", elapsed)
|
|
}
|
|
|
|
// TestFileOperations tests filesystem operations
|
|
func TestFileOperations(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
ctx := context.Background()
|
|
userID := "file-user"
|
|
chatID := "file-chat-" + time.Now().Format("20060102150405")
|
|
|
|
container, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed: %v", err)
|
|
}
|
|
defer m.Remove(ctx, container.Name)
|
|
|
|
// Start container first
|
|
if err := m.Start(ctx, container.Name); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
|
|
// Test MkDir
|
|
testDir := "/workspace/testdir"
|
|
if err := m.MkDir(ctx, container.Name, testDir); err != nil {
|
|
t.Fatalf("MkDir failed: %v", err)
|
|
}
|
|
|
|
// Test WriteFile
|
|
testFile := "/workspace/testdir/test.txt"
|
|
testContent := []byte("Hello, Sandbox!")
|
|
if err := m.WriteFile(ctx, container.Name, testFile, testContent); err != nil {
|
|
t.Fatalf("WriteFile failed: %v", err)
|
|
}
|
|
|
|
// Test ReadFile
|
|
content, err := m.ReadFile(ctx, container.Name, testFile)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile failed: %v", err)
|
|
}
|
|
|
|
if string(content) != string(testContent) {
|
|
t.Errorf("Expected content '%s', got '%s'", testContent, content)
|
|
}
|
|
|
|
// Test Stat
|
|
info, err := m.Stat(ctx, container.Name, testFile)
|
|
if err != nil {
|
|
t.Fatalf("Stat failed: %v", err)
|
|
}
|
|
|
|
if info == nil {
|
|
t.Fatal("Stat returned nil")
|
|
}
|
|
|
|
if info.Name != "test.txt" {
|
|
t.Errorf("Expected name 'test.txt', got '%s'", info.Name)
|
|
}
|
|
|
|
if info.Size != int64(len(testContent)) {
|
|
t.Errorf("Expected size %d, got %d", len(testContent), info.Size)
|
|
}
|
|
|
|
// Test ListDir
|
|
files, err := m.ListDir(ctx, container.Name, "/workspace/testdir")
|
|
if err != nil {
|
|
t.Fatalf("ListDir failed: %v", err)
|
|
}
|
|
|
|
found := false
|
|
for _, f := range files {
|
|
if f.Name == "test.txt" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("Expected to find test.txt in directory listing")
|
|
}
|
|
|
|
// Test RemoveFile
|
|
if err := m.RemoveFile(ctx, container.Name, testFile); err != nil {
|
|
t.Fatalf("RemoveFile failed: %v", err)
|
|
}
|
|
|
|
// Verify file is removed - check via ls instead of stat
|
|
// (stat command may still succeed with different output)
|
|
files2, err := m.ListDir(ctx, container.Name, "/workspace/testdir")
|
|
if err != nil {
|
|
t.Fatalf("ListDir after removal failed: %v", err)
|
|
}
|
|
|
|
found = false
|
|
for _, f := range files2 {
|
|
if f.Name == "test.txt" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if found {
|
|
t.Error("File test.txt should be removed")
|
|
}
|
|
}
|
|
|
|
// TestCopyOperations tests copy to/from container
|
|
func TestCopyOperations(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
ctx := context.Background()
|
|
userID := "copy-user"
|
|
chatID := "copy-chat-" + time.Now().Format("20060102150405")
|
|
|
|
container, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed: %v", err)
|
|
}
|
|
defer m.Remove(ctx, container.Name)
|
|
|
|
// Start container
|
|
if err := m.Start(ctx, container.Name); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
|
|
// Create temp file on host
|
|
tmpDir, err := os.MkdirTemp("", "sandbox-copy-test-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tmpDir)
|
|
|
|
hostFile := filepath.Join(tmpDir, "source.txt")
|
|
if err := os.WriteFile(hostFile, []byte("copy test content"), 0644); err != nil {
|
|
t.Fatalf("Failed to write host file: %v", err)
|
|
}
|
|
|
|
// Copy to container
|
|
if err := m.CopyToContainer(ctx, container.Name, hostFile, "/workspace/"); err != nil {
|
|
t.Fatalf("CopyToContainer failed: %v", err)
|
|
}
|
|
|
|
// Verify file exists in container
|
|
content, err := m.ReadFile(ctx, container.Name, "/workspace/source.txt")
|
|
if err != nil {
|
|
t.Fatalf("ReadFile after copy failed: %v", err)
|
|
}
|
|
|
|
if string(content) != "copy test content" {
|
|
t.Errorf("Expected 'copy test content', got '%s'", content)
|
|
}
|
|
|
|
// Copy from container
|
|
extractDir := filepath.Join(tmpDir, "extracted")
|
|
os.MkdirAll(extractDir, 0755)
|
|
|
|
if err := m.CopyFromContainer(ctx, container.Name, "/workspace/source.txt", extractDir); err != nil {
|
|
t.Fatalf("CopyFromContainer failed: %v", err)
|
|
}
|
|
|
|
// Verify extracted file
|
|
extractedContent, err := os.ReadFile(filepath.Join(extractDir, "source.txt"))
|
|
if err != nil {
|
|
t.Fatalf("Failed to read extracted file: %v", err)
|
|
}
|
|
|
|
if string(extractedContent) != "copy test content" {
|
|
t.Errorf("Expected 'copy test content', got '%s'", extractedContent)
|
|
}
|
|
}
|
|
|
|
// TestListContainers tests listing containers for a user
|
|
func TestListContainers(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
ctx := context.Background()
|
|
userID := "list-user"
|
|
chatIDs := []string{
|
|
"list-chat-1-" + time.Now().Format("20060102150405"),
|
|
"list-chat-2-" + time.Now().Format("20060102150405"),
|
|
}
|
|
|
|
// Create multiple containers for same user
|
|
for _, chatID := range chatIDs {
|
|
container, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed for %s: %v", chatID, err)
|
|
}
|
|
defer m.Remove(ctx, container.Name)
|
|
}
|
|
|
|
// List containers for user
|
|
containers, err := m.List(ctx, userID)
|
|
if err != nil {
|
|
t.Fatalf("List failed: %v", err)
|
|
}
|
|
|
|
if len(containers) != 2 {
|
|
t.Errorf("Expected 2 containers, got %d", len(containers))
|
|
}
|
|
|
|
// Verify all containers belong to user
|
|
for _, c := range containers {
|
|
if c.UserID != userID {
|
|
t.Errorf("Expected UserID %s, got %s", userID, c.UserID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestConcurrencyLimit tests the max containers limit
|
|
func TestConcurrencyLimit(t *testing.T) {
|
|
workspaceRoot, ipcDir, tmpDir, err := getTestDirs("sandbox-concurrency-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
if tmpDir != "" {
|
|
defer os.RemoveAll(tmpDir)
|
|
}
|
|
|
|
cfg := &Config{
|
|
Image: "yaoapp/sandbox-claude:latest",
|
|
WorkspaceRoot: workspaceRoot,
|
|
IPCDir: ipcDir,
|
|
MaxContainers: 2, // Low limit for testing
|
|
IdleTimeout: 1 * time.Minute,
|
|
MaxMemory: "256m",
|
|
MaxCPU: 0.25,
|
|
ContainerUser: getContainerUser(),
|
|
}
|
|
|
|
m, err := NewManager(cfg)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "Docker not available") {
|
|
t.Skip("Docker not available")
|
|
}
|
|
t.Fatalf("Failed to create manager: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create containers up to limit
|
|
containers := make([]*Container, 0)
|
|
for i := 0; i < cfg.MaxContainers; i++ {
|
|
c, err := m.GetOrCreate(ctx, "limit-user", "limit-chat-"+string(rune('a'+i)))
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed for container %d: %v", i, err)
|
|
}
|
|
containers = append(containers, c)
|
|
}
|
|
|
|
// Try to create one more - should fail
|
|
_, err = m.GetOrCreate(ctx, "limit-user", "limit-chat-extra")
|
|
if err != ErrTooManyContainers {
|
|
t.Errorf("Expected ErrTooManyContainers, got: %v", err)
|
|
}
|
|
|
|
// Cleanup
|
|
for _, c := range containers {
|
|
m.Remove(ctx, c.Name)
|
|
}
|
|
}
|
|
|
|
// TestConcurrentAccess tests concurrent container access
|
|
func TestConcurrentAccess(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
ctx := context.Background()
|
|
userID := "concurrent-user"
|
|
chatID := "concurrent-chat-" + time.Now().Format("20060102150405")
|
|
|
|
// Create container
|
|
container, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed: %v", err)
|
|
}
|
|
defer m.Remove(ctx, container.Name)
|
|
|
|
// Concurrent GetOrCreate calls should return same container
|
|
var wg sync.WaitGroup
|
|
results := make(chan *Container, 10)
|
|
errors := make(chan error, 10)
|
|
|
|
for i := 0; i < 10; i++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
c, err := m.GetOrCreate(ctx, userID, chatID)
|
|
if err != nil {
|
|
errors <- err
|
|
return
|
|
}
|
|
results <- c
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
close(results)
|
|
close(errors)
|
|
|
|
// Check for errors
|
|
for err := range errors {
|
|
t.Errorf("Concurrent GetOrCreate error: %v", err)
|
|
}
|
|
|
|
// All results should have same ID
|
|
var firstID string
|
|
for c := range results {
|
|
if firstID == "" {
|
|
firstID = c.ID
|
|
} else if c.ID != firstID {
|
|
t.Errorf("Expected same container ID, got different: %s vs %s", firstID, c.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestContainerNotFound tests operations on non-existent container
|
|
func TestContainerNotFound(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
ctx := context.Background()
|
|
fakeName := "yao-sandbox-fake-user-fake-chat"
|
|
|
|
// Test Stop on non-existent (should not error)
|
|
if err := m.Stop(ctx, fakeName); err != nil {
|
|
t.Errorf("Stop on non-existent container should not error: %v", err)
|
|
}
|
|
|
|
// Test Remove on non-existent (should not error)
|
|
if err := m.Remove(ctx, fakeName); err != nil {
|
|
t.Errorf("Remove on non-existent container should not error: %v", err)
|
|
}
|
|
|
|
// Test ensureRunning on non-existent (should error)
|
|
if err := m.ensureRunning(ctx, fakeName); err != ErrContainerNotFound {
|
|
t.Errorf("Expected ErrContainerNotFound, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCleanup tests the cleanup function
|
|
func TestCleanup(t *testing.T) {
|
|
workspaceRoot, ipcDir, tmpDir, err := getTestDirs("sandbox-cleanup-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
if tmpDir != "" {
|
|
defer os.RemoveAll(tmpDir)
|
|
}
|
|
|
|
cfg := &Config{
|
|
Image: "yaoapp/sandbox-claude:latest",
|
|
WorkspaceRoot: workspaceRoot,
|
|
IPCDir: ipcDir,
|
|
MaxContainers: 10,
|
|
IdleTimeout: 100 * time.Millisecond, // Very short for testing
|
|
MaxMemory: "256m",
|
|
MaxCPU: 0.25,
|
|
ContainerUser: getContainerUser(),
|
|
}
|
|
|
|
m, err := NewManager(cfg)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "Docker not available") {
|
|
t.Skip("Docker not available")
|
|
}
|
|
t.Fatalf("Failed to create manager: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create and start container
|
|
container, err := m.GetOrCreate(ctx, "cleanup-user", "cleanup-chat")
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed: %v", err)
|
|
}
|
|
defer m.Remove(ctx, container.Name)
|
|
|
|
if err := m.Start(ctx, container.Name); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
|
|
// Verify running
|
|
c, _ := m.containers.Load(container.Name)
|
|
if c.(*Container).Status != StatusRunning {
|
|
t.Fatalf("Container should be running")
|
|
}
|
|
|
|
// Set LastUsedAt to past
|
|
c.(*Container).LastUsedAt = time.Now().Add(-1 * time.Hour)
|
|
|
|
// Run cleanup
|
|
if err := m.Cleanup(ctx); err != nil {
|
|
t.Fatalf("Cleanup failed: %v", err)
|
|
}
|
|
|
|
// Verify stopped
|
|
c, _ = m.containers.Load(container.Name)
|
|
if c.(*Container).Status != StatusStopped {
|
|
t.Errorf("Container should be stopped after cleanup, got: %s", c.(*Container).Status)
|
|
}
|
|
}
|
|
|
|
// TestManagerWithYaoApp tests sandbox with Yao application loaded
|
|
// This is the full integration test that loads the Yao application environment
|
|
func TestManagerWithYaoApp(t *testing.T) {
|
|
// Check if YAO_TEST_APPLICATION is set
|
|
if os.Getenv("YAO_TEST_APPLICATION") == "" {
|
|
t.Skip("Skipping: YAO_TEST_APPLICATION not set")
|
|
}
|
|
|
|
// Prepare Yao test environment
|
|
test.Prepare(t, config.Conf)
|
|
defer test.Clean()
|
|
|
|
// Now test with the Yao environment loaded
|
|
workspaceRoot, ipcDir, tmpDir, err := getTestDirs("sandbox-yao-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
if tmpDir != "" {
|
|
defer os.RemoveAll(tmpDir)
|
|
}
|
|
|
|
cfg := &Config{
|
|
Image: "yaoapp/sandbox-claude:latest",
|
|
WorkspaceRoot: workspaceRoot,
|
|
IPCDir: ipcDir,
|
|
MaxContainers: 5,
|
|
IdleTimeout: 5 * time.Minute,
|
|
MaxMemory: "1g",
|
|
MaxCPU: 1.0,
|
|
ContainerUser: getContainerUser(),
|
|
}
|
|
|
|
m, err := NewManager(cfg)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "Docker not available") {
|
|
t.Skip("Docker not available")
|
|
}
|
|
t.Fatalf("Failed to create manager: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create container
|
|
container, err := m.GetOrCreate(ctx, "yao-user", "yao-chat")
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed: %v", err)
|
|
}
|
|
defer m.Remove(ctx, container.Name)
|
|
|
|
// Start container
|
|
if err := m.Start(ctx, container.Name); err != nil {
|
|
t.Fatalf("Start failed: %v", err)
|
|
}
|
|
|
|
// Execute a command to verify container is working
|
|
result, err := m.Exec(ctx, container.Name, []string{"node", "--version"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec node --version failed: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(result.Stdout, "v") {
|
|
t.Errorf("Expected node version output, got: %s", result.Stdout)
|
|
}
|
|
|
|
// Execute Python version check
|
|
result, err = m.Exec(ctx, container.Name, []string{"python3", "--version"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Exec python3 --version failed: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(result.Stdout, "Python") {
|
|
t.Errorf("Expected Python version output, got: %s", result.Stdout)
|
|
}
|
|
|
|
t.Log("Sandbox integration with Yao app successful")
|
|
}
|
|
|
|
// TestGetAccessors tests getter methods
|
|
func TestGetAccessors(t *testing.T) {
|
|
m := skipIfNoDocker(t)
|
|
|
|
// Test GetIPCManager
|
|
ipcMgr := m.GetIPCManager()
|
|
if ipcMgr == nil {
|
|
t.Error("GetIPCManager should not return nil")
|
|
}
|
|
|
|
// Test GetConfig
|
|
cfg := m.GetConfig()
|
|
if cfg == nil {
|
|
t.Error("GetConfig should not return nil")
|
|
}
|
|
|
|
if cfg.MaxContainers != 5 {
|
|
t.Errorf("Expected MaxContainers 5, got %d", cfg.MaxContainers)
|
|
}
|
|
}
|
|
|
|
// TestEnsureImageAutoPull tests that missing images are automatically pulled
|
|
func TestEnsureImageAutoPull(t *testing.T) {
|
|
workspaceRoot, ipcDir, tmpDir, err := getTestDirs("sandbox-autopull-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
if tmpDir != "" {
|
|
defer os.RemoveAll(tmpDir)
|
|
}
|
|
|
|
// Use a small known image for testing
|
|
cfg := &Config{
|
|
Image: "alpine:latest",
|
|
WorkspaceRoot: workspaceRoot,
|
|
IPCDir: ipcDir,
|
|
MaxContainers: 2,
|
|
IdleTimeout: 1 * time.Minute,
|
|
MaxMemory: "128m",
|
|
MaxCPU: 0.25,
|
|
ContainerUser: getContainerUser(),
|
|
}
|
|
|
|
m, err := NewManager(cfg)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "Docker not available") {
|
|
t.Skip("Docker not available")
|
|
}
|
|
t.Fatalf("Failed to create manager: %v", err)
|
|
}
|
|
defer m.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Create container - should auto-pull alpine if not present
|
|
container, err := m.GetOrCreate(ctx, "autopull-user", "autopull-chat")
|
|
if err != nil {
|
|
t.Fatalf("GetOrCreate failed (should auto-pull image): %v", err)
|
|
}
|
|
defer m.Remove(ctx, container.Name)
|
|
|
|
// Verify container was created
|
|
if container.Status != StatusCreated {
|
|
t.Errorf("Expected status %s, got %s", StatusCreated, container.Status)
|
|
}
|
|
|
|
t.Log("Image auto-pull successful")
|
|
}
|