-
Notifications
You must be signed in to change notification settings - Fork 137
Diagnose with AI for Radar OSS (local, keyless, BYO-agent) #947
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nadaverell
wants to merge
15
commits into
main
Choose a base branch
from
feature/radar-opensre-diagnose
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
efe8ff9
Diagnose with AI for Radar OSS (local, keyless, BYO-agent)
nadaverell 3e5d431
Diagnose: product-review fixes (correctness + honesty + legibility)
nadaverell 2aae1d5
Diagnose: inconclusive verdict, verified apply, GitOps-apply gate, tr…
nadaverell 110eba3
Diagnose: polish — Apply icon, resize handle, settings footer, status…
nadaverell 40ac22e
Diagnose: point GitOps-apply TODO at Linear SKY-1075
nadaverell 5b4cb52
Diagnose: drop redundant double-negation (eslint no-extra-boolean-cast)
nadaverell c737f83
Diagnose: resize handle uses aria-label, not native title (lint: no-n…
nadaverell eb4dbf4
Add Cursor agent backend for AI diagnosis
nadaverell 30762c3
AI panel: dock under navbar, push content only, tone drawer shadow
nadaverell 964c949
Unify AI panel + drawers into one app-shell body frame
nadaverell 57314e3
Diagnose: fix codex containment/effort, gone-run state, running indic…
nadaverell 23670d3
Diagnose: maximized is a decision-first 2-pane workspace
nadaverell 2050f42
Diagnose: skip the staged-reveal choreography when replaying a finish…
nadaverell ac2ec95
Diagnose: always show the investigations list in the maximized workspace
nadaverell 697a890
Diagnose: review-loop fixes — readonly-MCP OAuth shim, honest failure…
nadaverell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| package ai | ||
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| "os/exec" | ||
| "path/filepath" | ||
| "strings" | ||
| ) | ||
|
|
||
| // Agent abstracts a coding CLI radar drives for AI diagnosis. Each backend knows | ||
| // how to spawn its CLI for one turn (flags, MCP wiring, env, cwd) and how to parse | ||
| // that CLI's event stream into radar's normalized StreamEvents + final Diagnosis. | ||
| // The generic run loop (process group, stdout pipe, lifecycle) lives in Diagnoser. | ||
| type Agent interface { | ||
| // Name is the stable backend identifier ("claude", "codex"). | ||
| Name() string | ||
| // command builds the fully-configured *exec.Cmd for one turn (bin, args, env, | ||
| // cwd) plus a cleanup for any temp files it created. | ||
| command(ctx context.Context, s turnSpec) (*exec.Cmd, func(), error) | ||
| // parseStream consumes the CLI's stdout, emits normalized events, and returns | ||
| // the assembled Diagnosis (including the resumable session id). | ||
| parseStream(r io.Reader, onEvent func(StreamEvent)) Diagnosis | ||
| } | ||
|
|
||
| // turnSpec is everything an Agent needs to build one turn, independent of CLI. | ||
| type turnSpec struct { | ||
| mcpURL string // radar MCP endpoint (read-only or full) to point the agent at | ||
| prompt string // the user/turn prompt | ||
| systemPrompt string // SRE+security framing; set only on the first turn (empty on resume) | ||
| sessionID string // resume target; empty means a fresh session | ||
| apply bool // user-confirmed remediation turn (write tools allowed) | ||
| isolated bool // run without the user's own CLI config (Codex only) | ||
| model string // optional model override; empty = the CLI's default | ||
| effort string // optional reasoning effort (Codex only); empty = default | ||
| maxTurns int | ||
| // workdir is a stable per-RUN scratch directory (same across the run's turns). | ||
| // Cursor needs it: its --resume is workspace-scoped, so follow-ups must run in | ||
| // the same workspace as the first turn. Empty for one-shot (non-RunManager) use. | ||
| workdir string | ||
| } | ||
|
|
||
| // resolveAgent picks a backend from the CLI binary name (e.g. RADAR_AI_CLI_BIN or | ||
| // the detected CLI): "cursor-agent" → Cursor, "codex" → Codex, else → Claude. | ||
| func resolveAgent(bin string) Agent { | ||
| base := strings.ToLower(filepath.Base(bin)) | ||
| switch { | ||
| case strings.Contains(base, "cursor"): | ||
| return &cursorAgent{bin: bin} | ||
| case strings.Contains(base, "codex"): | ||
| return &codexAgent{bin: bin} | ||
| default: | ||
| return &claudeAgent{bin: bin} | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| package ai | ||
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| "os" | ||
| "os/exec" | ||
| "strconv" | ||
| ) | ||
|
|
||
| // claudeAgent drives Claude Code. Containment is via CLI flags: --tools "" disables | ||
| // ALL built-in tools (no Bash/WebFetch), and --allowedTools restricts MCP usage to | ||
| // radar's read tools (plus write tools on a confirmed apply turn). The read-only | ||
| // MCP mount enforces the same server-side, so the allowlist is defense-in-depth. | ||
| type claudeAgent struct{ bin string } | ||
|
|
||
| func (a *claudeAgent) Name() string { return "claude" } | ||
|
|
||
| func (a *claudeAgent) command(ctx context.Context, s turnSpec) (*exec.Cmd, func(), error) { | ||
| cfgPath, cleanup, err := writeMCPConfig(s.mcpURL) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
|
|
||
| args := []string{ | ||
| "-p", s.prompt, | ||
| "--mcp-config", cfgPath, "--strict-mcp-config", | ||
| "--tools", "", // disable all built-in tools — cluster access is MCP-only | ||
| "--allowedTools", | ||
| } | ||
| for _, t := range radarReadTools { | ||
| args = append(args, "mcp__radar__"+t) | ||
| } | ||
| if s.apply { | ||
| for _, t := range radarWriteTools { | ||
| args = append(args, "mcp__radar__"+t) | ||
| } | ||
| } | ||
| args = append(args, | ||
| "--permission-mode", "acceptEdits", | ||
| "--max-turns", strconv.Itoa(s.maxTurns), | ||
| "--output-format", "stream-json", "--verbose", | ||
| ) | ||
| if s.model != "" { | ||
| args = append(args, "--model", s.model) // Claude has no separate effort knob | ||
| } | ||
| if s.sessionID != "" { | ||
| args = append(args, "--resume", s.sessionID) | ||
| } else { | ||
| args = append(args, "--append-system-prompt", s.systemPrompt) | ||
| } | ||
|
|
||
| cmd := exec.CommandContext(ctx, a.bin, args...) | ||
| cmd.Env = scrubbedEnv() | ||
| // Run from the user's home dir so the session is stored under a stable, | ||
| // predictable project path: Claude Code's `--resume <id>` is cwd-scoped, and | ||
| // the "Open in Claude Code" hand-off resumes from a home-dir terminal. Claude's | ||
| // built-in tools are disabled (--tools ""), so cwd doesn't widen its access. | ||
| if home, err := os.UserHomeDir(); err == nil { | ||
| cmd.Dir = home | ||
| } | ||
| return cmd, cleanup, nil | ||
| } | ||
|
|
||
| func (a *claudeAgent) parseStream(r io.Reader, onEvent func(StreamEvent)) Diagnosis { | ||
| return parseStream(r, onEvent) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| package ai | ||
|
|
||
| import ( | ||
| "bufio" | ||
| "bytes" | ||
| "context" | ||
| "encoding/json" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "os/exec" | ||
| "strings" | ||
| ) | ||
|
|
||
| // codexAgent drives the Codex CLI (`codex exec`). Codex has no per-MCP-tool | ||
| // allowlist and its shell can't be disabled, so containment differs from Claude: | ||
| // - cluster access is gated by the read-only MCP MOUNT (radar-side), not flags; | ||
| // - --sandbox read-only blocks the model's shell from network + filesystem | ||
| // writes (verified: no loopback, no kubectl), though it can still READ local | ||
| // files into context — disclosed to the user; | ||
| // - --ignore-user-config keeps the user's other MCP servers out of an isolated | ||
| // run; cmd.Dir is an empty temp dir so it doesn't read the launch directory. | ||
| type codexAgent struct{ bin string } | ||
|
|
||
| func (a *codexAgent) Name() string { return "codex" } | ||
|
|
||
| func (a *codexAgent) command(ctx context.Context, s turnSpec) (*exec.Cmd, func(), error) { | ||
| // Codex has no system-prompt flag; the framing rides on the first turn's | ||
| // prompt (the resumed session already carries it). | ||
| prompt := s.prompt | ||
| if s.systemPrompt != "" { | ||
| prompt = s.systemPrompt + "\n\n" + prompt | ||
| } | ||
| mcpCfg := fmt.Sprintf("mcp_servers.radar.url=%q", s.mcpURL) | ||
|
|
||
| // Always start from the base flags; isolation adds --ignore-user-config, an | ||
| // empty cwd, and a minimal env. "My setup" keeps the user's config (their other | ||
| // MCP servers, guidelines), their full env, and their home cwd. The shell stays | ||
| // --sandbox read-only in BOTH modes — but the "cluster writes go only through | ||
| // Radar's read-only MCP" containment holds ONLY when isolated. In "my setup" | ||
| // the agent also gets the user's own MCP servers (possibly write/network/cloud | ||
| // capable) + local file reads: a deliberate trusted mode, not a contained one. | ||
| base := []string{"--json", "--skip-git-repo-check", "-c", mcpCfg} | ||
| // Emit reasoning summaries so the UI can show the model's thinking between tool | ||
| // calls (off by default — without this the stream is only tool calls + the final | ||
| // message). The Codex parser maps these `reasoning` items to thinking events. | ||
| base = append(base, "-c", `model_reasoning_summary="auto"`) | ||
| // Disable Codex's built-in web_search tool: it reaches the public internet | ||
| // (server-side, so --sandbox can't stop it), which breaks the "contained, reads | ||
| // only this cluster" guarantee. The diagnosis needs cluster data via MCP, not the | ||
| // web. (image_gen is also built in but is inert for k8s and can't be disabled.) | ||
| base = append(base, "-c", `web_search="disabled"`) | ||
| if s.isolated { | ||
| base = append(base, "--ignore-user-config") | ||
| } | ||
| if s.model != "" { | ||
| base = append(base, "-m", s.model) | ||
| } | ||
| // Default to medium reasoning effort (not Codex's bare default) — it gives a | ||
| // solid investigation AND is the level at which Codex actually emits reasoning | ||
| // summaries under --ignore-user-config, so the UI can show the model's thinking. | ||
| effort := s.effort | ||
| if effort == "" { | ||
| effort = "medium" | ||
| } | ||
| // Codex's always-on image_gen tool is rejected by the API at "minimal" effort | ||
| // (400), and it can't be disabled — so clamp minimal up to low. | ||
| if effort == "minimal" { | ||
| effort = "low" | ||
| } | ||
| base = append(base, "-c", fmt.Sprintf("model_reasoning_effort=%q", effort)) | ||
|
|
||
| var args []string | ||
| if s.sessionID != "" { | ||
| // resume lacks --sandbox; set sandbox via -c (cwd via cmd.Dir below). | ||
| args = append([]string{"exec", "resume"}, base...) | ||
| args = append(args, "-c", `sandbox_mode="read-only"`, s.sessionID, prompt) | ||
| } else { | ||
| args = append([]string{"exec"}, base...) | ||
| args = append(args, "--sandbox", "read-only", prompt) | ||
| } | ||
|
|
||
| cmd := exec.CommandContext(ctx, a.bin, args...) | ||
|
|
||
| cleanup := func() {} | ||
| if s.isolated { | ||
| // Empty working dir so the model's shell can't read radar's source / cwd. | ||
| dir, err := os.MkdirTemp("", "radar-codex-") | ||
| if err != nil { | ||
| return nil, nil, fmt.Errorf("ai: codex workdir: %w", err) | ||
| } | ||
| cleanup = func() { _ = os.RemoveAll(dir) } | ||
| cmd.Dir = dir | ||
| cmd.Env = codexEnv() | ||
| } | ||
| // "My setup": inherit radar's cwd + full env so the user's auth/config/MCPs work. | ||
|
|
||
| return cmd, cleanup, nil | ||
| } | ||
|
|
||
| // codexEnv is the minimal environment Codex needs (auth via HOME/CODEX_HOME, PATH | ||
| // to exec, locale). Cloud-provider secrets are deliberately omitted — the shell | ||
| // can read env into context, and the agent reaches the cluster only via MCP. | ||
| func codexEnv() []string { | ||
| keep := map[string]bool{ | ||
| "HOME": true, "PATH": true, "CODEX_HOME": true, "TMPDIR": true, | ||
| "TERM": true, "LANG": true, "USER": true, "LOGNAME": true, "SHELL": true, | ||
| } | ||
| var out []string | ||
| for _, kv := range os.Environ() { | ||
| k, _, ok := strings.Cut(kv, "=") | ||
| if !ok { | ||
| continue | ||
| } | ||
| if keep[k] || strings.HasPrefix(k, "LC_") { | ||
| out = append(out, kv) | ||
| } | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| // Codex JSONL event shapes (codex exec --json). Only the fields we consume. | ||
| type codexEvent struct { | ||
| Type string `json:"type"` | ||
| ThreadID string `json:"thread_id"` | ||
| Item *codexItem `json:"item"` | ||
| } | ||
|
|
||
| type codexItem struct { | ||
| ID string `json:"id"` | ||
| Type string `json:"type"` // mcp_tool_call | agent_message | reasoning | ... | ||
| Text string `json:"text"` | ||
| Tool string `json:"tool"` | ||
| Arguments json.RawMessage `json:"arguments"` | ||
| Result *struct { | ||
| Content []struct { | ||
| Text string `json:"text"` | ||
| } `json:"content"` | ||
| } `json:"result"` | ||
| } | ||
|
|
||
| func (a *codexAgent) parseStream(r io.Reader, onEvent func(StreamEvent)) Diagnosis { | ||
| sc := bufio.NewScanner(r) | ||
| sc.Buffer(make([]byte, 0, 64*1024), 4<<20) | ||
| var sessionID string | ||
| var answer strings.Builder | ||
|
|
||
| for sc.Scan() { | ||
| line := bytes.TrimSpace(sc.Bytes()) | ||
| if len(line) == 0 || line[0] != '{' { | ||
| continue | ||
| } | ||
| var e codexEvent | ||
| if json.Unmarshal(line, &e) != nil { | ||
| continue | ||
| } | ||
| switch e.Type { | ||
| case "thread.started": | ||
| if e.ThreadID != "" { | ||
| sessionID = e.ThreadID | ||
| } | ||
| case "item.started": | ||
| if e.Item != nil && e.Item.Type == "mcp_tool_call" { | ||
| onEvent(StreamEvent{Type: "step", Step: &StepInfo{ | ||
| ID: e.Item.ID, Tool: e.Item.Tool, Status: "running", | ||
| Summary: codexArgsText(e.Item.Arguments), | ||
| }}) | ||
| } | ||
| case "item.completed": | ||
| if e.Item == nil { | ||
| continue | ||
| } | ||
| switch e.Item.Type { | ||
| case "mcp_tool_call": | ||
| res, trunc := capPayload(codexResultText(e.Item)) | ||
| onEvent(StreamEvent{Type: "step", Step: &StepInfo{ | ||
| ID: e.Item.ID, Tool: e.Item.Tool, Status: "done", | ||
| Result: res, Truncated: trunc, | ||
| }}) | ||
| case "reasoning": | ||
| if e.Item.Text != "" { | ||
| onEvent(StreamEvent{Type: "thinking", Token: e.Item.Text}) | ||
| } | ||
| case "agent_message": | ||
| if e.Item.Text != "" { | ||
| answer.WriteString(e.Item.Text) | ||
| answer.WriteString("\n") | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| d := diagnosisFromText(answer.String()) | ||
| d.SessionID = sessionID | ||
| return d | ||
| } | ||
|
|
||
| func codexArgsText(raw json.RawMessage) string { | ||
| s := strings.TrimSpace(string(raw)) | ||
| if s == "" || s == "null" || s == "{}" { | ||
| return "" | ||
| } | ||
| args, _ := capPayload(s) | ||
| return args | ||
| } | ||
|
|
||
| // codexResultText joins the text parts of a Codex mcp_tool_call result (already | ||
| // the unwrapped content[].text shape). Capping happens at the call site so the | ||
| // truncated flag can be surfaced. | ||
| func codexResultText(it *codexItem) string { | ||
| if it.Result == nil { | ||
| return "" | ||
| } | ||
| var b strings.Builder | ||
| for _, c := range it.Result.Content { | ||
| b.WriteString(c.Text) | ||
| } | ||
| return b.String() | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Codex isolated breaks session resume
Medium Severity
In isolated Codex mode each turn uses a fresh temp working directory and ignores the per-run
workdirfromRunManager. Follow-up and apply turns resume the CLI session from a different cwd than the first turn, which can break multi-turn investigations under the default isolated setting.Reviewed by Cursor Bugbot for commit 72e8f08. Configure here.