Build & push image / build-and-push (pull_request) Successful in 8s
Gadfly's own swarm reviewed PR #26 and reached consensus (3/3 models) on a real bug, plus flagged security/maintainability items. Fixes: - Pass-through auth (BLOCKING, 3/3 agreement): openCodeEnv() stripped every provider key except OLLAMA_API_KEY, so the documented opencode/<provider>/<model> escape hatch (e.g. opencode/anthropic/...) had no way to authenticate — the reusable workflow forwards ANTHROPIC_API_KEY/OPENAI_API_KEY into the container and the allowlist discarded them. Now forward ANTHROPIC_*/OPENAI_*/GOOGLE_*/ GEMINI_* so OpenCode's built-in providers can authenticate, while still withholding gadfly's own secrets (Gitea/findings tokens, claude-code OAuth). - Read-only hardening (security lens): the generated config denied only edit/bash. Using OpenCode's documented permission schema, also deny webfetch/websearch/ external_directory — the network + out-of-sandbox tools — closing the exfiltration surface a prompt-injected review could otherwise reach. Permission is now a map so the deny set is extensible. - Dedup (maintainability lens, 3/3): extract shared filterEnv() and killGroupOnCancel() helpers in engine.go, used by both shell-out engines' runPass/env builders instead of the copy-pasted blocks. - Cosmetic: split the const block so defaultOpenCodeBaseURL's doc comment no longer visually misattaches to the agent-name const. README updated: the read-only note and the reduced-env note now reflect the broader deny set and the forwarded provider keys. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
304 lines
11 KiB
Go
304 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestIsOpenCodeSpec(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"opencode": true,
|
|
"opencode/glm-5.2": true,
|
|
"open-code/glm-5.2": true, // accepted alias spelling
|
|
"opencode/qwen3-coder:480b-cloud": true, // colon-bearing model id
|
|
"opencode/anthropic/claude": true, // pass-through form
|
|
" opencode ": true, // trimmed
|
|
"opencode-extra": false, // not the bare id, not a "/" form
|
|
"qwen3-coder:480b-cloud": false,
|
|
"claude-code/opus": false,
|
|
"": false,
|
|
}
|
|
for spec, want := range cases {
|
|
if got := isOpenCodeSpec(spec); got != want {
|
|
t.Errorf("isOpenCodeSpec(%q) = %v, want %v", spec, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewOpenCodeEngineModel(t *testing.T) {
|
|
t.Setenv("GADFLY_OPENCODE_MODEL", "")
|
|
|
|
// "opencode/<model>" → wrapped in the generated "gadfly" provider.
|
|
if e := newOpenCodeEngine("opencode/glm-5.2", "/repo"); e.providerModel != "glm-5.2" || e.modelRef != "gadfly/glm-5.2" {
|
|
t.Errorf("glm-5.2: providerModel=%q modelRef=%q, want glm-5.2 / gadfly/glm-5.2", e.providerModel, e.modelRef)
|
|
}
|
|
// Colon-bearing ollama id is preserved verbatim — NOT split on ":".
|
|
if e := newOpenCodeEngine("opencode/qwen3-coder:480b-cloud", "/repo"); e.providerModel != "qwen3-coder:480b-cloud" {
|
|
t.Errorf("colon id: providerModel=%q, want qwen3-coder:480b-cloud (no split)", e.providerModel)
|
|
}
|
|
// "open-code/" spelling behaves identically.
|
|
if e := newOpenCodeEngine("open-code/glm-5.2", "/repo"); e.modelRef != "gadfly/glm-5.2" {
|
|
t.Errorf("open-code alias: modelRef=%q, want gadfly/glm-5.2", e.modelRef)
|
|
}
|
|
// Pass-through "opencode/<provider>/<model>" → no generated provider.
|
|
if e := newOpenCodeEngine("opencode/anthropic/claude-sonnet-4-6", "/repo"); e.providerModel != "" || e.modelRef != "anthropic/claude-sonnet-4-6" {
|
|
t.Errorf("pass-through: providerModel=%q modelRef=%q, want '' / anthropic/claude-sonnet-4-6", e.providerModel, e.modelRef)
|
|
}
|
|
// Bare spec → no model, no provider (CLI default applies).
|
|
if e := newOpenCodeEngine("opencode", "/repo"); e.providerModel != "" || e.modelRef != "" {
|
|
t.Errorf("bare: providerModel=%q modelRef=%q, want both empty", e.providerModel, e.modelRef)
|
|
}
|
|
// GADFLY_OPENCODE_MODEL overrides the spec suffix.
|
|
t.Setenv("GADFLY_OPENCODE_MODEL", "deepseek-v3")
|
|
if e := newOpenCodeEngine("opencode/glm-5.2", "/repo"); e.providerModel != "deepseek-v3" || e.modelRef != "gadfly/deepseek-v3" {
|
|
t.Errorf("env override: providerModel=%q modelRef=%q, want deepseek-v3 / gadfly/deepseek-v3", e.providerModel, e.modelRef)
|
|
}
|
|
}
|
|
|
|
func TestOpenCodeEngineDefaults(t *testing.T) {
|
|
t.Setenv("GADFLY_OPENCODE_BIN", "")
|
|
t.Setenv("GADFLY_OPENCODE_BASE_URL", "")
|
|
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "")
|
|
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
|
|
if e.bin != "opencode" {
|
|
t.Errorf("bin = %q, want opencode", e.bin)
|
|
}
|
|
if e.baseURL != defaultOpenCodeBaseURL {
|
|
t.Errorf("baseURL = %q, want %q", e.baseURL, defaultOpenCodeBaseURL)
|
|
}
|
|
if e.repoDir != "/repo" {
|
|
t.Errorf("repoDir = %q, want /repo", e.repoDir)
|
|
}
|
|
}
|
|
|
|
func TestOpenCodeArgs(t *testing.T) {
|
|
t.Setenv("GADFLY_OPENCODE_MODEL", "")
|
|
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "--variant reasoning")
|
|
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
|
|
args := e.args("TASK-PROMPT")
|
|
|
|
// "run" is the subcommand and must be first.
|
|
if len(args) == 0 || args[0] != "run" {
|
|
t.Fatalf("args[0] = %q, want run (args=%v)", args, args)
|
|
}
|
|
if argAfter(args, "--agent") != openCodeAgentName {
|
|
t.Errorf("--agent = %q, want %q", argAfter(args, "--agent"), openCodeAgentName)
|
|
}
|
|
if argAfter(args, "--model") != "gadfly/glm-5.2" {
|
|
t.Errorf("--model = %q, want gadfly/glm-5.2", argAfter(args, "--model"))
|
|
}
|
|
// extra args appended verbatim (split on whitespace).
|
|
if !strings.Contains(strings.Join(args, " "), "--variant reasoning") {
|
|
t.Errorf("extra args not appended: %v", args)
|
|
}
|
|
// task is the positional message and must be LAST.
|
|
if args[len(args)-1] != "TASK-PROMPT" {
|
|
t.Errorf("last arg = %q, want TASK-PROMPT (args=%v)", args[len(args)-1], args)
|
|
}
|
|
}
|
|
|
|
func TestOpenCodeArgsBareModelOmitsFlag(t *testing.T) {
|
|
t.Setenv("GADFLY_OPENCODE_MODEL", "")
|
|
t.Setenv("GADFLY_OPENCODE_EXTRA_ARGS", "")
|
|
e := newOpenCodeEngine("opencode", "/repo")
|
|
args := e.args("t")
|
|
if slices.Contains(args, "--model") {
|
|
t.Errorf("--model should be omitted for a bare opencode spec: %v", args)
|
|
}
|
|
if args[len(args)-1] != "t" {
|
|
t.Errorf("last arg = %q, want t", args[len(args)-1])
|
|
}
|
|
}
|
|
|
|
func TestOpenCodeConfig(t *testing.T) {
|
|
t.Setenv("GADFLY_OPENCODE_MODEL", "")
|
|
t.Setenv("GADFLY_OPENCODE_BASE_URL", "")
|
|
|
|
// Round-trip a system prompt containing quotes and newlines.
|
|
sys := "Line one with \"quotes\".\nLine two."
|
|
e := newOpenCodeEngine("opencode/glm-5.2", "/repo")
|
|
raw, err := e.config(sys)
|
|
if err != nil {
|
|
t.Fatalf("config: %v", err)
|
|
}
|
|
var cfg openCodeConfig
|
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
|
t.Fatalf("generated config is not valid JSON: %v\n%s", err, raw)
|
|
}
|
|
|
|
// Agent carries the system prompt verbatim and denies edit+bash.
|
|
ag, ok := cfg.Agent[openCodeAgentName]
|
|
if !ok {
|
|
t.Fatalf("agent %q missing from config", openCodeAgentName)
|
|
}
|
|
if ag.Prompt != sys {
|
|
t.Errorf("agent prompt = %q, want it to round-trip the system prompt", ag.Prompt)
|
|
}
|
|
// Mutating/network tools are denied at BOTH the agent and global level (defense
|
|
// in depth); the read/search tools stay at OpenCode's default.
|
|
for _, k := range []string{"edit", "bash", "webfetch", "websearch", "external_directory"} {
|
|
if ag.Permission[k] != "deny" {
|
|
t.Errorf("agent permission[%q] = %q, want deny", k, ag.Permission[k])
|
|
}
|
|
if cfg.Permission[k] != "deny" {
|
|
t.Errorf("global permission[%q] = %q, want deny", k, cfg.Permission[k])
|
|
}
|
|
}
|
|
|
|
// Provider block: correct npm, default baseURL, env-ref apiKey, model in map.
|
|
prov, ok := cfg.Provider[openCodeProviderName]
|
|
if !ok {
|
|
t.Fatalf("provider %q missing from config", openCodeProviderName)
|
|
}
|
|
if prov.NPM != "@ai-sdk/openai-compatible" {
|
|
t.Errorf("provider npm = %q, want @ai-sdk/openai-compatible", prov.NPM)
|
|
}
|
|
if prov.Options.BaseURL != defaultOpenCodeBaseURL {
|
|
t.Errorf("provider baseURL = %q, want %q", prov.Options.BaseURL, defaultOpenCodeBaseURL)
|
|
}
|
|
if prov.Options.APIKey != "{env:OLLAMA_API_KEY}" {
|
|
t.Errorf("provider apiKey = %q, want {env:OLLAMA_API_KEY} (never a literal secret)", prov.Options.APIKey)
|
|
}
|
|
if _, ok := prov.Models["glm-5.2"]; !ok {
|
|
t.Errorf("provider models = %v, want it to contain glm-5.2", prov.Models)
|
|
}
|
|
|
|
// GADFLY_OPENCODE_BASE_URL override reaches the provider.
|
|
t.Setenv("GADFLY_OPENCODE_BASE_URL", "http://localhost:11434/v1")
|
|
e2 := newOpenCodeEngine("opencode/glm-5.2", "/repo")
|
|
raw2, err := e2.config(sys)
|
|
if err != nil {
|
|
t.Fatalf("config override: %v", err)
|
|
}
|
|
var cfg2 openCodeConfig
|
|
if err := json.Unmarshal(raw2, &cfg2); err != nil {
|
|
t.Fatalf("override config invalid JSON: %v", err)
|
|
}
|
|
if got := cfg2.Provider[openCodeProviderName].Options.BaseURL; got != "http://localhost:11434/v1" {
|
|
t.Errorf("override baseURL = %q, want http://localhost:11434/v1", got)
|
|
}
|
|
}
|
|
|
|
func TestOpenCodeConfigNoProviderForPassThroughAndBare(t *testing.T) {
|
|
t.Setenv("GADFLY_OPENCODE_MODEL", "")
|
|
for _, spec := range []string{"opencode", "opencode/anthropic/claude-sonnet-4-6"} {
|
|
e := newOpenCodeEngine(spec, "/repo")
|
|
raw, err := e.config("sys")
|
|
if err != nil {
|
|
t.Fatalf("config(%q): %v", spec, err)
|
|
}
|
|
if strings.Contains(string(raw), "\"provider\"") {
|
|
t.Errorf("spec %q: config should omit the provider block, got %s", spec, raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenCodeEnvFilters(t *testing.T) {
|
|
t.Setenv("GITEA_TOKEN", "secret-gitea")
|
|
t.Setenv("OLLAMA_API_KEY", "keep-ollama")
|
|
t.Setenv("GADFLY_API_KEY", "secret-gadfly")
|
|
t.Setenv("GADFLY_FINDINGS_TOKEN", "secret-findings")
|
|
t.Setenv("ANTHROPIC_API_KEY", "keep-anthropic") // pass-through provider auth
|
|
t.Setenv("OPENAI_API_KEY", "keep-openai") // pass-through provider auth
|
|
t.Setenv("CLAUDE_CODE_OAUTH_TOKEN", "secret-claude")
|
|
t.Setenv("GADFLY_OPENCODE_MODEL", "keep-knob")
|
|
t.Setenv("OPENCODE_CONFIG_CONTENT", "should-not-inherit")
|
|
|
|
env := openCodeEnv()
|
|
has := func(k string) bool {
|
|
for _, kv := range env {
|
|
if strings.HasPrefix(kv, k+"=") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
// kept: the ollama key + the standard provider keys the opencode/<provider>/<model>
|
|
// pass-through form needs + opencode knobs + PATH
|
|
for _, k := range []string{"OLLAMA_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GADFLY_OPENCODE_MODEL", "PATH"} {
|
|
if !has(k) {
|
|
t.Errorf("openCodeEnv dropped %s, but it should be kept", k)
|
|
}
|
|
}
|
|
// dropped: gadfly's own secrets + the claude engine's subscription token
|
|
// (OpenCode's anthropic provider uses ANTHROPIC_API_KEY, not this OAuth token).
|
|
for _, k := range []string{"GITEA_TOKEN", "GADFLY_API_KEY", "GADFLY_FINDINGS_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"} {
|
|
if has(k) {
|
|
t.Errorf("openCodeEnv leaked %s into the subprocess env", k)
|
|
}
|
|
}
|
|
// OPENCODE_CONFIG_CONTENT must NOT be inherited — runPass sets it, and a
|
|
// duplicate key would be ambiguous.
|
|
if has("OPENCODE_CONFIG_CONTENT") {
|
|
t.Errorf("openCodeEnv inherited OPENCODE_CONFIG_CONTENT; runPass sets it explicitly")
|
|
}
|
|
}
|
|
|
|
// stubOpenCode writes an executable shell stub that prints body and exits code,
|
|
// and returns an engine pointed at it.
|
|
func stubOpenCode(t *testing.T, body string, code int) *openCodeEngine {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := dir + "/opencode-stub.sh"
|
|
script := "#!/bin/sh\nprintf '%s' " + shSingleQuote(body) + "\nexit " + itoa(code) + "\n"
|
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &openCodeEngine{bin: path, repoDir: dir}
|
|
}
|
|
|
|
func TestOpenCodeRunPassCleanResult(t *testing.T) {
|
|
e := stubOpenCode(t, " REVIEW TEXT ", 0)
|
|
out, err := e.runPass(context.Background(), "sys", "task", 0)
|
|
if err != nil || out != "REVIEW TEXT" {
|
|
t.Fatalf("clean result: got (%q, %v), want (REVIEW TEXT, nil)", out, err)
|
|
}
|
|
}
|
|
|
|
func TestOpenCodeRunPassEmptyIsError(t *testing.T) {
|
|
e := stubOpenCode(t, " ", 0)
|
|
out, err := e.runPass(context.Background(), "sys", "task", 0)
|
|
if err == nil {
|
|
t.Fatalf("empty output should be an error, got out=%q", out)
|
|
}
|
|
}
|
|
|
|
func TestOpenCodeRunPassNonZero(t *testing.T) {
|
|
e := stubOpenCode(t, "fatal: provider auth failed", 1)
|
|
_, err := e.runPass(context.Background(), "sys", "task", 0)
|
|
if err == nil || !strings.Contains(err.Error(), "opencode run failed") {
|
|
t.Fatalf("non-zero exit should error with detail, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestOpenCodeRunPassInjectsConfig proves the end-to-end env plumbing: the stub
|
|
// echoes OPENCODE_CONFIG_CONTENT back, and the emitted JSON must carry the exact
|
|
// system prompt as the gadfly agent's prompt.
|
|
func TestOpenCodeRunPassInjectsConfig(t *testing.T) {
|
|
dir := t.TempDir()
|
|
stub := dir + "/opencode-stub.sh"
|
|
script := "#!/bin/sh\nprintf '%s' \"$OPENCODE_CONFIG_CONTENT\"\n"
|
|
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e := newOpenCodeEngine("opencode/glm-5.2", dir)
|
|
e.bin = stub
|
|
|
|
sys := "SYSTEM-PROMPT-SENTINEL\nwith a second line"
|
|
out, err := e.runPass(context.Background(), sys, "task", 0)
|
|
if err != nil {
|
|
t.Fatalf("runPass: %v", err)
|
|
}
|
|
var cfg openCodeConfig
|
|
if err := json.Unmarshal([]byte(out), &cfg); err != nil {
|
|
t.Fatalf("injected config is not valid JSON: %v\n%s", err, out)
|
|
}
|
|
if got := cfg.Agent[openCodeAgentName].Prompt; got != sys {
|
|
t.Errorf("injected agent prompt = %q, want the system prompt", got)
|
|
}
|
|
}
|