mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
The harness runs Claude Code in print mode, where AskUserQuestion and the plan mode tools are disabled: Claude Code asked its questions in plain text and ended the turn. For each turn, the Claude Code driver now serves a permission_prompt tool from an MCP server on loopback and hands it to the CLI as its permission prompt tool. Claude Code then offers AskUserQuestion and plan mode, and consults the tool before AskUserQuestion and ExitPlanMode only. The driver turns them into elicitation forms and answers with the user's choices, or with a denial when the form is declined or dismissed. The server requires a bearer token kept in a 0600 MCP config file, sets a 24 hour tool timeout (Claude Code otherwise abandons an HTTP tool call after 60 seconds), and ends open questions with the turn. Through the new optional harness.Eliciter capability, the runtime passes an elicitation handler only when someone can answer: serve mcp, A2A and background agents keep plain text questions. Signed-off-by: Sayt-0 <louis.dal-moro-compta@epita.fr>
224 lines
7.4 KiB
Go
224 lines
7.4 KiB
Go
package codingharness
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
baseharness "github.com/rumpl/harness"
|
|
|
|
"github.com/docker/docker-agent/pkg/atomicfile"
|
|
"github.com/docker/docker-agent/pkg/harness"
|
|
"github.com/docker/docker-agent/pkg/tools"
|
|
"github.com/docker/docker-agent/pkg/version"
|
|
)
|
|
|
|
const (
|
|
// Claude Code consults this MCP tool, through --permission-prompt-tool,
|
|
// before a tool that waits for the user. The flag is also what enables
|
|
// AskUserQuestion and the plan mode tools under --print.
|
|
claudePermissionServerName = "docker-agent-harness"
|
|
claudePermissionToolName = "permission_prompt"
|
|
|
|
// claudePermissionTimeout is how long a question may wait for the user:
|
|
// without it, Claude Code abandons an HTTP tool call after 60 seconds.
|
|
claudePermissionTimeout = 24 * time.Hour
|
|
)
|
|
|
|
// WithElicitation returns a provider whose turns put Claude Code's questions
|
|
// and plan approvals to the user through elicit.
|
|
func (p claudeCodeProvider) WithElicitation(elicit tools.ElicitationHandler) harness.Provider {
|
|
p.elicit = elicit
|
|
return p
|
|
}
|
|
|
|
func (p claudeCodeProvider) Run(ctx context.Context, prompt string, handle func(harness.Event)) error {
|
|
return p.withPermissionPrompts(ctx, func(cli baseharness.Provider) error {
|
|
return baseharness.Run(ctx, cli, prompt, adapt(handle))
|
|
})
|
|
}
|
|
|
|
func (p claudeCodeProvider) Resume(ctx context.Context, sessionID, prompt string, handle func(harness.Event)) error {
|
|
return p.withPermissionPrompts(ctx, func(cli baseharness.Provider) error {
|
|
return baseharness.Resume(ctx, cli, sessionID, prompt, adapt(handle))
|
|
})
|
|
}
|
|
|
|
// withPermissionPrompts runs one turn with the permission prompt server up.
|
|
// When it cannot start, the turn still runs: Claude Code then asks in plain
|
|
// text, as it does without elicitation.
|
|
func (p claudeCodeProvider) withPermissionPrompts(ctx context.Context, run func(baseharness.Provider) error) error {
|
|
cli, ok := p.Provider.(baseharness.ResumableCommandProvider)
|
|
if p.elicit == nil || !ok {
|
|
return run(p.Provider)
|
|
}
|
|
server, err := startClaudePermissionServer(ctx, p.elicit)
|
|
if err != nil {
|
|
slog.WarnContext(ctx, "Claude Code questions will be asked in plain text: the permission prompt server did not start", "error", err)
|
|
return run(p.Provider)
|
|
}
|
|
defer server.Close()
|
|
return run(claudePermissionArgs{ResumableCommandProvider: cli, flags: server.flags})
|
|
}
|
|
|
|
// claudePermissionArgs adds the permission prompt flags to the upstream
|
|
// command, so its hardened runner still drives the CLI.
|
|
type claudePermissionArgs struct {
|
|
baseharness.ResumableCommandProvider
|
|
|
|
flags []string
|
|
}
|
|
|
|
func (a claudePermissionArgs) PrintArgs(prompt string) []string {
|
|
return withClaudeFlags(a.ResumableCommandProvider.PrintArgs(prompt), a.flags)
|
|
}
|
|
|
|
func (a claudePermissionArgs) ResumeArgs(sessionID, prompt string) []string {
|
|
return withClaudeFlags(a.ResumableCommandProvider.ResumeArgs(sessionID, prompt), a.flags)
|
|
}
|
|
|
|
// withClaudeFlags puts flags right after the binary, ahead of the prompt.
|
|
func withClaudeFlags(args, flags []string) []string {
|
|
if len(args) == 0 {
|
|
return args
|
|
}
|
|
return slices.Concat(args[:1], flags, args[1:])
|
|
}
|
|
|
|
// claudePermissionServer serves the permission prompt tool on loopback for
|
|
// one turn. A bearer token keeps other local processes from reaching it; it
|
|
// lives in the MCP config file (0600, in a 0700 directory) rather than on
|
|
// the command line, which other users can read.
|
|
type claudePermissionServer struct {
|
|
server *http.Server
|
|
cancel context.CancelFunc
|
|
dir string
|
|
flags []string
|
|
}
|
|
|
|
func startClaudePermissionServer(ctx context.Context, elicit tools.ElicitationHandler) (*claudePermissionServer, error) {
|
|
var lc net.ListenConfig
|
|
listener, err := lc.Listen(ctx, "tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token := rand.Text()
|
|
// Close cancels it, so a question still open when the CLI exits ends
|
|
// with the turn.
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
|
|
mcpServer := mcp.NewServer(&mcp.Implementation{Name: claudePermissionServerName, Version: version.Version}, &mcp.ServerOptions{
|
|
Capabilities: &mcp.ServerCapabilities{},
|
|
})
|
|
mcpServer.AddTool(&mcp.Tool{
|
|
Name: claudePermissionToolName,
|
|
Description: "Asks the docker-agent user before a Claude Code tool that needs them.",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"tool_name": map[string]any{"type": "string"},
|
|
"input": map[string]any{"type": "object"},
|
|
"tool_use_id": map[string]any{"type": "string"},
|
|
},
|
|
"required": []any{"tool_name", "input"},
|
|
},
|
|
}, claudePermissionHandler(ctx, elicit))
|
|
|
|
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return mcpServer }, &mcp.StreamableHTTPOptions{Stateless: true})
|
|
server := &http.Server{
|
|
Handler: requireBearerToken(token, handler),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
}
|
|
go func() {
|
|
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
slog.WarnContext(ctx, "Claude Code permission prompt server stopped", "error", err)
|
|
}
|
|
}()
|
|
|
|
s := &claudePermissionServer{server: server, cancel: cancel}
|
|
s.dir, err = os.MkdirTemp("", "docker-agent-claude-")
|
|
if err != nil {
|
|
s.Close()
|
|
return nil, err
|
|
}
|
|
config, err := json.Marshal(map[string]any{
|
|
"mcpServers": map[string]any{
|
|
claudePermissionServerName: map[string]any{
|
|
"type": "http",
|
|
"url": "http://" + listener.Addr().String() + "/mcp",
|
|
"headers": map[string]string{"Authorization": "Bearer " + token},
|
|
"timeout": claudePermissionTimeout.Milliseconds(),
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
s.Close()
|
|
return nil, err
|
|
}
|
|
configPath := filepath.Join(s.dir, "mcp.json")
|
|
if err := atomicfile.Write(configPath, bytes.NewReader(config), 0o600); err != nil {
|
|
s.Close()
|
|
return nil, err
|
|
}
|
|
s.flags = []string{
|
|
"--mcp-config", configPath,
|
|
"--permission-prompt-tool", "mcp__" + claudePermissionServerName + "__" + claudePermissionToolName,
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *claudePermissionServer) Close() {
|
|
s.cancel()
|
|
_ = s.server.Close()
|
|
if s.dir != "" {
|
|
_ = os.RemoveAll(s.dir)
|
|
}
|
|
}
|
|
|
|
func requireBearerToken(token string, next http.Handler) http.Handler {
|
|
want := []byte("Bearer " + token)
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), want) != 1 {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// claudePermissionHandler answers Claude Code's permission prompts. The
|
|
// elicitation runs under the turn's context, whose values the runtime reads,
|
|
// and stops early if Claude Code drops the request.
|
|
func claudePermissionHandler(turnCtx context.Context, elicit tools.ElicitationHandler) mcp.ToolHandler {
|
|
return func(reqCtx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
var call struct {
|
|
ToolName string `json:"tool_name"`
|
|
Input map[string]any `json:"input"`
|
|
}
|
|
if err := json.Unmarshal(req.Params.Arguments, &call); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(turnCtx)
|
|
defer cancel()
|
|
defer context.AfterFunc(reqCtx, cancel)()
|
|
|
|
decision, err := json.Marshal(decideClaudePermission(ctx, elicit, call.ToolName, call.Input))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(decision)}}}, nil
|
|
}
|
|
}
|