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") }