summaryrefslogtreecommitdiff
path: root/cmd/spike/main.go
blob: c2f3f8d7320ce431aa1fae79ed406c2b9e07ca64 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
// 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"
	"github.com/thepeterstone/claudomator/internal/role"
)

type recordingChannel struct {
	mu         sync.Mutex
	asks       []string
	summary    string
	progress   []string
	subtasks   []executor.SubtaskSpec
	epics      []executor.EpicProposal
	roleConfig []role.RoleConfig
}

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 (c *recordingChannel) ProposeEpic(_ context.Context, spec executor.EpicProposal) (string, error) {
	c.mu.Lock()
	c.epics = append(c.epics, spec)
	c.mu.Unlock()
	return "spike-epic-1", nil
}
func (c *recordingChannel) ProposeRoleConfig(_ context.Context, cfg role.RoleConfig) (int, error) {
	c.mu.Lock()
	c.roleConfig = append(c.roleConfig, cfg)
	c.mu.Unlock()
	return 1, nil
}
func (c *recordingChannel) ReportVerdict(_ context.Context, approved bool, reasoning string) error {
	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:]
}