summaryrefslogtreecommitdiff
path: root/internal/executor/container.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
commit68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch)
tree14b73f21570a2f42ae729ca7e9676de6628d6819 /internal/executor/container.go
parentb28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff)
parent7388337d3be5cc7f65ef547e30b2e39884dd165b (diff)
merge: integrate oss branch — budget gating, MCP back-channel, events, loopback-only bind
Key features from OSS branch: - Budget gating: rolling per-provider spend caps with BUDGET_EXCEEDED task state - Agent MCP back-channel: runners mint tokens; /mcp endpoint resolves them - Chatbot MCP server at /chatbot/mcp (requires api_token in config) - Event timeline: unified event stream replacing ad-hoc question/summary flows - Loopback-only default bind (127.0.0.1:8484); external_bind_allowed=true to expose - repository_url required on task creation (enforces traceability) - Auto-checker: spawns verification task after each execution Conflict resolutions: - serve.go: keep cfg.Runners.XEnabled() conditional registration from main + agentRegistry from oss - config.go: keep RunnersConfig from main + BudgetConfig/ExternalBindAllowed from oss - server.go: handleStaticFiles (base-path rewrite) kept; deduplicated duplicate from both sides - executor/claude.go: accepted oss deletion (ClaudeRunner replaced by ContainerRunner) - container_test.go: added noopChannel + AgentChannel parameter to Run call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/executor/container.go')
-rw-r--r--internal/executor/container.go147
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",
}