diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-06-03 23:01:39 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-06-03 23:01:39 +0000 |
| commit | 68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch) | |
| tree | 14b73f21570a2f42ae729ca7e9676de6628d6819 /cmd | |
| parent | b28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff) | |
| parent | 7388337d3be5cc7f65ef547e30b2e39884dd165b (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 'cmd')
| -rw-r--r-- | cmd/spike/main.go | 165 |
1 files changed, 165 insertions, 0 deletions
diff --git a/cmd/spike/main.go b/cmd/spike/main.go new file mode 100644 index 0000000..dd87021 --- /dev/null +++ b/cmd/spike/main.go @@ -0,0 +1,165 @@ +// Command spike is a MANUAL validation harness (never run by tests/CI; it +// spawns a real agent CLI and makes paid API calls). It validates the agent MCP +// back-channel against a real binary without Docker: it starts the agent MCP +// server on the host, points the chosen agent CLI at it, and reports whether the +// agent's tool calls reached the AgentChannel. +// +// Usage: +// +// go run ./cmd/spike # claude (default) +// go run ./cmd/spike gemini # gemini (requires a `gemini` binary) +// +// FINDINGS (2026-05, claude 2.1.150 / claude-sonnet-4-6): +// - PASS: claude discovered the server via --mcp-config (streamable HTTP + +// bearer) and called record_progress + report_summary; both reached the +// AgentChannel with zero permission denials (~$0.11/run). +// - PASS: claude called ask_user, received the ErrAgentBlocked "end your turn" +// response, and ended its turn cleanly (the human-in-the-loop path). +// - PRODUCTION NOTE: `claude --permission-mode bypassPermissions` (what +// ContainerRunner.buildInnerCmd emits) is REJECTED when the process is root. +// The in-container agent must run as a non-root user, or tools must be +// pre-allowed via --allowedTools. Worth verifying the container image's user. +// - GEMINI: not validated — no `gemini` binary was available. The gemini path +// below writes the same mcpServers/httpUrl settings the production +// ContainerRunner does; run `go run ./cmd/spike gemini` where a binary exists. +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/thepeterstone/claudomator/internal/executor" +) + +type recordingChannel struct { + mu sync.Mutex + asks []string + summary string + progress []string + subtasks []executor.SubtaskSpec +} + +func (c *recordingChannel) AskUser(_ context.Context, q string) (string, error) { + c.mu.Lock() + c.asks = append(c.asks, q) + c.mu.Unlock() + return "", executor.ErrAgentBlocked // mirror production storeChannel +} +func (c *recordingChannel) ReportSummary(_ context.Context, s string) error { + c.mu.Lock() + c.summary = s + c.mu.Unlock() + return nil +} +func (c *recordingChannel) SpawnSubtask(_ context.Context, spec executor.SubtaskSpec) (string, error) { + c.mu.Lock() + c.subtasks = append(c.subtasks, spec) + c.mu.Unlock() + return "spike-subtask-1", nil +} +func (c *recordingChannel) RecordProgress(_ context.Context, m string) error { + c.mu.Lock() + c.progress = append(c.progress, m) + c.mu.Unlock() + return nil +} + +func main() { + agent := "claude" + if len(os.Args) > 1 { + agent = os.Args[1] + } + + rec := &recordingChannel{} + reg := executor.NewRegistry() + token, err := reg.Mint(rec) + if err != nil { + fmt.Println("mint:", err) + os.Exit(1) + } + + mux := http.NewServeMux() + mux.Handle("/mcp", executor.NewAgentMCPHandler(reg)) + srv := httptest.NewServer(mux) + defer srv.Close() + mcpURL := srv.URL + "/mcp" + + prompt := "You have MCP tools from the 'claudomator' server. Do exactly this and nothing else: " + + "(1) call record_progress with message 'spike: starting'; " + + "(2) call report_summary with summary 'spike validation complete'; " + + "then stop. Do not edit files or run shell commands." + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) + defer cancel() + + var cmd *exec.Cmd + switch agent { + case "claude": + cfg := map[string]any{"mcpServers": map[string]any{"claudomator": map[string]any{ + "type": "http", "url": mcpURL, "headers": map[string]string{"Authorization": "Bearer " + token}, + }}} + data, _ := json.Marshal(cfg) + cfgPath := "/tmp/spike-claude-mcp.json" + _ = os.WriteFile(cfgPath, data, 0600) + allowed := "mcp__claudomator__record_progress,mcp__claudomator__report_summary,mcp__claudomator__ask_user,mcp__claudomator__spawn_subtask" + cmd = exec.CommandContext(ctx, "claude", "-p", prompt, + "--mcp-config", cfgPath, "--allowedTools", allowed, + "--output-format", "stream-json", "--verbose") + case "gemini": + home, _ := os.MkdirTemp("", "spike-gemini-home-*") + geminiDir := filepath.Join(home, ".gemini") + _ = os.MkdirAll(geminiDir, 0755) + cfg := map[string]any{"mcpServers": map[string]any{"claudomator": map[string]any{ + "httpUrl": mcpURL, "headers": map[string]string{"Authorization": "Bearer " + token}, + }}} + data, _ := json.Marshal(cfg) + _ = os.WriteFile(filepath.Join(geminiDir, "settings.json"), data, 0600) + cmd = exec.CommandContext(ctx, "gemini", "-p", prompt) + cmd.Env = append(os.Environ(), "HOME="+home) + default: + fmt.Println("unknown agent:", agent) + os.Exit(1) + } + + var out, errb strings.Builder + cmd.Stdout = &out + cmd.Stderr = &errb + start := time.Now() + runErr := cmd.Run() + + fmt.Printf("=== %s exit: err=%v elapsed=%s ===\n", agent, runErr, time.Since(start).Round(time.Millisecond)) + fmt.Println("--- stderr (tail) ---") + fmt.Println(tail(errb.String(), 1200)) + fmt.Println("--- stdout (tail) ---") + fmt.Println(tail(out.String(), 1500)) + + rec.mu.Lock() + defer rec.mu.Unlock() + fmt.Println("=== AgentChannel received ===") + fmt.Printf("record_progress: %d %v\n", len(rec.progress), rec.progress) + fmt.Printf("report_summary: %q\n", rec.summary) + fmt.Printf("ask_user: %d\n", len(rec.asks)) + fmt.Printf("spawn_subtask: %d\n", len(rec.subtasks)) + + if len(rec.progress) > 0 && rec.summary != "" { + fmt.Println("RESULT: PASS — the agent discovered and called the MCP tools; calls reached the AgentChannel.") + } else { + fmt.Println("RESULT: INCONCLUSIVE — see output above.") + } +} + +func tail(s string, n int) string { + if len(s) <= n { + return s + } + return "…" + s[len(s)-n:] +} |
