Add a third review harness alongside the in-process majordomo loop and the
claude-code CLI shell-out: the OpenCode CLI (opencode.ai) driving an ollama-cloud
model, selected by an "opencode/<model>" spec. The goal is to benchmark gadfly's
boutique executus harness against a freely-available agentic harness on the SAME
model (e.g. "ollama-cloud/glm-5.2" vs "opencode/glm-5.2").
OpenCode has no --append-system-prompt flag, so the lens system prompt and the
read-only discipline are delivered through a generated config injected via
OPENCODE_CONFIG_CONTENT: a "gadfly" agent whose prompt is the system prompt with
edit/bash denied at both the global and agent level, plus a "gadfly" ollama-cloud
provider. That env var is the highest-precedence config source in the container,
so a reviewed repo's own opencode.json can't re-enable edits on the reviewer.
Spec forms: "opencode/<model>" (wrapped in the generated provider), the
"open-code/" alias, "opencode/<provider>/<model>" pass-through to OpenCode's own
registry, and bare "opencode". Model ids are taken verbatim so colon-bearing
ollama ids (qwen3-coder:480b-cloud) survive. Auth reuses OLLAMA_CLOUD_API_KEY
(mapped to OLLAMA_API_KEY, referenced as {env:OLLAMA_API_KEY} in config, never a
literal secret). Knobs mirror GADFLY_CLAUDE_*: GADFLY_OPENCODE_BIN/MODEL/BASE_URL/
EXTRA_ARGS. openCodeEnv() forwards OLLAMA_API_KEY (the inverse of claudeEnv) but
still withholds the Gitea/findings/Anthropic secrets.
main.go engine selection is now a switch (claude-code / opencode / majordomo), and
the auto-select path uses a type-check instead of a boolean so a shell-out engine
can never hit the *majordomoEngine assertion. auto-select and delegate_investigation
stay majordomo-only and are skipped for opencode (the CLI does its own legwork).
Dockerfile bundles opencode-ai (npm auto-selects its musl build on alpine) with a
best-effort version check + provider pre-warm that never fails the shared image
build. README/examples/CLAUDE.md/scripts updated per the maintenance rules.
Tests: new opencode_test.go mirrors engine_test.go (spec/model/args/config/env-
filter + stub-CLI runtime tests). Verified end-to-end with a fake opencode CLI:
correct argv, injected config, and consolidated markdown output.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
266 lines
10 KiB
Go
266 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
// openCodeEngine reviews by shelling out to the `opencode` CLI (opencode.ai) in
|
|
// non-interactive `run` mode. Like the claude-code engine it is a pure shell-out
|
|
// — OpenCode brings its OWN read tools (read/grep/glob/list) and reads the
|
|
// checked-out tree, so findings are verified against real code — but it drives an
|
|
// ollama-cloud model instead of a Claude subscription. The point is to benchmark
|
|
// gadfly's boutique executus harness against a freely-available agentic harness
|
|
// on the SAME models (e.g. "ollama-cloud/glm-5.2" vs "opencode/glm-5.2").
|
|
//
|
|
// OpenCode has no --append-system-prompt flag, so the lens system prompt AND the
|
|
// read-only discipline are delivered through a generated config injected via the
|
|
// OPENCODE_CONFIG_CONTENT env var (see config): a custom "gadfly" agent whose
|
|
// prompt is the system prompt with edit/bash denied, plus a "gadfly" provider
|
|
// pointing at ollama-cloud. OPENCODE_CONFIG_CONTENT is the highest-precedence
|
|
// config source that matters in the container — it outranks any opencode.json a
|
|
// reviewed repo might ship — so a repo can't re-enable edits on us.
|
|
type openCodeEngine struct {
|
|
bin string // CLI binary (GADFLY_OPENCODE_BIN, default "opencode")
|
|
providerModel string // ollama model id for the generated "gadfly" provider ("" = none)
|
|
modelRef string // --model value ("gadfly/<id>", a pass-through "<prov>/<id>", or "" = CLI default)
|
|
baseURL string // ollama-cloud base URL (GADFLY_OPENCODE_BASE_URL)
|
|
repoDir string // cwd for the CLI, so its tools read the checked-out tree
|
|
extraArgs []string // appended verbatim (GADFLY_OPENCODE_EXTRA_ARGS)
|
|
}
|
|
|
|
const (
|
|
// openCodeProviderName / openCodeAgentName are the internal names of the
|
|
// provider and agent gadfly generates in the injected config. "gadfly" won't
|
|
// collide with OpenCode's models.dev provider registry.
|
|
openCodeProviderName = "gadfly"
|
|
openCodeAgentName = "gadfly"
|
|
// defaultOpenCodeBaseURL is ollama-cloud's OpenAI-compatible endpoint.
|
|
defaultOpenCodeBaseURL = "https://ollama.com/v1"
|
|
)
|
|
|
|
// isOpenCodeSpec reports whether a GADFLY_MODEL spec selects the opencode engine:
|
|
// the bare id "opencode"/"open-code" or an "opencode/<model>" form (both
|
|
// spellings accepted; "opencode" is canonical).
|
|
func isOpenCodeSpec(model string) bool {
|
|
m := strings.TrimSpace(model)
|
|
for _, p := range []string{"opencode", "open-code"} {
|
|
if m == p || strings.HasPrefix(m, p+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// newOpenCodeEngine builds the engine from the GADFLY_MODEL spec and the optional
|
|
// GADFLY_OPENCODE_* overrides. The part after the FIRST slash is the model, taken
|
|
// verbatim — no ":"-suffix parsing, because ollama model ids legitimately contain
|
|
// colons (e.g. "qwen3-coder:480b-cloud"). Three spec forms:
|
|
//
|
|
// opencode → bare: no --model, no generated provider (CLI default model)
|
|
// opencode/<model> → wrap <model> in the generated ollama-cloud "gadfly" provider
|
|
// opencode/<provider>/<model> → pass-through: --model <provider>/<model>, using OpenCode's
|
|
// own provider registry/auth (escape hatch, no generated provider)
|
|
//
|
|
// GADFLY_OPENCODE_MODEL overrides the model taken from the spec (and is itself run
|
|
// through the same slash logic). It does not verify the CLI is installed — a
|
|
// missing binary surfaces as a normal pass error (advisory, never fatal).
|
|
func newOpenCodeEngine(spec, repoDir string) *openCodeEngine {
|
|
e := &openCodeEngine{
|
|
bin: envOr("GADFLY_OPENCODE_BIN", "opencode"),
|
|
baseURL: envOr("GADFLY_OPENCODE_BASE_URL", defaultOpenCodeBaseURL),
|
|
repoDir: repoDir,
|
|
extraArgs: strings.Fields(os.Getenv("GADFLY_OPENCODE_EXTRA_ARGS")),
|
|
}
|
|
var after string
|
|
if _, a, ok := strings.Cut(strings.TrimSpace(spec), "/"); ok {
|
|
after = strings.TrimSpace(a)
|
|
}
|
|
if env := strings.TrimSpace(os.Getenv("GADFLY_OPENCODE_MODEL")); env != "" {
|
|
after = env
|
|
}
|
|
switch {
|
|
case after == "":
|
|
// bare spec: let OpenCode's configured default model apply.
|
|
case strings.Contains(after, "/"):
|
|
// "<provider>/<model>" pass-through to an OpenCode built-in provider.
|
|
e.modelRef = after
|
|
default:
|
|
// A bare model id → serve it via the generated ollama-cloud provider.
|
|
e.providerModel = after
|
|
e.modelRef = openCodeProviderName + "/" + after
|
|
}
|
|
return e
|
|
}
|
|
|
|
// args assembles the `opencode` argv for one pass. Factored out (and pure) so it
|
|
// can be unit-tested without invoking the CLI. The task is the positional message
|
|
// and MUST come last; it never begins with '-' (buildTask output starts with "PR
|
|
// title:"/"Review …"), so no "--" terminator is needed. Note: in `opencode run`,
|
|
// -p is basic-auth password, NOT the prompt — the message is positional.
|
|
func (e *openCodeEngine) args(task string) []string {
|
|
a := []string{"run", "--agent", openCodeAgentName}
|
|
if e.modelRef != "" {
|
|
a = append(a, "--model", e.modelRef)
|
|
}
|
|
a = append(a, e.extraArgs...)
|
|
return append(a, task)
|
|
}
|
|
|
|
// openCode config structs — the minimal shape gadfly generates. Marshaled to JSON
|
|
// and handed to the CLI via OPENCODE_CONFIG_CONTENT.
|
|
type openCodeConfig struct {
|
|
Schema string `json:"$schema"`
|
|
Permission openCodePermission `json:"permission"`
|
|
Provider map[string]openCodeProvider `json:"provider,omitempty"`
|
|
Agent map[string]openCodeAgent `json:"agent"`
|
|
}
|
|
|
|
type openCodePermission struct {
|
|
Edit string `json:"edit"`
|
|
Bash string `json:"bash"`
|
|
}
|
|
|
|
type openCodeProvider struct {
|
|
NPM string `json:"npm"`
|
|
Name string `json:"name"`
|
|
Options openCodeProviderOptions `json:"options"`
|
|
Models map[string]struct{} `json:"models"`
|
|
}
|
|
|
|
type openCodeProviderOptions struct {
|
|
BaseURL string `json:"baseURL"`
|
|
APIKey string `json:"apiKey"`
|
|
}
|
|
|
|
type openCodeAgent struct {
|
|
Description string `json:"description"`
|
|
Mode string `json:"mode"`
|
|
Prompt string `json:"prompt"`
|
|
Permission openCodePermission `json:"permission"`
|
|
}
|
|
|
|
// config builds the OpenCode config JSON for one pass. The system prompt becomes
|
|
// the "gadfly" agent's prompt; edit/bash are denied at BOTH the global and agent
|
|
// level (defense in depth — OpenCode's read-only tools stay available). For a
|
|
// bare or pass-through spec the generated ollama-cloud provider block is omitted.
|
|
// The API key is always the "{env:OLLAMA_API_KEY}" reference, never a literal
|
|
// secret baked into the config.
|
|
func (e *openCodeEngine) config(system string) ([]byte, error) {
|
|
deny := openCodePermission{Edit: "deny", Bash: "deny"}
|
|
cfg := openCodeConfig{
|
|
Schema: "https://opencode.ai/config.json",
|
|
Permission: deny,
|
|
Agent: map[string]openCodeAgent{
|
|
openCodeAgentName: {
|
|
Description: "Gadfly adversarial code-review lens (read-only).",
|
|
Mode: "primary",
|
|
Prompt: system,
|
|
Permission: deny,
|
|
},
|
|
},
|
|
}
|
|
if e.providerModel != "" {
|
|
cfg.Provider = map[string]openCodeProvider{
|
|
openCodeProviderName: {
|
|
NPM: "@ai-sdk/openai-compatible",
|
|
Name: openCodeProviderName,
|
|
Options: openCodeProviderOptions{
|
|
BaseURL: e.baseURL,
|
|
APIKey: "{env:OLLAMA_API_KEY}",
|
|
},
|
|
Models: map[string]struct{}{e.providerModel: {}},
|
|
},
|
|
}
|
|
}
|
|
return json.Marshal(cfg)
|
|
}
|
|
|
|
func (e *openCodeEngine) runPass(ctx context.Context, system, task string, _ int) (string, error) {
|
|
cfg, err := e.config(system)
|
|
if err != nil {
|
|
return "", fmt.Errorf("opencode config: %w", err)
|
|
}
|
|
cmd := exec.CommandContext(ctx, e.bin, e.args(task)...)
|
|
cmd.Dir = e.repoDir
|
|
// Inject the review config (system prompt as the agent prompt + read-only
|
|
// permissions + the ollama-cloud provider) via OPENCODE_CONFIG_CONTENT, which
|
|
// outranks any opencode.json the reviewed repo itself ships. NO_COLOR keeps the
|
|
// captured stdout free of ANSI decoration.
|
|
cmd.Env = append(openCodeEnv(), "OPENCODE_CONFIG_CONTENT="+string(cfg), "NO_COLOR=1")
|
|
// Put the CLI and the Node children it spawns in their own process group and
|
|
// kill the WHOLE group on context cancel, so a timed-out lens can't leave
|
|
// orphaned opencode/node processes behind in the container.
|
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
cmd.Cancel = func() error {
|
|
if cmd.Process != nil {
|
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
|
}
|
|
return nil
|
|
}
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
runErr := cmd.Run()
|
|
|
|
// A cancelled/timed-out run must surface as an error, never as whatever partial
|
|
// bytes the CLI flushed before it was killed.
|
|
if ctx.Err() != nil {
|
|
return "", fmt.Errorf("opencode run %v", ctx.Err())
|
|
}
|
|
if runErr != nil {
|
|
detail := truncateForErr(stderr.String())
|
|
if detail == "" {
|
|
detail = truncateForErr(stdout.String())
|
|
}
|
|
if detail != "" {
|
|
return "", fmt.Errorf("opencode run failed: %v: %s", runErr, detail)
|
|
}
|
|
return "", fmt.Errorf("opencode run failed: %v", runErr)
|
|
}
|
|
// OpenCode's default (non-JSON) format prints the assistant's final text; trust
|
|
// it as the review. An empty result on a clean exit is an error, never "".
|
|
if out := strings.TrimSpace(stdout.String()); out != "" {
|
|
return out, nil
|
|
}
|
|
return "", fmt.Errorf("opencode run returned no output")
|
|
}
|
|
|
|
// openCodeEnv builds a minimal environment for the `opencode` subprocess: only
|
|
// what the CLI needs (PATH/HOME, locale, Node/XDG/OPENCODE_*/GADFLY_OPENCODE_*
|
|
// knobs) plus OLLAMA_API_KEY — which the generated provider references and which
|
|
// claudeEnv deliberately DROPS. The runner's other secrets (GITEA_TOKEN,
|
|
// GADFLY_FINDINGS_TOKEN, Anthropic/Claude keys) are withheld — the CLI has no need
|
|
// for them. OPENCODE_CONFIG_CONTENT is never inherited: runPass sets it, and a
|
|
// duplicate key would be ambiguous (getenv returns the first occurrence).
|
|
func openCodeEnv() []string {
|
|
keep := func(k string) bool {
|
|
if k == "OPENCODE_CONFIG_CONTENT" {
|
|
return false // set explicitly by runPass; never inherit a competing value
|
|
}
|
|
switch k {
|
|
case "PATH", "HOME", "USER", "LOGNAME", "TMPDIR", "LANG", "TERM", "SHELL", "OLLAMA_API_KEY":
|
|
return true
|
|
}
|
|
return strings.HasPrefix(k, "LC_") ||
|
|
strings.HasPrefix(k, "OPENCODE_") ||
|
|
strings.HasPrefix(k, "GADFLY_OPENCODE_") ||
|
|
strings.HasPrefix(k, "NODE_") ||
|
|
strings.HasPrefix(k, "XDG_")
|
|
}
|
|
var env []string
|
|
for _, kv := range os.Environ() {
|
|
if k, _, ok := strings.Cut(kv, "="); ok && keep(k) {
|
|
env = append(env, kv)
|
|
}
|
|
}
|
|
return env
|
|
}
|