package executor import ( "context" "fmt" "log/slog" "os" "os/exec" "path/filepath" "sync" "syscall" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) // ContainerRunner executes an agent inside a container. type ContainerRunner struct { Image string // default image if not specified in task Logger *slog.Logger LogDir string APIURL string DropsDir string SSHAuthSock string // optional path to host SSH agent } func (r *ContainerRunner) ExecLogDir(execID string) string { if r.LogDir == "" { return "" } return filepath.Join(r.LogDir, execID) } func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { repoURL := t.RepositoryURL if repoURL == "" { // Fallback to project_dir if repository_url is not set (legacy support) if t.Agent.ProjectDir != "" { repoURL = t.Agent.ProjectDir } else { return fmt.Errorf("task %s has no repository_url or project_dir", t.ID) } } image := t.Agent.ContainerImage if image == "" { image = r.Image } if image == "" { image = "claudomator-agent:latest" } // 1. Setup workspace on host workspace, err := os.MkdirTemp("", "claudomator-workspace-*") if err != nil { return fmt.Errorf("creating workspace: %w", err) } defer os.RemoveAll(workspace) // 2. Clone repo into workspace r.Logger.Info("cloning repository", "url", repoURL, "workspace", workspace) if out, err := exec.CommandContext(ctx, "git", "clone", repoURL, workspace).CombinedOutput(); err != nil { return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) } // 3. Prepare logs logDir := r.ExecLogDir(e.ID) if logDir == "" { logDir = filepath.Join(workspace, ".claudomator-logs") } if err := os.MkdirAll(logDir, 0700); err != nil { return fmt.Errorf("creating log dir: %w", err) } e.StdoutPath = filepath.Join(logDir, "stdout.log") e.StderrPath = filepath.Join(logDir, "stderr.log") e.ArtifactDir = logDir stdoutFile, err := os.Create(e.StdoutPath) if err != nil { return fmt.Errorf("creating stdout log: %w", err) } defer stdoutFile.Close() stderrFile, err := os.Create(e.StderrPath) if err != nil { return fmt.Errorf("creating stderr log: %w", err) } defer stderrFile.Close() // 4. Run container // Build docker command args := []string{ "run", "--rm", "-v", workspace + ":/workspace", "-w", "/workspace", "-e", "CLAUDOMATOR_API_URL=" + r.APIURL, "-e", "CLAUDOMATOR_TASK_ID=" + e.TaskID, "-e", "CLAUDOMATOR_DROP_DIR=" + r.DropsDir, "-e", "ANTHROPIC_API_KEY=" + os.Getenv("ANTHROPIC_API_KEY"), "-e", "GOOGLE_API_KEY=" + os.Getenv("GOOGLE_API_KEY"), } // Inject custom instructions as environment variable or via file instructionsFile := filepath.Join(workspace, ".claudomator-instructions.txt") if err := os.WriteFile(instructionsFile, []byte(t.Agent.Instructions), 0600); err != nil { return fmt.Errorf("writing instructions: %w", err) } // Command to run inside container: we assume the image has 'claude' or 'gemini' // and a wrapper script that reads CLAUDOMATOR_TASK_ID etc. innerCmd := []string{"claude", "-p", t.Agent.Instructions, "--session-id", e.ID, "--output-format", "stream-json", "--verbose", "--permission-mode", "bypassPermissions"} if t.Agent.Type == "gemini" { innerCmd = []string{"gemini", "-p", t.Agent.Instructions} // simplified for now } args = append(args, image) args = append(args, innerCmd...) r.Logger.Info("starting container", "image", image, "taskID", t.ID) cmd := exec.CommandContext(ctx, "docker", args...) cmd.Stderr = stderrFile cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Use os.Pipe for stdout so we can parse it in real-time stdoutR, stdoutW, err := os.Pipe() if err != nil { return fmt.Errorf("creating stdout pipe: %w", err) } cmd.Stdout = stdoutW if err := cmd.Start(); err != nil { stdoutW.Close() stdoutR.Close() return fmt.Errorf("starting container: %w", err) } stdoutW.Close() // Stream stdout to the log file and parse cost/errors. var costUSD float64 var streamErr error var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() costUSD, streamErr = parseStream(stdoutR, stdoutFile, r.Logger) stdoutR.Close() }() waitErr := cmd.Wait() wg.Wait() e.CostUSD = costUSD // 5. Post-execution: push changes if successful if waitErr == nil && streamErr == nil { r.Logger.Info("pushing changes back to remote", "url", repoURL) // We assume the sandbox has committed changes (the agent image should enforce this) if out, err := exec.CommandContext(ctx, "git", "-C", workspace, "push", "origin", "HEAD").CombinedOutput(); err != nil { r.Logger.Warn("git push failed", "error", err, "output", string(out)) // Don't fail the task just because push failed, but record it? // Actually, user said: "they should only ever commit to their sandbox, and only ever push to an actual remote" // So push failure is a task failure in this new model. return fmt.Errorf("git push failed: %w\n%s", err, string(out)) } } if waitErr != nil { return fmt.Errorf("container execution failed: %w", waitErr) } return nil }