summaryrefslogtreecommitdiff
path: root/internal/executor/container.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/container.go')
-rw-r--r--internal/executor/container.go63
1 files changed, 60 insertions, 3 deletions
diff --git a/internal/executor/container.go b/internal/executor/container.go
index 4269ef1..f0f728c 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
}
@@ -307,8 +311,24 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto
return fmt.Errorf("writing instructions: %w", err)
}
+ // Per-task MCP back-channel: mint a scoped token bound to this run's channel
+ // and write an mcp-config pointing the agent at the host agent MCP server.
+ mcpEnabled := false
+ if r.Registry != nil && t.Agent.Type != "gemini" {
+ 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 err := writeMCPConfig(workspace, mcpURL, token); err != nil {
+ return fmt.Errorf("writing mcp config: %w", err)
+ }
+ mcpEnabled = true
+ }
+
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...)
@@ -365,7 +385,17 @@ func (r *ContainerRunner) runContainer(ctx context.Context, t *task.Task, e *sto
e.SessionID = sessionID
}
- // Check whether the agent left a question before exiting.
+ // 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)
+ }
+ return &BlockedError{QuestionJSON: q, SessionID: e.SessionID, SandboxDir: workspace}
+ }
+
+ // Check whether the agent left a question before exiting (file fallback for
+ // in-flight tasks started on the pre-MCP wire).
questionFile := filepath.Join(logDir, "question.json")
if data, readErr := os.ReadFile(questionFile); readErr == nil {
os.Remove(questionFile) // consumed
@@ -467,7 +497,30 @@ 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"
+
+// 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)
+}
+
+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.
@@ -491,6 +544,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()}
@@ -501,6 +557,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",
}