Update all hardcoded paths from ~/AI/projects/* to ~/chorus/project-queues/active/* and ~/AI/secrets/* to ~/chorus/business/secrets/* after workspace reorganization. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
359 lines
11 KiB
Go
359 lines
11 KiB
Go
package sandbox
|
|
|
|
import (
|
|
"archive/tar"
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/anthonyrawlins/bzzz/pkg/config"
|
|
"github.com/anthonyrawlins/bzzz/workspace"
|
|
"github.com/docker/docker/api/types/container"
|
|
"github.com/docker/docker/client"
|
|
"github.com/docker/docker/pkg/stdcopy"
|
|
)
|
|
|
|
// Sandbox represents a stateful, isolated execution environment for a single task.
|
|
type Sandbox struct {
|
|
ID string // The ID of the running container.
|
|
HostPath string // The path on the host machine mounted as the workspace.
|
|
Workspace string // The path inside the container that is the workspace.
|
|
dockerCli *client.Client
|
|
ctx context.Context
|
|
hcfsWorkspace *workspace.HCFSWorkspace // HCFS-backed workspace
|
|
workspaceManager *workspace.HCFSWorkspaceManager // HCFS workspace manager
|
|
}
|
|
|
|
// CommandResult holds the output of a command executed in the sandbox.
|
|
type CommandResult struct {
|
|
StdOut string
|
|
StdErr string
|
|
ExitCode int
|
|
}
|
|
|
|
// CreateSandbox provisions a new Docker container for a task.
|
|
func CreateSandbox(ctx context.Context, taskImage string, agentConfig *config.AgentConfig) (*Sandbox, error) {
|
|
return CreateSandboxWithHCFS(ctx, taskImage, agentConfig, "", "")
|
|
}
|
|
|
|
// CreateSandboxWithHCFS provisions a new Docker container with HCFS workspace support
|
|
func CreateSandboxWithHCFS(ctx context.Context, taskImage string, agentConfig *config.AgentConfig, agentID, taskID string) (*Sandbox, error) {
|
|
if taskImage == "" {
|
|
taskImage = agentConfig.SandboxImage
|
|
}
|
|
|
|
// Create a new Docker client
|
|
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create docker client: %w", err)
|
|
}
|
|
|
|
// Initialize HCFS workspace manager
|
|
hcfsAPIURL := os.Getenv("HCFS_API_URL")
|
|
if hcfsAPIURL == "" {
|
|
hcfsAPIURL = "http://localhost:8000" // Default HCFS API URL
|
|
}
|
|
|
|
hcfsMountPath := os.Getenv("HCFS_MOUNT_PATH")
|
|
if hcfsMountPath == "" {
|
|
hcfsMountPath = "/tmp/hcfs-workspaces" // Default mount path
|
|
}
|
|
|
|
workspaceManager := workspace.NewHCFSWorkspaceManager(hcfsAPIURL, hcfsMountPath)
|
|
|
|
var hostPath string
|
|
var hcfsWorkspace *workspace.HCFSWorkspace
|
|
|
|
// Create workspace - use HCFS if agent/task IDs provided, fallback to temp dir
|
|
if agentID != "" && taskID != "" {
|
|
// Create HCFS-backed workspace
|
|
hcfsWorkspace, err = workspaceManager.CreateWorkspace(ctx, agentID, taskID, agentConfig)
|
|
if err != nil {
|
|
fmt.Printf("⚠️ Failed to create HCFS workspace, falling back to temp dir: %v\n", err)
|
|
// Fallback to temporary directory
|
|
hostPath, err = os.MkdirTemp("", "bzzz-sandbox-")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create temp dir for sandbox: %w", err)
|
|
}
|
|
} else {
|
|
hostPath = workspaceManager.GetWorkspacePath(hcfsWorkspace)
|
|
}
|
|
} else {
|
|
// Create a temporary directory on the host (legacy mode)
|
|
hostPath, err = os.MkdirTemp("", "bzzz-sandbox-")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create temp dir for sandbox: %w", err)
|
|
}
|
|
fmt.Printf("⚠️ Creating sandbox without HCFS (no agent/task ID provided)\n")
|
|
}
|
|
|
|
// Read GitHub token for authentication
|
|
githubToken := os.Getenv("BZZZ_GITHUB_TOKEN")
|
|
if githubToken == "" {
|
|
// Try to read from file
|
|
tokenBytes, err := os.ReadFile("/home/tony/chorus/business/secrets/gh-token")
|
|
if err == nil {
|
|
githubToken = strings.TrimSpace(string(tokenBytes))
|
|
}
|
|
}
|
|
|
|
// Define container configuration
|
|
containerConfig := &container.Config{
|
|
Image: taskImage,
|
|
Tty: true, // Keep the container running
|
|
OpenStdin: true,
|
|
WorkingDir: "/home/agent/work",
|
|
User: "agent",
|
|
Env: []string{
|
|
"GITHUB_TOKEN=" + githubToken,
|
|
"GH_TOKEN=" + githubToken,
|
|
},
|
|
}
|
|
|
|
// Define host configuration (e.g., volume mounts, resource limits)
|
|
hostConfig := &container.HostConfig{
|
|
Binds: []string{fmt.Sprintf("%s:/home/agent/work", hostPath)},
|
|
Resources: container.Resources{
|
|
NanoCPUs: 2 * 1000000000, // 2 CPUs
|
|
Memory: 2 * 1024 * 1024 * 1024, // 2GB
|
|
},
|
|
}
|
|
|
|
// Create the container
|
|
resp, err := cli.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, "")
|
|
if err != nil {
|
|
os.RemoveAll(hostPath) // Clean up the directory if container creation fails
|
|
return nil, fmt.Errorf("failed to create container: %w", err)
|
|
}
|
|
|
|
// Start the container
|
|
if err := cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
|
os.RemoveAll(hostPath) // Clean up
|
|
return nil, fmt.Errorf("failed to start container: %w", err)
|
|
}
|
|
|
|
fmt.Printf("✅ Sandbox container %s created successfully.\n", resp.ID[:12])
|
|
|
|
return &Sandbox{
|
|
ID: resp.ID,
|
|
HostPath: hostPath,
|
|
Workspace: "/home/agent/work",
|
|
dockerCli: cli,
|
|
ctx: ctx,
|
|
hcfsWorkspace: hcfsWorkspace,
|
|
workspaceManager: workspaceManager,
|
|
}, nil
|
|
}
|
|
|
|
// DestroySandbox stops and removes the container and its associated host directory.
|
|
func (s *Sandbox) DestroySandbox() error {
|
|
if s == nil || s.ID == "" {
|
|
return nil
|
|
}
|
|
|
|
// Define a timeout for stopping the container
|
|
timeout := 30 // seconds
|
|
|
|
// Stop the container
|
|
fmt.Printf("🛑 Stopping sandbox container %s...\n", s.ID[:12])
|
|
err := s.dockerCli.ContainerStop(s.ctx, s.ID, container.StopOptions{Timeout: &timeout})
|
|
if err != nil {
|
|
// Log the error but continue to try and clean up
|
|
fmt.Printf("⚠️ Error stopping container %s: %v. Proceeding with cleanup.\n", s.ID, err)
|
|
}
|
|
|
|
// Remove the container
|
|
err = s.dockerCli.ContainerRemove(s.ctx, s.ID, container.RemoveOptions{Force: true})
|
|
if err != nil {
|
|
fmt.Printf("⚠️ Error removing container %s: %v. Proceeding with cleanup.\n", s.ID, err)
|
|
}
|
|
|
|
// Handle workspace cleanup - HCFS vs temp directory
|
|
if s.hcfsWorkspace != nil && s.workspaceManager != nil {
|
|
// Store any final artifacts before cleanup
|
|
artifacts := s.collectWorkspaceArtifacts()
|
|
if len(artifacts) > 0 {
|
|
if err := s.workspaceManager.StoreWorkspaceArtifacts(s.ctx, s.hcfsWorkspace, artifacts); err != nil {
|
|
fmt.Printf("⚠️ Failed to store workspace artifacts: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// Destroy HCFS workspace
|
|
if err := s.workspaceManager.DestroyWorkspace(s.ctx, s.hcfsWorkspace); err != nil {
|
|
fmt.Printf("⚠️ Failed to destroy HCFS workspace: %v\n", err)
|
|
}
|
|
} else {
|
|
// Legacy mode: Remove the host directory
|
|
fmt.Printf("🗑️ Removing host directory %s...\n", s.HostPath)
|
|
err = os.RemoveAll(s.HostPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove host directory %s: %w", s.HostPath, err)
|
|
}
|
|
}
|
|
|
|
fmt.Printf("✅ Sandbox %s destroyed successfully.\n", s.ID[:12])
|
|
return nil
|
|
}
|
|
|
|
// RunCommand executes a shell command inside the sandbox.
|
|
func (s *Sandbox) RunCommand(command string) (*CommandResult, error) {
|
|
// Update workspace usage if using HCFS
|
|
if s.hcfsWorkspace != nil && s.workspaceManager != nil {
|
|
s.workspaceManager.UpdateWorkspaceUsage(s.hcfsWorkspace)
|
|
}
|
|
|
|
// Configuration for the exec process
|
|
execConfig := container.ExecOptions{
|
|
Cmd: []string{"/bin/sh", "-c", command},
|
|
AttachStdout: true,
|
|
AttachStderr: true,
|
|
Tty: false,
|
|
}
|
|
|
|
// Create the exec instance
|
|
execID, err := s.dockerCli.ContainerExecCreate(s.ctx, s.ID, execConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create exec in container: %w", err)
|
|
}
|
|
|
|
// Start the exec process
|
|
resp, err := s.dockerCli.ContainerExecAttach(s.ctx, execID.ID, container.ExecStartOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to attach to exec in container: %w", err)
|
|
}
|
|
defer resp.Close()
|
|
|
|
// Read the output
|
|
var stdout, stderr bytes.Buffer
|
|
_, err = stdcopy.StdCopy(&stdout, &stderr, resp.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read exec output: %w", err)
|
|
}
|
|
|
|
// Inspect the exec process to get the exit code
|
|
inspect, err := s.dockerCli.ContainerExecInspect(s.ctx, execID.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to inspect exec in container: %w", err)
|
|
}
|
|
|
|
return &CommandResult{
|
|
StdOut: stdout.String(),
|
|
StdErr: stderr.String(),
|
|
ExitCode: inspect.ExitCode,
|
|
}, nil
|
|
}
|
|
|
|
// WriteFile writes content to a file inside the sandbox's workspace.
|
|
func (s *Sandbox) WriteFile(path string, content []byte) error {
|
|
// Create a temporary file on the host
|
|
tmpfile, err := os.CreateTemp("", "bzzz-write-")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create temp file: %w", err)
|
|
}
|
|
defer os.Remove(tmpfile.Name())
|
|
|
|
if _, err := tmpfile.Write(content); err != nil {
|
|
return fmt.Errorf("failed to write to temp file: %w", err)
|
|
}
|
|
tmpfile.Close()
|
|
|
|
// Copy the file into the container
|
|
dstPath := filepath.Join(s.Workspace, path)
|
|
|
|
// Create tar archive of the file
|
|
tarBuf := new(bytes.Buffer)
|
|
tw := tar.NewWriter(tarBuf)
|
|
|
|
fileInfo, err := os.Stat(tmpfile.Name())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to stat temp file: %w", err)
|
|
}
|
|
|
|
header := &tar.Header{
|
|
Name: filepath.Base(path),
|
|
Size: fileInfo.Size(),
|
|
Mode: 0644,
|
|
}
|
|
|
|
if err := tw.WriteHeader(header); err != nil {
|
|
return fmt.Errorf("failed to write tar header: %w", err)
|
|
}
|
|
|
|
fileContent, err := os.ReadFile(tmpfile.Name())
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read temp file: %w", err)
|
|
}
|
|
|
|
if _, err := tw.Write(fileContent); err != nil {
|
|
return fmt.Errorf("failed to write to tar: %w", err)
|
|
}
|
|
|
|
if err := tw.Close(); err != nil {
|
|
return fmt.Errorf("failed to close tar writer: %w", err)
|
|
}
|
|
|
|
return s.dockerCli.CopyToContainer(s.ctx, s.ID, filepath.Dir(dstPath), tarBuf, container.CopyToContainerOptions{})
|
|
}
|
|
|
|
// ReadFile reads the content of a file from the sandbox's workspace.
|
|
func (s *Sandbox) ReadFile(path string) ([]byte, error) {
|
|
srcPath := filepath.Join(s.Workspace, path)
|
|
|
|
// Copy the file from the container
|
|
reader, _, err := s.dockerCli.CopyFromContainer(s.ctx, s.ID, srcPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to copy from container: %w", err)
|
|
}
|
|
defer reader.Close()
|
|
|
|
// The result is a tar archive, so we need to extract it
|
|
tr := tar.NewReader(reader)
|
|
if _, err := tr.Next(); err != nil {
|
|
return nil, fmt.Errorf("failed to get tar header: %w", err)
|
|
}
|
|
|
|
buf := new(bytes.Buffer)
|
|
if _, err := io.Copy(buf, tr); err != nil {
|
|
return nil, fmt.Errorf("failed to read file content from tar: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// collectWorkspaceArtifacts collects important artifacts from the workspace
|
|
func (s *Sandbox) collectWorkspaceArtifacts() map[string]string {
|
|
artifacts := make(map[string]string)
|
|
|
|
// Common artifact files to collect
|
|
artifactFiles := []string{
|
|
"output/result.txt",
|
|
"output/summary.md",
|
|
"logs/execution.log",
|
|
"build/manifest.json",
|
|
".bzzz-workspace-state",
|
|
}
|
|
|
|
for _, artifactFile := range artifactFiles {
|
|
content, err := s.ReadFile(artifactFile)
|
|
if err == nil && len(content) > 0 {
|
|
artifacts[artifactFile] = string(content)
|
|
}
|
|
}
|
|
|
|
return artifacts
|
|
}
|
|
|
|
// GetHCFSWorkspace returns the HCFS workspace if available
|
|
func (s *Sandbox) GetHCFSWorkspace() *workspace.HCFSWorkspace {
|
|
return s.hcfsWorkspace
|
|
}
|
|
|
|
// IsUsingHCFS returns true if this sandbox is using HCFS workspace
|
|
func (s *Sandbox) IsUsingHCFS() bool {
|
|
return s.hcfsWorkspace != nil && s.workspaceManager != nil
|
|
}
|