diff options
Diffstat (limited to 'internal/executor/container.go')
| -rw-r--r-- | internal/executor/container.go | 147 |
1 files changed, 115 insertions, 32 deletions
diff --git a/internal/executor/container.go b/internal/executor/container.go index 23e35b3..39f737a 100644 --- a/internal/executor/container.go +++ b/internal/executor/container.go @@ -2,6 +2,7 @@ package executor import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -29,6 +30,9 @@ type ContainerRunner struct { ClaudeConfigDir string // host path to ~/.claude; mounted into container for auth credentials CredentialSyncCmd string // optional path to sync-credentials script for auth-error auto-recovery Store Store // optional; used to look up stories and projects for story-aware cloning + // Registry mints the per-task MCP token; when set, the agent gets an + // mcp-config pointing at the host agent MCP server. + Registry *Registry // Command allows mocking exec.CommandContext for tests. Command func(ctx context.Context, name string, arg ...string) *exec.Cmd } @@ -100,7 +104,7 @@ func (r *ContainerRunner) ensureStoryBranch(ctx context.Context, remoteURL, bran return nil } -func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { +func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution, ch AgentChannel) error { var err error repoURL := t.RepositoryURL if repoURL == "" { @@ -237,7 +241,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec } // Run container (with auth retry on failure). - runErr := r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch) + runErr := r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch, ch) if runErr != nil && isAuthError(runErr) && r.CredentialSyncCmd != "" { r.Logger.Warn("auth failure detected, syncing credentials and retrying once", "taskID", t.ID) syncOut, syncErr := r.command(ctx, r.CredentialSyncCmd).CombinedOutput() @@ -251,7 +255,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec if srcData, readErr := os.ReadFile(filepath.Join(r.ClaudeConfigDir, ".claude.json")); readErr == nil { _ = os.WriteFile(filepath.Join(agentHome, ".claude.json"), srcData, 0644) } - runErr = r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch) + runErr = r.runContainer(ctx, t, e, workspace, agentHome, isResume, storyBranch, ch) } if runErr == nil { @@ -267,7 +271,7 @@ func (r *ContainerRunner) Run(ctx context.Context, t *task.Task, e *storage.Exec // runContainer runs the docker container for the given task and handles log setup, // environment files, instructions, and post-execution git operations. -func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *storage.Execution, workspace, agentHome string, isResume bool, storyBranch string) error { +func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *storage.Execution, workspace, agentHome string, isResume bool, storyBranch string, ch AgentChannel) error { repoURL := t.RepositoryURL image := t.Agent.ContainerImage @@ -311,14 +315,39 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto return fmt.Errorf("writing env file: %w", err) } - // Inject custom instructions via file to avoid CLI length limits + // Per-task MCP back-channel: mint a scoped token bound to this run's channel + // and point the agent at the host agent MCP server. Claude reads an + // --mcp-config file; the gemini CLI auto-discovers servers from its + // ~/.gemini/settings.json (agentHome is the container's $HOME). + mcpEnabled := false + if r.Registry != nil { + token, mintErr := r.Registry.Mint(ch) + if mintErr != nil { + return fmt.Errorf("minting agent mcp token: %w", mintErr) + } + defer r.Registry.Revoke(token) + mcpURL := strings.TrimRight(strings.ReplaceAll(r.APIURL, "localhost", "host.docker.internal"), "/") + "/mcp" + if t.Agent.Type == "gemini" { + if err := writeGeminiMCPSettings(agentHome, mcpURL, token); err != nil { + return fmt.Errorf("writing gemini mcp settings: %w", err) + } + } else if err := writeMCPConfig(workspace, mcpURL, token); err != nil { + return fmt.Errorf("writing mcp config: %w", err) + } + mcpEnabled = true + } + + // Inject custom instructions via file to avoid CLI length limits. When the + // MCP back-channel is active, prepend the planning preamble that points the + // agent at the ask_user/report_summary/spawn_subtask/record_progress tools. + instructions := buildAgentInstructions(t, mcpEnabled) instructionsFile := filepath.Join(workspace, ".claudomator-instructions.txt") - if err := os.WriteFile(instructionsFile, []byte(t.Agent.Instructions), 0644); err != nil { + if err := os.WriteFile(instructionsFile, []byte(instructions), 0644); err != nil { return fmt.Errorf("writing instructions: %w", err) } args := r.buildDockerArgs(workspace, agentHome, e.TaskID) - innerCmd := r.buildInnerCmd(t, e, isResume) + innerCmd := r.buildInnerCmd(t, e, isResume, mcpEnabled) fullArgs := append(args, image) fullArgs = append(fullArgs, innerCmd...) @@ -375,31 +404,13 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto e.SessionID = sessionID } - // Check whether the agent left a question before exiting. - questionFile := filepath.Join(logDir, "question.json") - if data, readErr := os.ReadFile(questionFile); readErr == nil { - os.Remove(questionFile) // consumed - questionJSON := strings.TrimSpace(string(data)) - if isCompletionReport(questionJSON) { - r.Logger.Info("treating question file as completion report", "taskID", e.TaskID) - e.Summary = extractQuestionText(questionJSON) - } else { - if e.SessionID == "" { - r.Logger.Warn("missing session ID; resume will start fresh", "taskID", e.TaskID) - } - return &BlockedError{ - QuestionJSON: questionJSON, - SessionID: e.SessionID, - SandboxDir: workspace, - } + // MCP transport: if the agent called ask_user, the question is buffered on + // the channel. Block so the task resumes with the user's answer. + if q, blocked := channelPendingQuestion(ch); blocked { + if e.SessionID == "" { + r.Logger.Warn("missing session ID; resume will start fresh", "taskID", e.TaskID) } - } - - // Read agent summary if written. - summaryFile := filepath.Join(logDir, "summary.txt") - if summaryData, readErr := os.ReadFile(summaryFile); readErr == nil { - os.Remove(summaryFile) // consumed - e.Summary = strings.TrimSpace(string(summaryData)) + return &BlockedError{QuestionJSON: q, SessionID: e.SessionID, SandboxDir: workspace} } // 5. Post-execution: push changes if successful @@ -467,6 +478,10 @@ func (r *ContainerRunner) buildDockerArgs(workspace, claudeHome, taskID string) "-w", "/workspace", "--env-file", hostEnvFile, "-e", "HOME=/home/agent", + // The container is an isolated sandbox; IS_SANDBOX=1 lets the claude CLI + // honor --permission-mode bypassPermissions even when the agent runs as + // root (buildDockerArgs maps --user to the host uid, which may be 0). + "-e", "IS_SANDBOX=1", "-e", "CLAUDOMATOR_API_URL=" + apiURL, "-e", "CLAUDOMATOR_TASK_ID=" + taskID, "-e", "CLAUDOMATOR_DROP_DIR=" + r.DropsDir, @@ -477,7 +492,71 @@ func (r *ContainerRunner) buildDockerArgs(workspace, claudeHome, taskID string) return args } -func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isResume bool) []string { +// mcpConfigContainerPath is where the agent mcp-config is mounted in-container +// (the workspace is bind-mounted at /workspace). +const mcpConfigContainerPath = "/workspace/.claudomator-mcp.json" + +// buildAgentInstructions returns the agent's instructions, prepending the +// MCP-tool planning preamble when the back-channel is active and planning is +// not skipped. +func buildAgentInstructions(t *task.Task, mcpEnabled bool) string { + instructions := t.Agent.Instructions + if mcpEnabled && !t.Agent.SkipPlanning { + instructions = withPlanningPreamble(instructions) + } + return instructions +} + +// writeMCPConfig writes a claude CLI mcp-config that registers the per-task +// agent MCP server over HTTP with a bearer token. +func writeMCPConfig(workspace, mcpURL, token string) error { + cfg := map[string]any{ + "mcpServers": map[string]any{ + "claudomator": map[string]any{ + "type": "http", + "url": mcpURL, + "headers": map[string]string{"Authorization": "Bearer " + token}, + }, + }, + } + data, err := json.Marshal(cfg) + if err != nil { + return err + } + return os.WriteFile(filepath.Join(workspace, ".claudomator-mcp.json"), data, 0600) +} + +// writeGeminiMCPSettings registers the per-task agent MCP server in the gemini +// CLI's user settings (agentHome/.gemini/settings.json, which is $HOME/.gemini +// in-container). The gemini CLI discovers MCP servers from this file rather than +// a command-line flag; httpUrl selects the streamable-HTTP transport and headers +// carry the bearer token. +// +// NOTE: the on-disk schema follows the gemini-cli mcpServers format, but whether +// the CLI actually invokes these tools in non-interactive (-p) mode has not been +// verified against a live gemini binary — confirm with a spike before relying on +// gemini tool-use in production. +func writeGeminiMCPSettings(agentHome, mcpURL, token string) error { + cfg := map[string]any{ + "mcpServers": map[string]any{ + "claudomator": map[string]any{ + "httpUrl": mcpURL, + "headers": map[string]string{"Authorization": "Bearer " + token}, + }, + }, + } + data, err := json.Marshal(cfg) + if err != nil { + return err + } + dir := filepath.Join(agentHome, ".gemini") + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, "settings.json"), data, 0600) +} + +func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isResume, mcpEnabled bool) []string { // Claude CLI uses -p for prompt text. To pass a file, we use a shell to cat it. // We use a shell variable to capture the expansion to avoid quoting issues with instructions contents. // The outer single quotes around the sh -c argument prevent host-side expansion. @@ -501,6 +580,9 @@ func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isRe if isResume && e.ResumeSessionID != "" { claudeCmd.WriteString(fmt.Sprintf(" --resume %s", e.ResumeSessionID)) } + if mcpEnabled { + claudeCmd.WriteString(" --mcp-config " + mcpConfigContainerPath) + } claudeCmd.WriteString(" --output-format stream-json --verbose --permission-mode bypassPermissions") return []string{"sh", "-c", claudeCmd.String()} @@ -511,6 +593,7 @@ func (r *ContainerRunner) buildInnerCmd(t *task.Task, e *storage.Execution, isRe var scaffoldPrefixes = []string{ ".claudomator-env", ".claudomator-instructions.txt", + ".claudomator-mcp.json", ".agent-home", } |
