Skip to content

Commit 7e6e117

Browse files
authored
Diagnose with AI for Radar OSS (local, keyless, BYO-agent) (#947)
1 parent c602ec1 commit 7e6e117

47 files changed

Lines changed: 7649 additions & 102 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/ai/agent.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package ai
2+
3+
import (
4+
"context"
5+
"io"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
)
10+
11+
// Agent abstracts a coding CLI radar drives for AI diagnosis. Each backend knows
12+
// how to spawn its CLI for one turn (flags, MCP wiring, env, cwd) and how to parse
13+
// that CLI's event stream into radar's normalized StreamEvents + final Diagnosis.
14+
// The generic run loop (process group, stdout pipe, lifecycle) lives in Diagnoser.
15+
type Agent interface {
16+
// Name is the stable backend identifier ("claude", "codex").
17+
Name() string
18+
// command builds the fully-configured *exec.Cmd for one turn (bin, args, env,
19+
// cwd) plus a cleanup for any temp files it created.
20+
command(ctx context.Context, s turnSpec) (*exec.Cmd, func(), error)
21+
// parseStream consumes the CLI's stdout, emits normalized events, and returns
22+
// the assembled Diagnosis (including the resumable session id).
23+
parseStream(r io.Reader, onEvent func(StreamEvent)) Diagnosis
24+
}
25+
26+
// turnSpec is everything an Agent needs to build one turn, independent of CLI.
27+
type turnSpec struct {
28+
mcpURL string // radar MCP endpoint (read-only or full) to point the agent at
29+
prompt string // the user/turn prompt
30+
systemPrompt string // SRE+security framing; set only on the first turn (empty on resume)
31+
sessionID string // resume target; empty means a fresh session
32+
apply bool // user-confirmed remediation turn (write tools allowed)
33+
isolated bool // run without the user's own CLI config (Codex only)
34+
model string // optional model override; empty = the CLI's default
35+
effort string // optional reasoning effort (Codex only); empty = default
36+
maxTurns int
37+
// workdir is a stable per-RUN scratch directory (same across the run's turns).
38+
// Cursor needs it: its --resume is workspace-scoped, so follow-ups must run in
39+
// the same workspace as the first turn. Empty for one-shot (non-RunManager) use.
40+
workdir string
41+
}
42+
43+
// resolveAgent picks a backend from the CLI binary name (e.g. RADAR_AI_CLI_BIN or
44+
// the detected CLI): "cursor-agent" → Cursor, "codex" → Codex, else → Claude.
45+
func resolveAgent(bin string) Agent {
46+
base := strings.ToLower(filepath.Base(bin))
47+
switch {
48+
case strings.Contains(base, "cursor"):
49+
return &cursorAgent{bin: bin}
50+
case strings.Contains(base, "codex"):
51+
return &codexAgent{bin: bin}
52+
default:
53+
return &claudeAgent{bin: bin}
54+
}
55+
}

internal/ai/agent_claude.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package ai
2+
3+
import (
4+
"context"
5+
"io"
6+
"os"
7+
"os/exec"
8+
"strconv"
9+
)
10+
11+
// claudeAgent drives Claude Code. Containment is via CLI flags: --tools "" disables
12+
// ALL built-in tools (no Bash/WebFetch), and --allowedTools restricts MCP usage to
13+
// radar's read tools (plus write tools on a confirmed apply turn). The read-only
14+
// MCP mount enforces the same server-side, so the allowlist is defense-in-depth.
15+
type claudeAgent struct{ bin string }
16+
17+
func (a *claudeAgent) Name() string { return "claude" }
18+
19+
func (a *claudeAgent) command(ctx context.Context, s turnSpec) (*exec.Cmd, func(), error) {
20+
cfgPath, cleanup, err := writeMCPConfig(s.mcpURL)
21+
if err != nil {
22+
return nil, nil, err
23+
}
24+
25+
args := []string{
26+
"-p", s.prompt,
27+
"--mcp-config", cfgPath, "--strict-mcp-config",
28+
"--tools", "", // disable all built-in tools — cluster access is MCP-only
29+
"--allowedTools",
30+
}
31+
for _, t := range radarReadTools {
32+
args = append(args, "mcp__radar__"+t)
33+
}
34+
if s.apply {
35+
for _, t := range radarWriteTools {
36+
args = append(args, "mcp__radar__"+t)
37+
}
38+
}
39+
args = append(args,
40+
"--permission-mode", "acceptEdits",
41+
"--max-turns", strconv.Itoa(s.maxTurns),
42+
"--output-format", "stream-json", "--verbose",
43+
)
44+
if s.model != "" {
45+
args = append(args, "--model", s.model) // Claude has no separate effort knob
46+
}
47+
if s.sessionID != "" {
48+
args = append(args, "--resume", s.sessionID)
49+
} else {
50+
args = append(args, "--append-system-prompt", s.systemPrompt)
51+
}
52+
53+
cmd := exec.CommandContext(ctx, a.bin, args...)
54+
cmd.Env = scrubbedEnv()
55+
// Run from the user's home dir so the session is stored under a stable,
56+
// predictable project path: Claude Code's `--resume <id>` is cwd-scoped, and
57+
// the "Open in Claude Code" hand-off resumes from a home-dir terminal. Claude's
58+
// built-in tools are disabled (--tools ""), so cwd doesn't widen its access.
59+
if home, err := os.UserHomeDir(); err == nil {
60+
cmd.Dir = home
61+
}
62+
return cmd, cleanup, nil
63+
}
64+
65+
func (a *claudeAgent) parseStream(r io.Reader, onEvent func(StreamEvent)) Diagnosis {
66+
return parseStream(r, onEvent)
67+
}

internal/ai/agent_codex.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package ai
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"os"
11+
"os/exec"
12+
"strings"
13+
)
14+
15+
// codexAgent drives the Codex CLI (`codex exec`). Codex has no per-MCP-tool
16+
// allowlist and its shell can't be disabled, so containment differs from Claude:
17+
// - cluster access is gated by the read-only MCP MOUNT (radar-side), not flags;
18+
// - --sandbox read-only blocks the model's shell from network + filesystem
19+
// writes (verified: no loopback, no kubectl), though it can still READ local
20+
// files into context — disclosed to the user;
21+
// - --ignore-user-config keeps the user's other MCP servers out of an isolated
22+
// run; cmd.Dir is an empty temp dir so it doesn't read the launch directory.
23+
type codexAgent struct{ bin string }
24+
25+
func (a *codexAgent) Name() string { return "codex" }
26+
27+
func (a *codexAgent) command(ctx context.Context, s turnSpec) (*exec.Cmd, func(), error) {
28+
// Codex has no system-prompt flag; the framing rides on the first turn's
29+
// prompt (the resumed session already carries it).
30+
prompt := s.prompt
31+
if s.systemPrompt != "" {
32+
prompt = s.systemPrompt + "\n\n" + prompt
33+
}
34+
mcpCfg := fmt.Sprintf("mcp_servers.radar.url=%q", s.mcpURL)
35+
36+
// Always start from the base flags; isolation adds --ignore-user-config, an
37+
// empty cwd, and a minimal env. "My setup" keeps the user's config (their other
38+
// MCP servers, guidelines), their full env, and their home cwd. The shell stays
39+
// --sandbox read-only in BOTH modes — but the "cluster writes go only through
40+
// Radar's read-only MCP" containment holds ONLY when isolated. In "my setup"
41+
// the agent also gets the user's own MCP servers (possibly write/network/cloud
42+
// capable) + local file reads: a deliberate trusted mode, not a contained one.
43+
base := []string{"--json", "--skip-git-repo-check", "-c", mcpCfg}
44+
// Emit reasoning summaries so the UI can show the model's thinking between tool
45+
// calls (off by default — without this the stream is only tool calls + the final
46+
// message). The Codex parser maps these `reasoning` items to thinking events.
47+
base = append(base, "-c", `model_reasoning_summary="auto"`)
48+
// Disable Codex's built-in web_search tool: it reaches the public internet
49+
// (server-side, so --sandbox can't stop it), which breaks the "contained, reads
50+
// only this cluster" guarantee. The diagnosis needs cluster data via MCP, not the
51+
// web. (image_gen is also built in but is inert for k8s and can't be disabled.)
52+
base = append(base, "-c", `web_search="disabled"`)
53+
if s.isolated {
54+
base = append(base, "--ignore-user-config")
55+
}
56+
if s.model != "" {
57+
base = append(base, "-m", s.model)
58+
}
59+
// Default to medium reasoning effort (not Codex's bare default) — it gives a
60+
// solid investigation AND is the level at which Codex actually emits reasoning
61+
// summaries under --ignore-user-config, so the UI can show the model's thinking.
62+
effort := s.effort
63+
if effort == "" {
64+
effort = "medium"
65+
}
66+
// Codex's always-on image_gen tool is rejected by the API at "minimal" effort
67+
// (400), and it can't be disabled — so clamp minimal up to low.
68+
if effort == "minimal" {
69+
effort = "low"
70+
}
71+
base = append(base, "-c", fmt.Sprintf("model_reasoning_effort=%q", effort))
72+
73+
var args []string
74+
if s.sessionID != "" {
75+
// resume lacks --sandbox; set sandbox via -c (cwd via cmd.Dir below).
76+
args = append([]string{"exec", "resume"}, base...)
77+
args = append(args, "-c", `sandbox_mode="read-only"`, s.sessionID, prompt)
78+
} else {
79+
args = append([]string{"exec"}, base...)
80+
args = append(args, "--sandbox", "read-only", prompt)
81+
}
82+
83+
cmd := exec.CommandContext(ctx, a.bin, args...)
84+
85+
cleanup := func() {}
86+
if s.isolated {
87+
// Empty working dir so the model's shell can't read radar's source / cwd.
88+
dir, err := os.MkdirTemp("", "radar-codex-")
89+
if err != nil {
90+
return nil, nil, fmt.Errorf("ai: codex workdir: %w", err)
91+
}
92+
cleanup = func() { _ = os.RemoveAll(dir) }
93+
cmd.Dir = dir
94+
cmd.Env = codexEnv()
95+
}
96+
// "My setup": inherit radar's cwd + full env so the user's auth/config/MCPs work.
97+
98+
return cmd, cleanup, nil
99+
}
100+
101+
// codexEnv is the minimal environment Codex needs (auth via HOME/CODEX_HOME, PATH
102+
// to exec, locale). Cloud-provider secrets are deliberately omitted — the shell
103+
// can read env into context, and the agent reaches the cluster only via MCP.
104+
func codexEnv() []string {
105+
keep := map[string]bool{
106+
"HOME": true, "PATH": true, "CODEX_HOME": true, "TMPDIR": true,
107+
"TERM": true, "LANG": true, "USER": true, "LOGNAME": true, "SHELL": true,
108+
}
109+
var out []string
110+
for _, kv := range os.Environ() {
111+
k, _, ok := strings.Cut(kv, "=")
112+
if !ok {
113+
continue
114+
}
115+
if keep[k] || strings.HasPrefix(k, "LC_") {
116+
out = append(out, kv)
117+
}
118+
}
119+
return out
120+
}
121+
122+
// Codex JSONL event shapes (codex exec --json). Only the fields we consume.
123+
type codexEvent struct {
124+
Type string `json:"type"`
125+
ThreadID string `json:"thread_id"`
126+
Item *codexItem `json:"item"`
127+
}
128+
129+
type codexItem struct {
130+
ID string `json:"id"`
131+
Type string `json:"type"` // mcp_tool_call | agent_message | reasoning | ...
132+
Text string `json:"text"`
133+
Tool string `json:"tool"`
134+
Arguments json.RawMessage `json:"arguments"`
135+
Result *struct {
136+
Content []struct {
137+
Text string `json:"text"`
138+
} `json:"content"`
139+
} `json:"result"`
140+
}
141+
142+
func (a *codexAgent) parseStream(r io.Reader, onEvent func(StreamEvent)) Diagnosis {
143+
sc := bufio.NewScanner(r)
144+
sc.Buffer(make([]byte, 0, 64*1024), 4<<20)
145+
var sessionID string
146+
var answer strings.Builder
147+
148+
for sc.Scan() {
149+
line := bytes.TrimSpace(sc.Bytes())
150+
if len(line) == 0 || line[0] != '{' {
151+
continue
152+
}
153+
var e codexEvent
154+
if json.Unmarshal(line, &e) != nil {
155+
continue
156+
}
157+
switch e.Type {
158+
case "thread.started":
159+
if e.ThreadID != "" {
160+
sessionID = e.ThreadID
161+
}
162+
case "item.started":
163+
if e.Item != nil && e.Item.Type == "mcp_tool_call" {
164+
onEvent(StreamEvent{Type: "step", Step: &StepInfo{
165+
ID: e.Item.ID, Tool: e.Item.Tool, Status: "running",
166+
Summary: codexArgsText(e.Item.Arguments),
167+
}})
168+
}
169+
case "item.completed":
170+
if e.Item == nil {
171+
continue
172+
}
173+
switch e.Item.Type {
174+
case "mcp_tool_call":
175+
res, trunc := capPayload(codexResultText(e.Item))
176+
onEvent(StreamEvent{Type: "step", Step: &StepInfo{
177+
ID: e.Item.ID, Tool: e.Item.Tool, Status: "done",
178+
Result: res, Truncated: trunc,
179+
}})
180+
case "reasoning":
181+
if e.Item.Text != "" {
182+
onEvent(StreamEvent{Type: "thinking", Token: e.Item.Text})
183+
}
184+
case "agent_message":
185+
if e.Item.Text != "" {
186+
answer.WriteString(e.Item.Text)
187+
answer.WriteString("\n")
188+
}
189+
}
190+
}
191+
}
192+
193+
d := diagnosisFromText(answer.String())
194+
d.SessionID = sessionID
195+
return d
196+
}
197+
198+
func codexArgsText(raw json.RawMessage) string {
199+
s := strings.TrimSpace(string(raw))
200+
if s == "" || s == "null" || s == "{}" {
201+
return ""
202+
}
203+
args, _ := capPayload(s)
204+
return args
205+
}
206+
207+
// codexResultText joins the text parts of a Codex mcp_tool_call result (already
208+
// the unwrapped content[].text shape). Capping happens at the call site so the
209+
// truncated flag can be surfaced.
210+
func codexResultText(it *codexItem) string {
211+
if it.Result == nil {
212+
return ""
213+
}
214+
var b strings.Builder
215+
for _, c := range it.Result.Content {
216+
b.WriteString(c.Text)
217+
}
218+
return b.String()
219+
}

0 commit comments

Comments
 (0)