feat(qwen): let Qwen (and Kimi) join the swarm #30

Merged
steve merged 12 commits from feat/qwen-provider into main 2026-08-12 22:46:04 +00:00
Owner

majordomo now ships qwen and kimi as built-ins — the openai client at their own base URLs (majordomo ADR-0027). This wires up everything the key has to pass through so qwen/qwen3.8-max can be added to GADFLY_DEFAULT_MODELS.

The dependency bump is not in this PR yet — majordomo #27 has to merge first; I'll add the go.mod bump before merging this so CI builds against a majordomo that actually has the built-ins.

Two provider switches, not one

resolveModel's GADFLY_BASE_URL override was the obvious place. endpointProvider's GADFLY_ENDPOINT_* parser is its sibling — and I fixed the first and missed the second on the first pass. That combination gives you a provider that resolves when configured one way and errors with "unknown provider" the other, for no reason a user could guess. TestOpenAICompatProvidersResolveOnBothPaths asserts both from one table so the pair fails together; break-checked in both directions (drop qwen from either switch → the test fails).

Key plumbing

  • QWEN_API_KEY / KIMI_API_KEY declared as workflow_call secrets and forwarded to the container.
  • Gadfly's own stub forwards QWEN_API_KEY, so a qwen entry joins the default swarm by editing the GADFLY_DEFAULT_MODELS var alone — no workflow edit, no re-release.
  • These are not interchangeable with OPENAI_API_KEY: majordomo's built-ins read only their own variable and refuse cross-provider fallback. That's the desirable behavior (a missing secret is a 401, never a mis-billed OpenAI call) but it means the forward has to be explicit.
  • Note kimi/<model> (Moonshot's API, KIMI_API_KEY) is a different route than the kimi-k2.6:cloud entry already in the swarm, which is Ollama Cloud on OLLAMA_CLOUD_API_KEY.

Credential pre-flight is now a table

run.sh only ever pre-checked ollama-cloud. Everything else discovered a missing key as five identical per-lens agent failures that name no variable — you read a stack trace to learn which secret you forgot. It's now a provider→variable table producing one clear skip notice.

Google is deliberately left out of the table: it accepts either GOOGLE_API_KEY or GEMINI_API_KEY, so a single-variable entry would skip a correctly-configured run. Verified across 17 provider × key-state combinations — including that a wrong-provider key never satisfies qwen, that GADFLY_API_KEY still overrides, that an empty-string key counts as missing, and that unkeyed providers (local ollama, llama-swap, foreman, google) are never blocked.

To actually use it

  1. Merge this + bump the image pin in review-reusable.yml to the new sha- tag (the pin is why consumers don't pick up a rebuild automatically).
  2. Add the QWEN_API_KEY repo secret.
  3. Add qwen/qwen3.8-max (or qwen/qwen3.7-plus) to the GADFLY_DEFAULT_MODELS user var.

Untested against the live endpoint — marked ⚠️ wired/untested in the README alongside the other paid providers, per the honest-status convention.

🤖 Generated with Claude Code

majordomo now ships `qwen` and `kimi` as built-ins — the openai client at their own base URLs (majordomo ADR-0027). This wires up everything the key has to pass through so `qwen/qwen3.8-max` can be added to `GADFLY_DEFAULT_MODELS`. **The dependency bump is not in this PR yet** — majordomo #27 has to merge first; I'll add the `go.mod` bump before merging this so CI builds against a majordomo that actually has the built-ins. ## Two provider switches, not one `resolveModel`'s `GADFLY_BASE_URL` override was the obvious place. `endpointProvider`'s `GADFLY_ENDPOINT_*` parser is its sibling — and I fixed the first and missed the second on the first pass. That combination gives you a provider that resolves when configured one way and errors with "unknown provider" the other, for no reason a user could guess. `TestOpenAICompatProvidersResolveOnBothPaths` asserts both from one table so the pair fails together; break-checked in both directions (drop `qwen` from either switch → the test fails). ## Key plumbing - `QWEN_API_KEY` / `KIMI_API_KEY` declared as `workflow_call` secrets and forwarded to the container. - Gadfly's own stub forwards `QWEN_API_KEY`, so a qwen entry joins the default swarm by editing the `GADFLY_DEFAULT_MODELS` var alone — no workflow edit, no re-release. - These are **not** interchangeable with `OPENAI_API_KEY`: majordomo's built-ins read only their own variable and refuse cross-provider fallback. That's the desirable behavior (a missing secret is a 401, never a mis-billed OpenAI call) but it means the forward has to be explicit. - Note `kimi/<model>` (Moonshot's API, `KIMI_API_KEY`) is a different route than the `kimi-k2.6:cloud` entry already in the swarm, which is Ollama Cloud on `OLLAMA_CLOUD_API_KEY`. ## Credential pre-flight is now a table `run.sh` only ever pre-checked ollama-cloud. Everything else discovered a missing key as five identical per-lens agent failures that name no variable — you read a stack trace to learn which secret you forgot. It's now a provider→variable table producing one clear skip notice. **Google is deliberately left out of the table**: it accepts either `GOOGLE_API_KEY` *or* `GEMINI_API_KEY`, so a single-variable entry would skip a correctly-configured run. Verified across **17** provider × key-state combinations — including that a wrong-provider key never satisfies qwen, that `GADFLY_API_KEY` still overrides, that an empty-string key counts as missing, and that unkeyed providers (local ollama, llama-swap, foreman, google) are never blocked. ## To actually use it 1. Merge this + bump the image pin in `review-reusable.yml` to the new `sha-` tag (the pin is why consumers don't pick up a rebuild automatically). 2. Add the `QWEN_API_KEY` repo secret. 3. Add `qwen/qwen3.8-max` (or `qwen/qwen3.7-plus`) to the `GADFLY_DEFAULT_MODELS` user var. Untested against the live endpoint — marked ⚠️ wired/untested in the README alongside the other paid providers, per the honest-status convention. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
steve added 1 commit 2026-08-12 20:30:50 +00:00
feat(qwen): let Qwen (and Kimi) join the swarm
Build & push image / build-and-push (pull_request) Successful in 14s
Gadfly review (reusable) / review (pull_request) Successful in 8m46s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m46s
0f40b21d79
majordomo now ships qwen and kimi as built-ins that ARE the openai client at
their own base URL, so "qwen/qwen3.8-max" works as a GADFLY_MODELS entry once
the key reaches the container. This wires up the parts that key has to pass
through.

Two provider switches had to learn the names, not one. resolveModel's
GADFLY_BASE_URL override was the obvious one; endpointProvider's
GADFLY_ENDPOINT_* parser is its sibling, and I fixed the first and missed the
second on the first pass — a config that resolves one way and errors the other
for no reason a user could guess. TestOpenAICompatProvidersResolveOnBothPaths
now asserts both from one table so the pair fails together; break-checked in
both directions.

QWEN_API_KEY (and KIMI_API_KEY) are declared as workflow_call secrets and
forwarded to the container, with gadfly's own stub forwarding QWEN_API_KEY so a
qwen entry can join the default swarm by editing GADFLY_DEFAULT_MODELS alone —
no workflow edit, no re-release.

The run.sh credential pre-flight is now a provider→variable table instead of an
ollama-cloud special case. Without it a forgotten key surfaces as five
identical per-lens agent failures naming no variable, and the operator reads a
stack trace to find out which secret they missed. Google stays out of the table
on purpose: it accepts either GOOGLE_API_KEY or GEMINI_API_KEY, and a one-var
entry would wrongly skip a correctly-configured run. Verified across 17
provider x key-state combinations, including that a wrong-provider key never
satisfies qwen (majordomo refuses cross-provider fallback) and that unkeyed
providers are never blocked.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

🪰 Gadfly — live review status

4/4 reviewers finished · updated 2026-08-12 22:32:54Z

claude-code/opus · claude-code — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

claude-code/sonnet · claude-code — done

  • security — Minor issues
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — No material issues found

glm-5.2:cloud · ollama-cloud — done

  • security — Minor issues
  • correctness — No material issues found
  • maintainability — Minor issues
  • performance — No material issues found
  • error-handling — Minor issues

kimi-k2.6:cloud · ollama-cloud — done

  • security — No material issues found
  • correctness — No material issues found
  • maintainability — No material issues found
  • performance — No material issues found
  • error-handling — Minor issues

Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.

<!-- gadfly-status-board --> ## 🪰 Gadfly — live review status 4/4 reviewers finished · updated 2026-08-12 22:32:54Z #### `claude-code/opus` · claude-code — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `claude-code/sonnet` · claude-code — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — No material issues found #### `glm-5.2:cloud` · ollama-cloud — ✅ done - ✅ **security** — Minor issues - ✅ **correctness** — No material issues found - ✅ **maintainability** — Minor issues - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues #### `kimi-k2.6:cloud` · ollama-cloud — ✅ done - ✅ **security** — No material issues found - ✅ **correctness** — No material issues found - ✅ **maintainability** — No material issues found - ✅ **performance** — No material issues found - ✅ **error-handling** — Minor issues <sub>Live status board. Findings are posted in each model's own comment. Advisory only — does not block merge.</sub>

🪰 Gadfly review — consensus across 4 models

Verdict: Minor issues · 10 findings (0 with multi-model agreement)

10 single-model findings (lower confidence)
Finding Where Model Lens
🟠 New pull_request-triggered 'go test' job is the first job in the repo to execute PR-authored code (vs. compile-only or text-only LLM review); no permissions: block or actor gate, though this matches (not deviates from) the repo's existing pattern for other pull_request-triggered secret-bearing jobs .gitea/workflows/build-image.yml:53 claude-code/sonnet security
🟠 Missing-key hint on the GADFLY_ENDPOINT_* path for builtins names the unmasked endpoint var, steering operators to store a credential in a non-masked Gitea variable (contradicts README's own warning); should name QWEN_API_KEY/KIMI_API_KEY instead cmd/gadfly/model.go:82 glm-5.2:cloud security
🟠 Override-path built-ins (GADFLY_BASE_URL set) are not pre-flighted; the stated rationale ('a built-in's own variable is never consulted on this path') is contradicted by openAICompatOptions consulting os.Getenv(builtinCompatKeyEnv(provider)) at model.go:78 on exactly the GADFLY_BASE_URL override path (model.go:159-161), so a keyless qwen/kimi override config passes preflight and fails at runtime as a 401 instead of a preflight notice scripts/preflight.sh:16 glm-5.2:cloud error-handling
🟡 Complex credential-scrub bash embedded inline in YAML instead of extracted to a testable script, inconsistent with how preflight.sh was factored out in the same PR .gitea/workflows/build-image.yml:78 claude-code/sonnet maintainability
🟡 No-cross-vendor-fallback rationale is restated in multiple places across Go and shell; prose has no single source of truth the way the code does cmd/gadfly/model.go:60 glm-5.2:cloud maintainability
🟡 TestOpenAICompatProvidersResolveOnBothPaths is mostly redundant with the stronger TestEndpointProviderNamesAreAllAccepted (only the Name() check is unique) cmd/gadfly/model_test.go:90 glm-5.2:cloud maintainability
🟡 Untrimmed model parameter in claude-code case: whitespace in GADFLY_MODEL bypasses the claude-code exemption and falls through to provider key check scripts/preflight.sh:39 kimi-k2.6:cloud error-handling
🟡 Unicode whitespace mismatch: bash tr -d [:space:] strips only POSIX whitespace while Go strings.TrimSpace strips all Unicode space, causing preflight to skip key checks for registry-path runs when GADFLY_BASE_URL contains only non-ASCII whitespace scripts/preflight.sh:49 kimi-k2.6:cloud error-handling
awk|cut|cut lookup is heavier than the 8-row table warrants; deliberate tradeoff but harder to read than a case/read scripts/preflight.sh:62 glm-5.2:cloud maintainability
Duplicated ollama-cloud hint-vs-copy rationale comment in two adjacent blocks (also at lines 55-60); one copy can rot scripts/preflight.sh:84 claude-code/opus maintainability
Per-model detail
claude-code/opus (claude-code) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

I've now examined the security-critical surfaces of this PR: the credential-resolution logic in model.go, the new CI credential-scrub test job, the pre-flight table, and the endpoint/key plumbing. I could not read majordomo's source (sandbox restricts me to the repo), so I reasoned about that one boundary and note it explicitly below.

Review — 🔒 Security

VERDICT: No material issues found

This change is unusually careful about exactly the things my lens cares about — cross-vendor credential leakage and secret exposure in CI. Findings I checked:

  • Cross-vendor key leakage (the core risk) is handled correctly. openAICompatOptions (cmd/gadfly/model.go:68) passes openai.WithAPIKey(key) unconditionally — including empty — for kimi/qwen (builtinCompatProviders), specifically so openai.New's built-in OPENAI_API_KEY default can't put an OpenAI key on the wire to Alibaba/Moonshot. Both construction paths (resolveModel base-URL override at model.go:157 and endpointProvider at model.go:333) route through the one helper, closing the historical "fixed on one path, still leaking on the other" gap. TestBuiltinCompatProvidersNeverInheritOpenAIKey asserts fail-closed on both paths against a live httptest server that records Authorization headers — a genuine adversarial test, not a smoke test.

    • Caveat I could not personally verify: the whole scheme rests on majordomo's openai.WithAPIKey("") overriding (not being ignored as "unset") the OPENAI_API_KEY fallback. I can't read the majordomo module from this sandbox. If that assumption were wrong it would be a real cross-vendor credential leak — but TestBuiltinCompatProvidersNeverInheritOpenAIKey would fail in that case, and this PR also adds the CI test job that runs it, so the assumption is now gated. Reasonable.
  • Endpoint-var key exposure is correctly steered away from. The README warning (README.md) and the own-key fallback (model.go:77-81, falling back to QWEN_API_KEY/KIMI_API_KEY, never a foreign var) correctly keep the credential in a masked secret rather than the unmasked GADFLY_ENDPOINT_* Gitea variable. TestBuiltinCompatOwnKeyFallback confirms the own-vendor-only fallback. This is a security improvement.

  • The new CI test job scrubs the push-capable registry credential before running PR code — verified fail-closed. In build-image.yml, the credential lives only in GIT_CONFIG_GLOBAL=$(mktemp), is removed by both an explicit rm and a trap … EXIT, and the secret-bearing env: is scoped to the "Fetch private modules" step only — later steps (go build/test, which run attacker-authored fork-PR code) never carry it. The post-scrub grep -rqF verification distinguishes grep's three exit codes and treats the ≥2 "couldn't run" case as fail-closed (build-image.yml:121-125) — the fail-open trap here is explicitly avoided. go mod download (which uses the live credential) executes no repository code, so there's no RCE window with the secret present.

  • No injection / SSRF from untrusted input. The provider/model/base-URL values that flow into preflight.sh (indirect expansion ${!key_env:-}, awk -F:) and into the endpoint constructors all originate from operator-set repo vars/secrets, not from the PR under review. The reviewed diff is passed to the agent as data, not shell. No tainted-input path.

Minor residual notes (not worth a finding): the grep scrub scans $HOME while GIT_CONFIG_GLOBAL lives in $TMPDIR — but its removal is directly asserted by test ! -e, so this is belt-and-suspenders, not a gap; and the base64 Authorization header is a derived value Gitea won't mask, but no step prints it. Neither is exploitable as written.

Nothing in the security lane rises to reporting.

🎯 Correctness — No material issues found

I've verified the substance of this PR against the checked-out code. Here's my correctness review.

VERDICT: No material issues found

I focused on the logic introduced by this change and re-derived the credential-routing behavior across all three surfaces (Go resolveModel/endpointProvider, preflight.sh, and the workflow YAMLs). It holds together.

What I checked and confirmed:

  • builtinCompatKeyEnv derivation (cmd/gadfly/model.go:56): qwenQWEN_API_KEY, kimiKIMI_API_KEY via ToUpper(ReplaceAll(p,"-","_"))+"_API_KEY". Correct for both built-ins.
  • The two-switch parity claim (model.go:159 and :334): both paths now gate the openai-compat family through the single isOpenAICompatProvider predicate over the shared openAICompatProviders slice, and both fall through to switches that between them accept every spelling in endpointProviderNames (openai/openai-compatible/kimi/qwen/ollama/ollama-cloud/llama-swap(s)/llamaswap(s)/foreman/anthropic/google/gemini). I read the full switches — google/gemini and all four llama-swap spellings are handled in both functions, so TestEndpointProviderNamesAreAllAccepted will not silently pass on a name one path rejects.
  • No behavior change for openai/openai-compatible: the refactor into openAICompatOptions reproduces the old if key != "" { WithAPIKey } exactly (non-builtin branch), so the OpenAI default-key path is preserved. Only kimi/qwen get the unconditional WithAPIKey(key)+WithAPIKeyName treatment that blocks the OPENAI_API_KEY default — which is the stated intent.
  • Registry vs override key rules are consistent across languages: resolveModel reads GADFLY_API_KEY only after the baseURL == "" early return (model.go:148), and preflight.sh mirrors this — it skips when a base URL is set and treats GADFLY_API_KEY as non-substitutive on the registry path. The whitespace handling agrees on the set/unset decision (tr -d '[:space:]' vs Go TrimSpace).
  • Preflight table semantics (scripts/preflight.sh): indirect expansion ${!key_env}, the claude-code exemption vs. non-exempt opencode, and the deliberate omission of google (two-variable) all match the accompanying table test's expectations. MODEL_PROVIDER in run.sh:75 yields qwen/opencode/etc. as the table keys expect.
  • Own-key fallback (model.go:77-81): keyless built-in endpoint falls back to its own QWEN_API_KEY/KIMI_API_KEY, never a foreign key — matching the README's masked-secret guidance and TestBuiltinCompatOwnKeyFallback.
  • YAML forwarding is consistent: reusable workflow declares both secrets (review-reusable.yml:85-91) and forwards them into the container env (:143-144); the caller forwards them (adversarial-review.yml).

Could not verify directly (outside the sandbox): majordomo's openai.WithAPIKeyName/WithName signatures and its client-side fail-closed-on-empty-key behavior. These are the linchpin of assertFailsClosed and the registry-path built-ins — but both are self-checked by the PR's own tests, so a mismatch fails CI rather than shipping. No correctness gap I can act on.

One aside, outside my lens: the reusable workflow still pins sha-b37cd09 (review-reusable.yml:116), which predates this binary — but the PR description explicitly lists bumping that pin as required step 1 of enablement, so it's intended, not a defect.

🧹 Code cleanliness & maintainability — Minor issues

I've verified both findings against the actual source. Let me finalize.

Finding 1 (preflight.sh duplicated rationale): Confirmed. Lines 55–60 and 84–88 both explain the ollama-cloud OLLAMA_API_KEY-vs-OLLAMA_CLOUD_API_KEY mechanism and both close with the near-verbatim sentence "If that copy ever moves after [this/the] call, this arm reports a missing key for a configured run." Genuine duplication; one copy can rot. Notably the commit message even claims the rationale is "stated once rather than in two comment blocks," yet two blocks remain — so the finding holds.

Finding 2 (isBuiltinCompatProvider): Dropped. The factual base (single-use slices.Contains wrapper at model.go:49, called only at :71) is accurate, but the suggestion is contradicted by the code's own documented intent — the wrapper deliberately "mirrors isOpenAICompatProvider … so both memberships are asked the same way," a symmetry inlining would break, and it's pinned by a test. The "8-symbol cluster" premise is also miscounted (the two slices.Contains wrappers are already among the seven named identifiers, so it's 7, not 8). This is a subjective style preference against a documented, tested design choice — reaching, not a defect.


I've verified the changed code against the checked-out tree. Reviewing strictly through the code cleanliness & maintainability lens.

VERDICT: Minor issues

The core refactor is a cleanliness win: the two previously-duplicated case "openai", "openai-compatible" blocks in resolveModel and endpointProvider are collapsed behind one shared predicate (isOpenAICompatProvider) and one shared options builder (openAICompatOptions), and the drift risk is pinned by tests (TestEndpointProviderNamesAreAllAccepted). That's the right direction. One small thing:

  • scripts/preflight.sh:55-60 and :84-88 — duplicated rationale comment. The ollama-cloud "checked on OLLAMA_API_KEY but hinted as OLLAMA_CLOUD_API_KEY … if that copy ever moves after this call, this arm reports a missing key for a configured run" paragraph is repeated almost verbatim in two adjacent blocks — one at the check site inside gadfly_preflight_key, one in the _gadfly_preflight_table doc. Since the behavior is driven by the single table row ollama-cloud:OLLAMA_API_KEY:OLLAMA_CLOUD_API_KEY, one of the two copies should be dropped (or reduced to a one-line pointer) so the explanation can't rot out of sync with the other. Trivial.

Everything else I checked is clean: no dead code (isBuiltinCompatProvider/builtinCompatKeyEnv/builtinCompatProviders are all referenced at model.go:71,78-79 and in tests), the endpointProviderNames const stays consistent with both switch statements, and the new comments — though dense — match the established verbosity of the surrounding file. The env: { GOPROXY: "off" } repetition across the build-image.yml steps is unavoidable (a job-level env would break the fetch step that needs the proxy on), so it's not a copy-paste defect.

No maintainability issue here rises to blocking.

Performance — No material issues found

Verified. resolveModel runs once per binary invocation at startup (main.go:171, not in any loop); endpointProvider runs during one-time endpoint parsing; the new openAICompatProviders/builtinCompatProviders slices are 4- and 2-element constants queried via slices.Contains at setup time; preflight.sh runs once per model in run.sh.

Review — Performance lens

VERDICT: No material issues found

This PR wires up two new majordomo built-in providers (Qwen/Kimi). Everything it touches is startup/config-time or CI-time code, not a runtime hot path:

  • cmd/gadfly/model.goisOpenAICompatProvider/isBuiltinCompatProvider do slices.Contains over 4- and 2-element constant slices, called from resolveModel/endpointProvider, which execute once per process at startup (verified: resolveModel is the single call at main.go:171, outside any loop). No measurable cost, no allocations of concern.
  • scripts/preflight.shgadfly_preflight_key regenerates an 8-row table and awks it once per invocation; called once per model in run.sh. Trivial and bounded.
  • .gitea/workflows/build-image.yml — the grep -rqF … "$HOME" credential-scrub walks the home dir once in CI; one-time, bounded, not a product hot path (and outside my lens anyway).
  • Testshttptest servers, exec.Command("bash", …) sourcing preflight.sh: test-only, not shipped runtime.

No N+1, no work inside hot loops, no unbounded growth, no missing pagination, no avoidable quadratic behavior, no new blocking calls on a hot path. Nothing in my lane to flag — I'm not going to invent a micro-optimization on constant-size startup code.

🧯 Error handling & edge cases — No material issues found

The Bash sandbox is restricted under plan mode, but I've verified the relevant code by reading it directly. My assessment of the shell constructs (indirect expansion ${!key_env:-} combined with :- default, grep tri-state exit handling, trap ... EXIT cleanup) is based on the source. Here is my review.


VERDICT: No material issues found (lens: 🧯 Error handling & edge cases)

I reviewed the unhappy paths this PR introduces and found them handled carefully — in several places more carefully than typical. Notes on what I checked and confirmed sound:

  • cmd/gadfly/model.go:68 openAICompatOptions — missing-key path fails closed. For kimi/qwen with no explicit key and no own env var, it appends WithAPIKey("") + WithAPIKeyName(keyHint) rather than silently letting openai.New inherit OPENAI_API_KEY. TestBuiltinCompatProvidersNeverInheritOpenAIKey (model_test.go) asserts via a local httptest server that no request escapes carrying the foreign key and that the error names the variable to set — a genuine fail-closed, not fail-open. Verified by reading both the option builder and the assertion helper.
  • scripts/preflight.sh:74 indirect expansion under set -u. ${!key_env:-} is valid bash (indirection then :- default), so an unset provider key does not trip set -u in run.sh's set -uo pipefail. All other expansions (${2:-}, ${GADFLY_BASE_URL:-}) are guarded. Empty-string key is correctly treated as missing (the -n test), and the whitespace-trim of GADFLY_BASE_URL (line 49) matches resolveModel's strings.TrimSpace so the two agree on "unset."
  • scripts/run.sh:199 exit-code capture. rc=$? is taken immediately after the command substitution, and with no set -e a non-zero reviewer exit is turned into a skip/notice comment rather than aborting — consistent with the advisory-only invariant. Temp files (DIFF_FILE, ERR_FILE) are rm -f'd on the success path.
  • .gitea/workflows/build-image.yml credential scrub. trap 'rm -f "$GIT_CONFIG_GLOBAL"' EXIT cleans up on any exit (not just success), and the grep leak-check distinguishes grep's three exit states (0 found → fail, 1 clean, ≥2 error → fail-closed), so a grep that cannot read the tree does not silently pass. This is the correct handling of an easily-swallowed error.
  • scripts/preflight.sh:39 engine-spec exemptions. claude-code/claude-code/* are exempted (they carry their own auth), while opencode is deliberately not — matching the table and the tests. Providers absent from the table (google, local ollama, llama-swap, foreman) correctly return "" (no false skip).

One benign observation, not a defect and outside a strict error-handling defect (mentioning only for completeness, not reporting): on the GADFLY_BASE_URL override path, openAICompatOptions does fall back to a built-in's own QWEN_API_KEY/KIMI_API_KEY (model.go:78), whereas the doc comment at model.go:154-158 says those are "read only on the registry path." The behavior is safe (same-vendor key, still fails closed if both are unset) — the comment is just slightly stronger than the code. Not worth a change on its own.

Nothing in my lens is materially wrong.

claude-code/sonnet (claude-code) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

VERDICT: Minor issues

  • .gitea/workflows/build-image.yml (new test job, ~lines 53-104) — new pull_request-triggered job executes untrusted PR-authored code (go test -count=1 ./...) with no permissions: block and no actor gate, and this is a genuinely new code-execution surface for the repo (confirmed: Dockerfile only ever ran go build, never go test/go vet on PR code).
    Verified directly: the job triggers on pull_request: [opened, synchronize, reopened] (build-image.yml:30-37), declares no permissions: block, and has no actor/collaborator check before go test runs. go test executes every init() and test function in the PR branch — genuinely new relative to the pre-existing build-and-push job, which only docker buildx builds (and that stage's Dockerfile runs go build, not go test — confirmed by reading Dockerfile:1-24).
    However, two of the draft's supporting comparisons don't hold up and should be corrected:
    • The claim that this is "unlike every other secret-touching job in this repo" re: actor gating is not accurate. adversarial-review.yml's "Security: only trusted users..." gate (lines 34-41) applies only to the issue_comment trigger path — its own comment says "pull_request + workflow_dispatch are already trusted." Checking entrypoint.sh:145-152 confirms the pull_request case does not call actor_allowed; only issue_comment does. That workflow already runs unrestricted on plain PR open/sync/reopen, forwarding several secrets (OLLAMA_CLOUD_API_KEY, CLAUDE_CODE_OAUTH_TOKEN, QWEN_API_KEY, etc.) with no actor gate — so the new test job's lack of a gate on the same trigger type is consistent with, not a deviation from, the existing repo pattern.
    • The claim that the job is "unlike every other job" for lacking a permissions: block is also not accurate: build-image.yml has no top-level or job-level permissions: block anywhere, including the pre-existing build-and-push job, which also handles the same push-capable REGISTRY_PASSWORD credential (grepped the whole file — zero permissions: occurrences before this PR).
    • The "--add-host gitea.stevedudenhoeffer.com:192.168.0.134... sibling job" citation is present (build-image.yml:208) but it's inside build-and-push's docker buildx build invocation specifically — the test job itself has no LAN/network-privileged step. The general point that this runs on a persistent, self-hosted act_runner (per CLAUDE.md's own description of runner caching behavior) still stands as background context, just not via that specific citation.
      Net: the underlying observation — this PR adds the repo's first job that actually executes PR-authored code rather than compiling it or feeding it to an LLM as text — is real and worth a second look, since the test job's actual secret exposure is narrow (only REGISTRY_USER/REGISTRY_PASSWORD, both deliberately scrubbed via trap + whole-$HOME grep before go test runs) and the missing-gate pattern matches the repo's existing tolerance for pull_request-triggered secret use elsewhere, rather than being a unique outlier. Worth considering a permissions: contents: read block (defense-in-depth against whatever this Gitea instance's default token grants) given code execution is a materially different risk than credential use or text-only diff review, but this is not the "codebase already does this elsewhere and skipped it here" gap the draft describes.
🎯 Correctness — No material issues found

VERDICT: No material issues found

I reviewed cmd/gadfly/model.go, cmd/gadfly/model_test.go, scripts/preflight.sh, scripts/preflight_test.sh, scripts/run.sh, and the workflow YAML changes with a focus on logic correctness.

What I verified:

  • openAICompatOptions (model.go:68-89): the builtin branch (kimi/qwen) unconditionally calls WithAPIKey/WithAPIKeyName even when the key is empty (fail-closed), while openai/openai-compatible only sets a key when non-empty (falls through to the client's own OPENAI_API_KEY default) — matches the stated no-cross-vendor-fallback design and is exercised by TestBuiltinCompatProvidersNeverInheritOpenAIKey/TestBuiltinCompatOwnKeyFallback.
  • Two-switch parity: confirmed resolveModel (model.go:159-161) and endpointProvider (model.go:334-340) both gate on isOpenAICompatProvider before their remaining switch, and both switches had the old openai/openai-compatible cases removed cleanly (no dead/duplicate cases).
  • run.shpreflight.sh interaction: confirmed the OLLAMA_CLOUD_API_KEYOLLAMA_API_KEY copy (run.sh:163-167) happens before gadfly_preflight_key is called (run.sh:173), which is required for the table's ollama-cloud:OLLAMA_API_KEY:OLLAMA_CLOUD_API_KEY row to behave as documented.
  • GADFLY_PROVIDER_EFF derivation (run.sh:75-78) matches Go's modelProvider() (model.go:254-263) — both take the prefix before /, else GADFLY_PROVIDER, else ollama-cloud.
  • Traced through the preflight.sh/preflight_test.sh table logic (awk row lookup, indirect expansion, whitespace-trimmed GADFLY_BASE_URL check, claude-code/opencode exemption cases) — all consistent with the accompanying test cases.
  • README's GADFLY_ENDPOINT_QWENWS example correctly resolves: endpointProvider("qwenws", "qwen|...") parses provider="qwen" from the DSN body while registering under name "qwenws", so isBuiltinCompatProvider("qwen") and the QWEN_API_KEY fallback both apply correctly even though the registered spec is qwenws/<model>.

One thing I could not verify (not reported as a finding, since I can't confirm it's wrong): openai.WithAPIKeyName and the qwen/kimi registry built-ins are new majordomo APIs pulled in by the go.mod/go.sum bump in this diff, and I have no network access to the private majordomo module to confirm they exist with this exact signature/behavior. If the bump doesn't actually match what's assumed here, this fails at compile/build time rather than misbehaving silently — worth confirming CI (the new test job) actually goes green before merge, per the PR's own note that the dependency isn't merged yet.

🧹 Code cleanliness & maintainability — Minor issues

VERDICT: Minor issues

  • .gitea/workflows/build-image.yml:78-126 — The new test job's "Fetch private modules" step embeds a ~49-line, non-trivial bash routine (temp GIT_CONFIG_GLOBAL, trap-based cleanup, base64 auth-header construction, then a $HOME-wide grep scrub-verification with three-way exit-code handling) directly in the YAML run: | block. Confirmed by reading the full file: the workflow's other run: steps (Set up Docker Buildx, Log in to the registry, Compute tags at lines 162-181, the two Discord notifiers, Build and push) are all short, single-purpose, and straightforward by comparison — Compute tags, the longest of them, is ~18 simple lines with no control-flow subtleties like this one's trap/exit-code handling. Confirmed there is no existing script for this logic (ls scripts/ shows only preflight.sh, preflight_test.sh, run.sh, status-board.sh, system-prompt.txt) — so unlike scripts/preflight.sh, which this same PR extracted specifically to be sourced by both run.sh and preflight_test.sh for unit testing, this security-sensitive credential-scrub logic exists only as an inline YAML string, unlintable and unexercisable outside a full CI run.

Everything else in the diff — cmd/gadfly/model.go's consolidation of the two openai-compat switch arms behind isOpenAICompatProvider/openAICompatOptions, the new scripts/preflight.sh, and the README/workflow doc updates — is clean, non-duplicative, and consistent with the surrounding code's style. No dead code, no confusing naming, no leaky abstractions found there.

Performance — No material issues found

VERDICT: No material issues found

Reviewed through the performance lens. This PR is entirely provider/credential wiring: two new provider names threaded through existing resolveModel/endpointProvider switches (cmd/gadfly/model.go), a shared openAICompatOptions helper, a new bash credential pre-flight table (scripts/preflight.sh), a new CI test job, README/workflow doc updates, and a majordomo dependency bump.

Checked specifically for the things in scope for this lens:

  • resolveModel()/endpointProvider() are called exactly once per model process (verified: single call site at cmd/gadfly/main.go:171; each model in the swarm is a separate OS process per the concurrency model in CLAUDE.md), so the added slices.Contains calls over 2–4 element slices and the extra os.Getenv/string-concat in openAICompatOptions (cmd/gadfly/model.go:66-85) are not hot-path — negligible one-time cost.
  • gadfly_preflight_key (scripts/preflight.sh) forks a subshell/awk to scan an 8-row table, but is invoked once per model in scripts/run.sh:173, not per lens or per LLM call — no meaningful overhead.
  • The new test: job in .gitea/workflows/build-image.yml runs alongside (not gating) build-and-push, so it doesn't add serial latency to the image publish path.
  • No new loops over PRs/findings/diffs, no new network calls in a loop, no unbounded growth, no missing pagination — the diff doesn't touch any of gadfly's actual hot paths (agent loop, compaction, diff pagination, fan-out concurrency).

Nothing to flag in this lens.

🧯 Error handling & edge cases — No material issues found

VERDICT: No material issues found

I reviewed this diff specifically for ignored/swallowed errors, missing cleanup, panics on bad input, and unhandled edge cases (nil/empty, zero/negative, boundary conditions) in the unhappy paths this PR introduces.

What I checked and verified against the actual checked-out code (cmd/gadfly/model.go, scripts/preflight.sh, scripts/run.sh, .gitea/workflows/build-image.yml):

  • openAICompatOptions (model.go:68-89): with no explicit key and no own-vendor env var set, it still appends openai.WithAPIKey("") + openai.WithAPIKeyName(keyHint) rather than silently doing nothing or falling back to a foreign key — the empty-key/fail-closed path is deliberate and is exercised by TestBuiltinCompatProvidersNeverInheritOpenAIKey (model_test.go:229-273), which asserts both no leaked Authorization header and a non-nil error naming the hint variable.
  • gadfly_preflight_key (preflight.sh:26-79): empty-string keys, whitespace-only GADFLY_BASE_URL, unmatched providers (fails open to "no check" by design, not silently blocking), and the claude-code exemption are all handled and match the accompanying table test (preflight_test.sh). I traced the OLLAMA_CLOUD_API_KEYOLLAMA_API_KEY copy in run.sh:165-167 and confirmed it happens before the gadfly_preflight_key call at run.sh:173, so the hint-vs-check variable mismatch documented in the comment can't actually fire as written.
  • build-image.yml credential scrub (lines 74-126): trap 'rm -f "$GIT_CONFIG_GLOBAL"' EXIT is registered immediately after mktemp, before any command that can fail under set -e, so a failed go mod download still cleans up. The grep-based post-scrub check explicitly distinguishes grep's three exit codes (0/1/≥2) and fails closed on an error rather than treating "grep couldn't run" as "nothing found" — a real fail-open bug this avoids.
  • Two-switch parity (resolveModel / endpointProvider) is enforced by a shared predicate (isOpenAICompatProvider) plus TestOpenAICompatProvidersResolveOnBothPaths and TestEndpointProviderNamesAreAllAccepted, so the previously-described "works one way, errors the other" class of bug is now test-pinned.

I looked for but did not find a genuine gap: empty/whitespace inputs, missing keys, and the provider/model boundary cases are all deliberately tested (preflight_test.sh has ~25 cases; model_test.go adds several targeted tests for exactly this lens). The one asymmetry I traced further — a bare opencode MODEL combined with an operator-set GADFLY_PROVIDER other than ollama-cloud could preflight-check the wrong variable — is unchanged pre-existing logic in run.sh:75-78, not introduced by this diff, so I'm not reporting it.

glm-5.2:cloud (ollama-cloud) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — Minor issues

The finding is confirmed. Let me verify the exact behavior once more by confirming the keyHint flow on the GADFLY_ENDPOINT_* path when both the explicit key and own-env are absent.

I've verified:

  1. model.go:337-338endpointProvider passes keyHint = "GADFLY_ENDPOINT_"+strings.ToUpper(name) into openAICompatOptions.
  2. model.go:77-81 — on the isBuiltinCompatProvider branch, keyHint is only reassigned to builtinCompatKeyEnv(provider) inside the if own := os.Getenv(...); own != "" block. When both the explicit DSN key field is absent and the provider's own env (QWEN_API_KEY/KIMI_API_KEY) is unset, keyHint stays "GADFLY_ENDPOINT_EP".
  3. model.go:82 — that hint is passed to openai.WithAPIKeyName, so the resulting missing-key failure message names the Gitea variable holding the endpoint URL.
  4. README.md:103-106 — explicitly warns that GADFLY_ENDPOINT_* are Gitea variables, not masked, and that the key must stay out of them; the secure fallback is the masked QWEN_API_KEY/KIMI_API_KEY secret.
  5. model_test.go:286assertFailsClosed(t, m, seen, foreign, "GADFLY_ENDPOINT_EP") pins the insecure hint, exactly as the draft claims.

So the hint in the genuine failure case (where the operator actually needs the pointer) names the non-masked endpoint variable rather than the masked secret — contradicting the README's own warning. The proposed fix (set keyHint = builtinCompatKeyEnv(provider) unconditionally before the own-env check) is sound, and the test's expected hint would need updating.


Verdict: Minor issues

  • cmd/gadfly/model.go:82 / cmd/gadfly/model.go:337-338 — missing-key hint steers operators toward an unmasked credential variable. On the GADFLY_ENDPOINT_* path, when a builtin (qwen/kimi) has no explicit |<key> field and its own env var (QWEN_API_KEY/KIMI_API_KEY) is unset, openAICompatOptions leaves keyHint as "GADFLY_ENDPOINT_"+strings.ToUpper(name) (set at model.go:338, never reassigned because the reassignment at model.go:78-80 is guarded by own != ""). The resulting skip/error message names the Gitea variable that holds the endpoint URL — which, per README.md:103-106's own warning ("Leave the key out of the endpoint var… Gitea variables are not masked"), is exactly where a credential must not go. The intended secure path (and the fallback the code supports) is the masked QWEN_API_KEY/KIMI_API_KEY secret, but the hint only names that secret when it is already set (model.go:78-80) — i.e., never in the failure case where the operator actually needs the pointer. The test TestBuiltinCompatProvidersNeverInheritOpenAIKey at model_test.go:286 pins wantHint="GADFLY_ENDPOINT_EP", baking in the insecure steering. Fix: for isBuiltinCompatProvider, set keyHint = builtinCompatKeyEnv(provider) unconditionally (before the own-env check), so the failure message always points at the masked secret; update the test's expected hint accordingly.
🎯 Correctness — No material issues found

Verdict: No material issues found (with one unverified dependency-semantics risk noted)

I reviewed the change through the correctness lens, verifying against the checked-out code (cmd/gadfly/model.go, scripts/run.sh, scripts/preflight.sh, scripts/preflight_test.sh, go.mod).

What I verified as correct:

  • The two provider switches (resolveModel GADFLY_BASE_URL path at model.go:159 and endpointProvider GADFLY_ENDPOINT_* path at model.go:334) both route kimi/qwen through the shared isOpenAICompatProvider predicate and openAICompatOptions, so the "resolves one way, errors the other" failure mode is genuinely closed. endpointProviderNames is consistent with both switches (every listed name — including ollama-cloud, gemini, all llama-swap spellings — has a matching case in both).
  • builtinCompatKeyEnv derives QWEN_API_KEY/KIMI_API_KEY correctly (model.go:56); both names have no hyphen so the ReplaceAll is a no-op and the derivation is sound.
  • The preflight claude-code exemption (preflight.sh) correctly short-circuits before the table lookup, so a bare claude-code whose provider falls back to ollama-cloud is not wrongly skipped; opencode/open-code are deliberately not exempted and do hit the OLLAMA_API_KEY/OLLAMA_CLOUD_API_KEY row. The run.sh ordering (OLLAMA_CLOUD_API_KEY → OLLAMA_API_KEY copy at line 165 before the preflight call at 173) matches the assumption documented in preflight.sh.
  • The whitespace-only GADFLY_BASE_URL handling in preflight (tr -d '[:space:]') matches resolveModel's strings.TrimSpace semantics — both treat it as unset → registry path. Correct.
  • For openai/openai-compatible with no explicit key, openAICompatOptions appends no WithAPIKey, preserving openai.New's OPENAI_API_KEY default — which is the correct key for those names. Correct.

One risk I could not verify (and per instructions flag as unverified rather than assert as a bug):

  • The entire no-cross-vendor-leak guarantee rests on majordomo's openai.New treating an explicitly-passed openai.WithAPIKey("") as "set to empty / fail" rather than "unset → fall back to the OPENAI_API_KEY default," and on openai.WithAPIKeyName(...) surfacing that name in the resulting error. The repo has no vendored copy of majordomo (no vendor/, module not checked in), so I could not open the openai provider source to confirm these semantics. TestBuiltinCompatProvidersNeverInheritOpenAIKey asserts this behavior, so if the test passes the assumption holds — but from this repo alone I cannot independently confirm it. If WithAPIKey("") is ignored as "unset," the foreign-key leak the PR exists to prevent would still occur for keyless kimi/qwen on both paths.

(No confirmed in-lens defects; the one dependency-semantics risk above is unverified against the external majordomo source and therefore not emitted as a finding.)

🧹 Code cleanliness & maintainability — Minor issues

All three findings are confirmable against the actual code. Let me verify the specifics:

Finding 1 (rationale restated): Confirmed — the no-cross-vendor-fallback/credential-rule rationale appears in openAICompatOptions doc (model.go:60-67), resolveModel inline (model.go:152-158), preflight.sh header (lines 16-25), preflight.sh inline comment (lines 71-73), and preflight_test.sh section headers. Note: the draft's claim that registerEnvProviders' doc restates it is inaccurate (lines 265-288 describe the endpoint format only, not the credential rule), but the core observation holds.

Finding 2 (test overlap): Confirmed — endpointProviderNames (model.go:100-101) is a strict superset of openAICompatProviders (model.go:34), and TestEndpointProviderNamesAreAllAccepted (model_test.go:120-144) asserts both endpointProvider and resolveModel resolve for every name, subsuming the resolve assertions in TestOpenAICompatProvidersResolveOnBothPaths (model_test.go:90-113). The only unique assertion is p.Name() == "ep" (line 99-101).

Finding 3 (awk|cut|cut): Confirmed — preflight.sh:62 uses awk, lines 67-68 use cut twice. A trivial style observation on an 8-row table.

VERDICT: Minor issues

  • cmd/gadfly/model.go / scripts/preflight.sh / scripts/preflight_test.sh — the no-cross-vendor-fallback rationale is restated across multiple sites. The "two resolution paths have different credential rules / built-ins read only their own variable / no cross-vendor fallback" rationale appears in openAICompatOptions's doc comment (model.go:60-67), resolveModel's inline comment (model.go:152-158), preflight.sh's header (lines 16-25) and inline comment (lines 71-73), and preflight_test.sh's section headers. The code has a single source (the slices/table); the rationale prose does not. If the rule ever changes, these comments drift independently and silently — exactly the failure mode the slices were centralized to prevent. Suggested fix: state the rule once at openAICompatOptions and have the other sites reference it rather than re-deriving it each time. (Note: the draft's claim that registerEnvProviders' doc restates the rule is not borne out — that doc describes the endpoint format only.)

  • cmd/gadfly/model_test.go:90 TestOpenAICompatProvidersResolveOnBothPaths is largely subsumed by TestEndpointProviderNamesAreAllAccepted (line 120). The latter ranges endpointProviderNames (model.go:100-101), which is a strict superset of openAICompatProviders (model.go:34 — openai/openai-compatible/kimi/qwen … plus ollama/ollama-cloud/llama-swap/...), and asserts both endpointProvider and resolveModel resolve without error for every name — the same "both paths" assertion the former makes for the four compat names. The only assertion unique to the first test is p.Name() == "ep" (lines 99-101). Two tests covering overlapping ground for overlapping reasons is maintenance noise: a future provider added to the compat slice will touch both, and a reader has to parse both to learn what's actually pinned. Suggested fix: fold the Name() check into the stronger test and drop the redundant one, or keep just the Name() assertion as a tiny focused test and delete the duplicate resolve assertions.

  • scripts/preflight.sh:61-69 — the awk | cut | cut lookup is heavier than the data warrants. For an 8-row table, _gadfly_preflight_table | awk -F: -v p="$provider" '$1 == p {print; exit}' (line 62) then cut -d: -f2 / -f3 (lines 67-68) is three pipeline stages to read two fields from one matched row. It's justified by the gadfly_preflight_providers contract (single source, queryable), so this is a deliberate tradeoff rather than a bug — but a plain case/read on a here-string would be more readable for the lookup half while still sourcing from the same table function. Trivial; only worth changing if touching the file anyway.

No dead code, no undefined symbols, and no copy-pasted logic (the helper consolidation is genuine) — the issues above are prose/overlap rather than structural duplication.

Performance — No material issues found

VERDICT: No material issues found

  • cmd/gadfly/model.go — The new openAICompatOptions/isOpenAICompatProvider paths add negligible constant-time work (a slices.Contains over a 4-element slice) on the model-resolution path, which runs once per review invocation, not in a hot loop. No N+1, no allocations on a hot path. Verified by reading resolveModel (model.go:126) and endpointProvider (model.go:317).
  • scripts/preflight.sh / run.sh — The credential pre-flight runs once per model in run.sh:173 (one awk/cut pipeline over an 8-row static table). This is outside any inner loop and is a no-op (returns "") when not needed; no measurable cost on the review path. The subshell/awk overhead per model is trivial compared to the LLM call that follows. Verified the call site at run.sh:173.
  • The GADFLY_BASE_URL trim (tr -d '[:space:]' via printf) in preflight.sh:49 runs once per model; not a hot path.

No performance regressions introduced. The change adds at most O(1) constant work per review model, all on setup paths that already block on an LLM call.

🧯 Error handling & edge cases — Minor issues

I have enough to verify. The finding's core claim is confirmed:

  1. preflight.sh:16-25 comment explicitly states the override path's credential is "GADFLY_API_KEY (falling back to the client's own default, OPENAI_API_KEY for the openai family) and a built-in's own variable is never consulted."
  2. model.go:77-81 (openAICompatOptions) does consult the built-in's own variable via os.Getenv(builtinCompatKeyEnv(provider)) when key == "".
  3. This function is called on the GADFLY_BASE_URL override path (model.go:159-161), not just the endpoint path — so the comment's "never consulted" claim is factually contradicted by the code on exactly the path the comment is describing.
  4. TestBuiltinCompatOwnKeyFallback (model_test.go:333) confirms the own-var fallback runs via endpointProvider, and the same openAICompatOptions is shared by the GADFLY_BASE_URL path.
  5. preflight.sh:50-53 echoes "" (skips the check) whenever GADFLY_BASE_URL is set, so a keyless override-path qwen/kimi config (no GADFLY_API_KEY, no own-var) passes preflight and fails at runtime.

One nuance: the draft says the runtime failure surfaces as "five identical per-lens agent failures that name no variable." The test (TestBuiltinCompatProvidersNeverInheritOpenAIKey, "via GADFLY_BASE_URL" subtest) shows the keyless override path actually fails closed and the error names GADFLY_API_KEY. So the "names no variable" characterization is imprecise — but the substantive issue (the comment is contradicted by the code, and the override path is unpreflighted, leaving a runtime 401 instead of a preflight notice) is real and verified. I'll keep the finding with the accurate framing.

Verdict: Minor issues

  • scripts/preflight.sh:16-25 / cmd/gadfly/model.go:77-81,159-161 — the preflight's stated rationale for skipping the override path is contradicted by the code.
    The preflight comment justifies excluding the GADFLY_BASE_URL-set path by claiming that on it "a built-in's own variable is never consulted." But openAICompatOptions — called on exactly that override path at model.go:159-161does consult the built-in's own variable: os.Getenv(builtinCompatKeyEnv(provider)) at model.go:78, falling back to QWEN_API_KEY/KIMI_API_KEY when GADFLY_API_KEY is empty. TestBuiltinCompatOwnKeyFallback (model_test.go:333) pins this fallback via endpointProvider, and the same openAICompatOptions is shared by the GADFLY_BASE_URL branch. Because the "rules are unknowable on this path" premise is false, a qwen/kimi entry configured through GADFLY_BASE_URL with neither GADFLY_API_KEY nor the own-var set passes the preflight (gadfly_preflight_key echoes "" at preflight.sh:50-53) and fails at runtime — the override path does fail closed (the error names GADFLY_API_KEY, per TestBuiltinCompatProvidersNeverInheritOpenAIKey's "via GADFLY_BASE_URL" subtest), but as a runtime 401 rather than the single clear preflight notice the preflight exists to produce. The credential rules on this path are statable (GADFLY_API_KEY → own var → fail closed), so the exclusion is neither necessary nor accurately justified. Suggested fix: extend the table/gadfly_preflight_key to cover the override path, or correct the comment so the exclusion isn't justified by a claim the code contradicts.

Everything else in the draft's "checks out" paragraph verifies: ${!key_env:-} is set -u-safe; the fixed table rows can't yield an empty key_env via cut; the env -i probe captures GADFLY_TEST_MODEL before clearing env; trap … EXIT scrubs the git credential on any exit; TestBuiltinCompatProvidersNeverInheritOpenAIKey pins the keyless-built-in fail-closed behavior.

kimi-k2.6:cloud (ollama-cloud) — Minor issues

Verdict: Minor issues — 5 reviewers: security, correctness, maintainability, performance, error-handling

🔒 Security — No material issues found

No material issues found

After tracing tainted input through all changed paths and scrutinizing credential handling, the PR is clean from a security perspective. The changes are deliberately defensive:

  • Cross-vendor credential leakage is prevented. openAICompatOptions unconditionally passes an explicit key (even empty) for kimi/qwen, and tests (TestBuiltinCompatProvidersNeverInheritOpenAIKey) verify that a missing own-key fails closed rather than silently falling back to OPENAI_API_KEY. Verified by reading cmd/gadfly/model.go:68-89 and the test at cmd/gadfly/model_test.go:261-310.

  • Secrets are properly forwarded, not logged. QWEN_API_KEY and KIMI_API_KEY are declared as workflow_call secrets in .gitea/workflows/review-reusable.yml and referenced via ${{ secrets.… }} in .gitea/workflows/adversarial-review.yml. No interpolation into shell commands or logs. Verified by reading both workflow files.

  • Credential pre-flight is clear and scoped. scripts/preflight.sh only covers the registry path (where rules are exact), deliberately skips the override path (where rules differ), and exempts google because it accepts either of two variables. Verified by reading scripts/preflight.sh:13-79.

  • CI credential scrubbing is defense-in-depth. The new test job in .gitea/workflows/build-image.yml writes registry credentials to a temp GIT_CONFIG_GLOBAL, scrubs it with an EXIT trap, and greps $HOME for the raw password before running go test. The base64-encoded form lives only in the explicitly removed gitconfig file. Verified by reading .gitea/workflows/build-image.yml:48-145.

🎯 Correctness — No material issues found

Verdict: No material issues found

I scrutinized the provider-resolution logic, credential handling, and cross-vendor fallback rules through the correctness lens. The change is methodically structured with shared predicates (isOpenAICompatProvider, isBuiltinCompatProvider) to keep the two resolution paths (GADFLY_BASE_URL override and GADFLY_ENDPOINT_* parser) in sync, and the extensive test suite (TestOpenAICompatProvidersResolveOnBothPaths, TestBuiltinCompatProvidersNeverInheritOpenAIKey, TestBuiltinCompatOwnKeyFallback, TestBuiltinCompatProvidersResolveViaRegistry) exercises the exact failure modes that have regressed before.

Key correctness checks verified:

  • No cross-vendor key leakage: Built-in compat providers (kimi, qwen) unconditionally get openai.WithAPIKey(key) even when key == "", overriding openai.New's default OPENAI_API_KEY fallback. The GADFLY_BASE_URL and GADFLY_ENDPOINT_* paths both use the shared openAICompatOptions helper, so fixes can't drift between the two switches.
  • Own-key fallback works: When no explicit key is provided in a named endpoint DSN, openAICompatOptions falls back to QWEN_API_KEY / KIMI_API_KEY via builtinCompatKeyEnv, letting credentials stay in masked secrets while endpoint URLs live in unmasked vars.
  • Registry vs override paths keep separate credential rules: The pre-flight table deliberately covers only the registry path (GADFLY_BASE_URL unset), because the override path uses GADFLY_API_KEY with a different fallback semantics. This prevents the false-pass/false-skip problem described in the PR.
  • Whitespace-only GADFLY_BASE_URL treated as unset: Both Go (strings.TrimSpace) and bash (tr -d '[:space:]') agree on emptiness, so a whitespace-only value takes the registry path and gets pre-flight checked.
  • Test coverage connects Go and shell: TestOpenAICompatProvidersAreFullyWired queries the live preflight.sh to ensure every openai-compat provider has a credential arm, preventing the "half-wired" problem where code accepts a provider but the pre-flight skips it.

No logic bugs, incorrect fallbacks, or semantic errors were found.

🧹 Code cleanliness & maintainability — No material issues found

No material issues found

After reviewing the PR through the code cleanliness & maintainability lens, I find the changes well-structured. The extraction of openAICompatProviders, builtinCompatProviders, isOpenAICompatProvider, isBuiltinCompatProvider, and openAICompatOptions successfully eliminates the copy-paste hazard the PR description calls out ("fixed the first and missed the second on the first pass"). The single endpointProviderNames constant with a matching TestEndpointProviderNamesAreAllAccepted table test is a solid anti-drift pattern. The shell-to-Go cross-check (TestOpenAICompatProvidersAreFullyWired sourcing preflight.sh rather than regexing it) avoids making bash formatting a cross-language contract. Comments are substantive and explain why, not just what.

Performance — No material issues found

No material issues found

🧯 Error handling & edge cases — Minor issues

Verdict: Minor issues

  • scripts/preflight.sh:49 — Unicode whitespace edge case in GADFLY_BASE_URL "unset" detection. The bash side uses tr -d '[:space:]', which only strips POSIX whitespace characters (space, tab, newline, carriage return, form-feed, vertical tab). Go's strings.TrimSpace removes all Unicode space characters as defined by unicode.IsSpace (e.g., non-breaking space \u00A0, zero-width space, etc.). If GADFLY_BASE_URL is set to a value containing only non-ASCII whitespace, Go treats it as empty and takes the registry path, but the preflight treats it as non-empty and skips the key check. A missing key on the registry path then surfaces as a bare 401 instead of the intended skip notice — the exact failure mode the preflight was designed to prevent.
    Suggested fix: Add a comment documenting that only ASCII whitespace is considered "unset" for preflight purposes, or add a regex-based trim that closer approximates Go's behavior (e.g., sed 's/^[[:space:]]*//;s/[[:space:]]*$//'), accepting that perfect Unicode parity between bash and Go is impractical.

  • scripts/run.sh:173 / scripts/preflight.sh:39 — The claude-code case exemption in gadfly_preflight_key does not trim whitespace from the model parameter, while the Go binary trims GADFLY_MODEL. If a model spec has leading/trailing whitespace (e.g., "claude-code "), the case statement won't match and the preflight will fall through to check the provider's key, potentially requiring an OLLAMA_CLOUD_API_KEY that the engine doesn't need.
    Suggested fix: Trim whitespace from model before the case statement, e.g. model="$(printf '%s' "$model" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')".

Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.

<!-- gadfly-consensus --> ## 🪰 Gadfly review — consensus across 4 models **Verdict: Minor issues** · 10 findings (0 with multi-model agreement) <details><summary>10 single-model findings (lower confidence)</summary> | | Finding | Where | Model | Lens | |--|--|--|--|--| | 🟠 | New pull_request-triggered 'go test' job is the first job in the repo to execute PR-authored code (vs. compile-only or text-only LLM review); no permissions: block or actor gate, though this matches (not deviates from) the repo's existing pattern for other pull_request-triggered secret-bearing jobs | `.gitea/workflows/build-image.yml:53` | claude-code/sonnet | security | | 🟠 | Missing-key hint on the GADFLY_ENDPOINT_* path for builtins names the unmasked endpoint var, steering operators to store a credential in a non-masked Gitea variable (contradicts README's own warning); should name QWEN_API_KEY/KIMI_API_KEY instead | `cmd/gadfly/model.go:82` | glm-5.2:cloud | security | | 🟠 | Override-path built-ins (GADFLY_BASE_URL set) are not pre-flighted; the stated rationale ('a built-in's own variable is never consulted on this path') is contradicted by openAICompatOptions consulting os.Getenv(builtinCompatKeyEnv(provider)) at model.go:78 on exactly the GADFLY_BASE_URL override path (model.go:159-161), so a keyless qwen/kimi override config passes preflight and fails at runtime as a 401 instead of a preflight notice | `scripts/preflight.sh:16` | glm-5.2:cloud | error-handling | | 🟡 | Complex credential-scrub bash embedded inline in YAML instead of extracted to a testable script, inconsistent with how preflight.sh was factored out in the same PR | `.gitea/workflows/build-image.yml:78` | claude-code/sonnet | maintainability | | 🟡 | No-cross-vendor-fallback rationale is restated in multiple places across Go and shell; prose has no single source of truth the way the code does | `cmd/gadfly/model.go:60` | glm-5.2:cloud | maintainability | | 🟡 | TestOpenAICompatProvidersResolveOnBothPaths is mostly redundant with the stronger TestEndpointProviderNamesAreAllAccepted (only the Name() check is unique) | `cmd/gadfly/model_test.go:90` | glm-5.2:cloud | maintainability | | 🟡 | Untrimmed model parameter in claude-code case: whitespace in GADFLY_MODEL bypasses the claude-code exemption and falls through to provider key check | `scripts/preflight.sh:39` | kimi-k2.6:cloud | error-handling | | 🟡 | Unicode whitespace mismatch: bash tr -d [:space:] strips only POSIX whitespace while Go strings.TrimSpace strips all Unicode space, causing preflight to skip key checks for registry-path runs when GADFLY_BASE_URL contains only non-ASCII whitespace | `scripts/preflight.sh:49` | kimi-k2.6:cloud | error-handling | | ⚪ | awk\|cut\|cut lookup is heavier than the 8-row table warrants; deliberate tradeoff but harder to read than a case/read | `scripts/preflight.sh:62` | glm-5.2:cloud | maintainability | | ⚪ | Duplicated ollama-cloud hint-vs-copy rationale comment in two adjacent blocks (also at lines 55-60); one copy can rot | `scripts/preflight.sh:84` | claude-code/opus | maintainability | </details> <details><summary>Per-model detail</summary> <details><summary><b>claude-code/opus</b> (claude-code) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> I've now examined the security-critical surfaces of this PR: the credential-resolution logic in `model.go`, the new CI credential-scrub `test` job, the pre-flight table, and the endpoint/key plumbing. I could not read majordomo's source (sandbox restricts me to the repo), so I reasoned about that one boundary and note it explicitly below. ## Review — 🔒 Security **VERDICT: No material issues found** This change is unusually careful about exactly the things my lens cares about — cross-vendor credential leakage and secret exposure in CI. Findings I checked: - **Cross-vendor key leakage (the core risk) is handled correctly.** `openAICompatOptions` (`cmd/gadfly/model.go:68`) passes `openai.WithAPIKey(key)` *unconditionally* — including empty — for `kimi`/`qwen` (`builtinCompatProviders`), specifically so `openai.New`'s built-in `OPENAI_API_KEY` default can't put an OpenAI key on the wire to Alibaba/Moonshot. Both construction paths (`resolveModel` base-URL override at `model.go:157` and `endpointProvider` at `model.go:333`) route through the *one* helper, closing the historical "fixed on one path, still leaking on the other" gap. `TestBuiltinCompatProvidersNeverInheritOpenAIKey` asserts fail-closed on both paths against a live `httptest` server that records `Authorization` headers — a genuine adversarial test, not a smoke test. - *Caveat I could not personally verify:* the whole scheme rests on majordomo's `openai.WithAPIKey("")` **overriding** (not being ignored as "unset") the `OPENAI_API_KEY` fallback. I can't read the majordomo module from this sandbox. If that assumption were wrong it would be a real cross-vendor credential leak — but `TestBuiltinCompatProvidersNeverInheritOpenAIKey` would fail in that case, and this PR *also* adds the CI `test` job that runs it, so the assumption is now gated. Reasonable. - **Endpoint-var key exposure is correctly steered away from.** The README warning (`README.md`) and the own-key fallback (`model.go:77-81`, falling back to `QWEN_API_KEY`/`KIMI_API_KEY`, never a foreign var) correctly keep the credential in a *masked* secret rather than the unmasked `GADFLY_ENDPOINT_*` Gitea variable. `TestBuiltinCompatOwnKeyFallback` confirms the own-vendor-only fallback. This is a security *improvement*. - **The new CI `test` job scrubs the push-capable registry credential before running PR code — verified fail-closed.** In `build-image.yml`, the credential lives only in `GIT_CONFIG_GLOBAL=$(mktemp)`, is removed by both an explicit `rm` and a `trap … EXIT`, and the secret-bearing `env:` is scoped to the "Fetch private modules" step only — later steps (`go build/test`, which run attacker-authored fork-PR code) never carry it. The post-scrub `grep -rqF` verification distinguishes grep's three exit codes and treats the ≥2 "couldn't run" case as fail-closed (`build-image.yml:121-125`) — the fail-open trap here is explicitly avoided. `go mod download` (which uses the live credential) executes no repository code, so there's no RCE window with the secret present. - **No injection / SSRF from untrusted input.** The provider/model/base-URL values that flow into `preflight.sh` (indirect expansion `${!key_env:-}`, `awk -F:`) and into the endpoint constructors all originate from operator-set repo vars/secrets, not from the PR under review. The reviewed diff is passed to the agent as data, not shell. No tainted-input path. Minor residual notes (not worth a finding): the `grep` scrub scans `$HOME` while `GIT_CONFIG_GLOBAL` lives in `$TMPDIR` — but its removal is directly asserted by `test ! -e`, so this is belt-and-suspenders, not a gap; and the base64 `Authorization` header is a derived value Gitea won't mask, but no step prints it. Neither is exploitable as written. Nothing in the security lane rises to reporting. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> I've verified the substance of this PR against the checked-out code. Here's my correctness review. ## VERDICT: No material issues found I focused on the logic introduced by this change and re-derived the credential-routing behavior across all three surfaces (Go `resolveModel`/`endpointProvider`, `preflight.sh`, and the workflow YAMLs). It holds together. What I checked and confirmed: - **`builtinCompatKeyEnv` derivation** (`cmd/gadfly/model.go:56`): `qwen` → `QWEN_API_KEY`, `kimi` → `KIMI_API_KEY` via `ToUpper(ReplaceAll(p,"-","_"))+"_API_KEY"`. Correct for both built-ins. - **The two-switch parity claim** (`model.go:159` and `:334`): both paths now gate the openai-compat family through the single `isOpenAICompatProvider` predicate over the shared `openAICompatProviders` slice, and both fall through to switches that between them accept every spelling in `endpointProviderNames` (`openai`/`openai-compatible`/`kimi`/`qwen`/`ollama`/`ollama-cloud`/`llama-swap(s)`/`llamaswap(s)`/`foreman`/`anthropic`/`google`/`gemini`). I read the full switches — `google`/`gemini` and all four llama-swap spellings are handled in *both* functions, so `TestEndpointProviderNamesAreAllAccepted` will not silently pass on a name one path rejects. - **No behavior change for `openai`/`openai-compatible`**: the refactor into `openAICompatOptions` reproduces the old `if key != "" { WithAPIKey }` exactly (non-builtin branch), so the OpenAI default-key path is preserved. Only `kimi`/`qwen` get the unconditional `WithAPIKey(key)+WithAPIKeyName` treatment that blocks the `OPENAI_API_KEY` default — which is the stated intent. - **Registry vs override key rules are consistent across languages**: `resolveModel` reads `GADFLY_API_KEY` only after the `baseURL == ""` early return (`model.go:148`), and `preflight.sh` mirrors this — it skips when a base URL is set and treats `GADFLY_API_KEY` as non-substitutive on the registry path. The whitespace handling agrees on the set/unset decision (`tr -d '[:space:]'` vs Go `TrimSpace`). - **Preflight table semantics** (`scripts/preflight.sh`): indirect expansion `${!key_env}`, the `claude-code` exemption vs. non-exempt `opencode`, and the deliberate omission of `google` (two-variable) all match the accompanying table test's expectations. `MODEL_PROVIDER` in `run.sh:75` yields `qwen`/`opencode`/etc. as the table keys expect. - **Own-key fallback** (`model.go:77-81`): keyless built-in endpoint falls back to its *own* `QWEN_API_KEY`/`KIMI_API_KEY`, never a foreign key — matching the README's masked-secret guidance and `TestBuiltinCompatOwnKeyFallback`. - **YAML forwarding** is consistent: reusable workflow declares both secrets (`review-reusable.yml:85-91`) and forwards them into the container env (`:143-144`); the caller forwards them (`adversarial-review.yml`). Could not verify directly (outside the sandbox): majordomo's `openai.WithAPIKeyName`/`WithName` signatures and its client-side fail-closed-on-empty-key behavior. These are the linchpin of `assertFailsClosed` and the registry-path built-ins — but both are self-checked by the PR's own tests, so a mismatch fails CI rather than shipping. No correctness gap I can act on. One aside, outside my lens: the reusable workflow still pins `sha-b37cd09` (`review-reusable.yml:116`), which predates this binary — but the PR description explicitly lists bumping that pin as required step 1 of enablement, so it's intended, not a defect. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> I've verified both findings against the actual source. Let me finalize. Finding 1 (preflight.sh duplicated rationale): **Confirmed.** Lines 55–60 and 84–88 both explain the ollama-cloud `OLLAMA_API_KEY`-vs-`OLLAMA_CLOUD_API_KEY` mechanism and both close with the near-verbatim sentence "If that copy ever moves after [this/the] call, this arm reports a missing key for a configured run." Genuine duplication; one copy can rot. Notably the commit message even claims the rationale is "stated once rather than in two comment blocks," yet two blocks remain — so the finding holds. Finding 2 (`isBuiltinCompatProvider`): **Dropped.** The factual base (single-use `slices.Contains` wrapper at `model.go:49`, called only at `:71`) is accurate, but the suggestion is contradicted by the code's own documented intent — the wrapper deliberately "mirrors `isOpenAICompatProvider` … so both memberships are asked the same way," a symmetry inlining would break, and it's pinned by a test. The "8-symbol cluster" premise is also miscounted (the two `slices.Contains` wrappers are already among the seven named identifiers, so it's 7, not 8). This is a subjective style preference against a documented, tested design choice — reaching, not a defect. --- I've verified the changed code against the checked-out tree. Reviewing strictly through the **code cleanliness & maintainability** lens. ## VERDICT: Minor issues The core refactor is a *cleanliness win*: the two previously-duplicated `case "openai", "openai-compatible"` blocks in `resolveModel` and `endpointProvider` are collapsed behind one shared predicate (`isOpenAICompatProvider`) and one shared options builder (`openAICompatOptions`), and the drift risk is pinned by tests (`TestEndpointProviderNamesAreAllAccepted`). That's the right direction. One small thing: - **`scripts/preflight.sh:55-60` and `:84-88` — duplicated rationale comment.** The ollama-cloud "checked on `OLLAMA_API_KEY` but hinted as `OLLAMA_CLOUD_API_KEY` … if that copy ever moves after this call, this arm reports a missing key for a configured run" paragraph is repeated almost verbatim in two adjacent blocks — one at the check site inside `gadfly_preflight_key`, one in the `_gadfly_preflight_table` doc. Since the behavior is driven by the single table row `ollama-cloud:OLLAMA_API_KEY:OLLAMA_CLOUD_API_KEY`, one of the two copies should be dropped (or reduced to a one-line pointer) so the explanation can't rot out of sync with the other. *Trivial.* Everything else I checked is clean: no dead code (`isBuiltinCompatProvider`/`builtinCompatKeyEnv`/`builtinCompatProviders` are all referenced at `model.go:71,78-79` and in tests), the `endpointProviderNames` const stays consistent with both switch statements, and the new comments — though dense — match the established verbosity of the surrounding file. The `env: { GOPROXY: "off" }` repetition across the `build-image.yml` steps is unavoidable (a job-level env would break the fetch step that needs the proxy on), so it's not a copy-paste defect. No maintainability issue here rises to blocking. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> Verified. `resolveModel` runs once per binary invocation at startup (main.go:171, not in any loop); `endpointProvider` runs during one-time endpoint parsing; the new `openAICompatProviders`/`builtinCompatProviders` slices are 4- and 2-element constants queried via `slices.Contains` at setup time; `preflight.sh` runs once per model in `run.sh`. ## Review — ⚡ Performance lens **VERDICT: No material issues found** This PR wires up two new majordomo built-in providers (Qwen/Kimi). Everything it touches is startup/config-time or CI-time code, not a runtime hot path: - **`cmd/gadfly/model.go`** — `isOpenAICompatProvider`/`isBuiltinCompatProvider` do `slices.Contains` over 4- and 2-element constant slices, called from `resolveModel`/`endpointProvider`, which execute **once per process at startup** (verified: `resolveModel` is the single call at `main.go:171`, outside any loop). No measurable cost, no allocations of concern. - **`scripts/preflight.sh`** — `gadfly_preflight_key` regenerates an 8-row table and `awk`s it once per invocation; called once per model in `run.sh`. Trivial and bounded. - **`.gitea/workflows/build-image.yml`** — the `grep -rqF … "$HOME"` credential-scrub walks the home dir once in CI; one-time, bounded, not a product hot path (and outside my lens anyway). - **Tests** — `httptest` servers, `exec.Command("bash", …)` sourcing `preflight.sh`: test-only, not shipped runtime. No N+1, no work inside hot loops, no unbounded growth, no missing pagination, no avoidable quadratic behavior, no new blocking calls on a hot path. Nothing in my lane to flag — I'm not going to invent a micro-optimization on constant-size startup code. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> The Bash sandbox is restricted under plan mode, but I've verified the relevant code by reading it directly. My assessment of the shell constructs (indirect expansion `${!key_env:-}` combined with `:-` default, grep tri-state exit handling, `trap ... EXIT` cleanup) is based on the source. Here is my review. --- **VERDICT: No material issues found** (lens: 🧯 Error handling & edge cases) I reviewed the unhappy paths this PR introduces and found them handled carefully — in several places more carefully than typical. Notes on what I checked and confirmed sound: - **`cmd/gadfly/model.go:68` `openAICompatOptions` — missing-key path fails closed.** For `kimi`/`qwen` with no explicit key and no own env var, it appends `WithAPIKey("")` + `WithAPIKeyName(keyHint)` rather than silently letting `openai.New` inherit `OPENAI_API_KEY`. `TestBuiltinCompatProvidersNeverInheritOpenAIKey` (model_test.go) asserts via a local `httptest` server that *no* request escapes carrying the foreign key **and** that the error names the variable to set — a genuine fail-closed, not fail-open. Verified by reading both the option builder and the assertion helper. - **`scripts/preflight.sh:74` indirect expansion under `set -u`.** `${!key_env:-}` is valid bash (indirection then `:-` default), so an unset provider key does not trip `set -u` in run.sh's `set -uo pipefail`. All other expansions (`${2:-}`, `${GADFLY_BASE_URL:-}`) are guarded. Empty-string key is correctly treated as missing (the `-n` test), and the whitespace-trim of `GADFLY_BASE_URL` (line 49) matches `resolveModel`'s `strings.TrimSpace` so the two agree on "unset." - **`scripts/run.sh:199` exit-code capture.** `rc=$?` is taken immediately after the command substitution, and with no `set -e` a non-zero reviewer exit is turned into a skip/notice comment rather than aborting — consistent with the advisory-only invariant. Temp files (`DIFF_FILE`, `ERR_FILE`) are `rm -f`'d on the success path. - **`.gitea/workflows/build-image.yml` credential scrub.** `trap 'rm -f "$GIT_CONFIG_GLOBAL"' EXIT` cleans up on *any* exit (not just success), and the grep leak-check distinguishes grep's three exit states (0 found → fail, 1 clean, ≥2 error → fail-closed), so a grep that cannot read the tree does not silently pass. This is the correct handling of an easily-swallowed error. - **`scripts/preflight.sh:39` engine-spec exemptions.** `claude-code`/`claude-code/*` are exempted (they carry their own auth), while `opencode` is deliberately *not* — matching the table and the tests. Providers absent from the table (`google`, local ollama, llama-swap, foreman) correctly return "" (no false skip). One benign observation, not a defect and outside a strict error-handling defect (mentioning only for completeness, not reporting): on the `GADFLY_BASE_URL` override path, `openAICompatOptions` *does* fall back to a built-in's own `QWEN_API_KEY`/`KIMI_API_KEY` (model.go:78), whereas the doc comment at model.go:154-158 says those are "read only on the registry path." The behavior is safe (same-vendor key, still fails closed if both are unset) — the comment is just slightly stronger than the code. Not worth a change on its own. Nothing in my lens is materially wrong. </details> </details> <details><summary><b>claude-code/sonnet</b> (claude-code) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> ## VERDICT: Minor issues - **`.gitea/workflows/build-image.yml` (new `test` job, ~lines 53-104) — new `pull_request`-triggered job executes untrusted PR-authored code (`go test -count=1 ./...`) with no `permissions:` block and no actor gate, and this is a genuinely new code-execution surface for the repo (confirmed: `Dockerfile` only ever ran `go build`, never `go test`/`go vet` on PR code).** Verified directly: the job triggers on `pull_request: [opened, synchronize, reopened]` (build-image.yml:30-37), declares no `permissions:` block, and has no actor/collaborator check before `go test` runs. `go test` executes every `init()` and test function in the PR branch — genuinely new relative to the pre-existing `build-and-push` job, which only `docker buildx build`s (and that stage's `Dockerfile` runs `go build`, not `go test` — confirmed by reading `Dockerfile:1-24`). However, two of the draft's supporting comparisons don't hold up and should be corrected: - The claim that this is "unlike every other secret-touching job in this repo" re: actor gating is **not accurate**. `adversarial-review.yml`'s "Security: only trusted users..." gate (lines 34-41) applies *only* to the `issue_comment` trigger path — its own comment says "`pull_request` + `workflow_dispatch` are already trusted." Checking `entrypoint.sh:145-152` confirms the `pull_request` case does **not** call `actor_allowed`; only `issue_comment` does. That workflow already runs unrestricted on plain PR open/sync/reopen, forwarding several secrets (`OLLAMA_CLOUD_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `QWEN_API_KEY`, etc.) with no actor gate — so the new test job's lack of a gate on the same trigger type is *consistent with*, not a deviation from, the existing repo pattern. - The claim that the job is "unlike every other job" for lacking a `permissions:` block is also not accurate: `build-image.yml` has **no** top-level or job-level `permissions:` block anywhere, including the pre-existing `build-and-push` job, which also handles the same push-capable `REGISTRY_PASSWORD` credential (grepped the whole file — zero `permissions:` occurrences before this PR). - The "`--add-host gitea.stevedudenhoeffer.com:192.168.0.134`... sibling job" citation is present (build-image.yml:208) but it's inside `build-and-push`'s `docker buildx build` invocation specifically — the `test` job itself has no LAN/network-privileged step. The general point that this runs on a persistent, self-hosted `act_runner` (per `CLAUDE.md`'s own description of runner caching behavior) still stands as background context, just not via that specific citation. Net: the underlying observation — this PR adds the repo's first job that actually *executes* PR-authored code rather than compiling it or feeding it to an LLM as text — is real and worth a second look, since the test job's actual secret exposure is narrow (only `REGISTRY_USER`/`REGISTRY_PASSWORD`, both deliberately scrubbed via `trap` + whole-`$HOME` grep before `go test` runs) and the missing-gate pattern matches the repo's existing tolerance for `pull_request`-triggered secret use elsewhere, rather than being a unique outlier. Worth considering a `permissions: contents: read` block (defense-in-depth against whatever this Gitea instance's default token grants) given code execution is a materially different risk than credential use or text-only diff review, but this is not the "codebase already does this elsewhere and skipped it here" gap the draft describes. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> ## VERDICT: No material issues found I reviewed `cmd/gadfly/model.go`, `cmd/gadfly/model_test.go`, `scripts/preflight.sh`, `scripts/preflight_test.sh`, `scripts/run.sh`, and the workflow YAML changes with a focus on logic correctness. What I verified: - **`openAICompatOptions` (model.go:68-89)**: the builtin branch (`kimi`/`qwen`) unconditionally calls `WithAPIKey`/`WithAPIKeyName` even when the key is empty (fail-closed), while `openai`/`openai-compatible` only sets a key when non-empty (falls through to the client's own `OPENAI_API_KEY` default) — matches the stated no-cross-vendor-fallback design and is exercised by `TestBuiltinCompatProvidersNeverInheritOpenAIKey`/`TestBuiltinCompatOwnKeyFallback`. - **Two-switch parity**: confirmed `resolveModel` (model.go:159-161) and `endpointProvider` (model.go:334-340) both gate on `isOpenAICompatProvider` before their remaining `switch`, and both switches had the old `openai`/`openai-compatible` cases removed cleanly (no dead/duplicate cases). - **`run.sh` → `preflight.sh` interaction**: confirmed the `OLLAMA_CLOUD_API_KEY` → `OLLAMA_API_KEY` copy (run.sh:163-167) happens *before* `gadfly_preflight_key` is called (run.sh:173), which is required for the table's `ollama-cloud:OLLAMA_API_KEY:OLLAMA_CLOUD_API_KEY` row to behave as documented. - **`GADFLY_PROVIDER_EFF` derivation** (run.sh:75-78) matches Go's `modelProvider()` (model.go:254-263) — both take the prefix before `/`, else `GADFLY_PROVIDER`, else `ollama-cloud`. - Traced through the `preflight.sh`/`preflight_test.sh` table logic (awk row lookup, indirect expansion, whitespace-trimmed `GADFLY_BASE_URL` check, `claude-code`/`opencode` exemption cases) — all consistent with the accompanying test cases. - README's `GADFLY_ENDPOINT_QWENWS` example correctly resolves: `endpointProvider("qwenws", "qwen|...")` parses `provider="qwen"` from the DSN body while registering under name `"qwenws"`, so `isBuiltinCompatProvider("qwen")` and the `QWEN_API_KEY` fallback both apply correctly even though the registered spec is `qwenws/<model>`. One thing I could not verify (not reported as a finding, since I can't confirm it's wrong): `openai.WithAPIKeyName` and the `qwen`/`kimi` registry built-ins are new majordomo APIs pulled in by the `go.mod`/`go.sum` bump in this diff, and I have no network access to the private majordomo module to confirm they exist with this exact signature/behavior. If the bump doesn't actually match what's assumed here, this fails at compile/build time rather than misbehaving silently — worth confirming CI (the new `test` job) actually goes green before merge, per the PR's own note that the dependency isn't merged yet. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> **VERDICT: Minor issues** - `.gitea/workflows/build-image.yml:78-126` — The new `test` job's "Fetch private modules" step embeds a ~49-line, non-trivial bash routine (temp `GIT_CONFIG_GLOBAL`, `trap`-based cleanup, base64 auth-header construction, then a `$HOME`-wide `grep` scrub-verification with three-way exit-code handling) directly in the YAML `run: |` block. Confirmed by reading the full file: the workflow's other `run:` steps (`Set up Docker Buildx`, `Log in to the registry`, `Compute tags` at lines 162-181, the two Discord notifiers, `Build and push`) are all short, single-purpose, and straightforward by comparison — `Compute tags`, the longest of them, is ~18 simple lines with no control-flow subtleties like this one's trap/exit-code handling. Confirmed there is no existing script for this logic (`ls scripts/` shows only `preflight.sh`, `preflight_test.sh`, `run.sh`, `status-board.sh`, `system-prompt.txt`) — so unlike `scripts/preflight.sh`, which this same PR extracted specifically to be sourced by both `run.sh` and `preflight_test.sh` for unit testing, this security-sensitive credential-scrub logic exists only as an inline YAML string, unlintable and unexercisable outside a full CI run. Everything else in the diff — `cmd/gadfly/model.go`'s consolidation of the two openai-compat switch arms behind `isOpenAICompatProvider`/`openAICompatOptions`, the new `scripts/preflight.sh`, and the README/workflow doc updates — is clean, non-duplicative, and consistent with the surrounding code's style. No dead code, no confusing naming, no leaky abstractions found there. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> **VERDICT: No material issues found** Reviewed through the performance lens. This PR is entirely provider/credential wiring: two new provider names threaded through existing `resolveModel`/`endpointProvider` switches (`cmd/gadfly/model.go`), a shared `openAICompatOptions` helper, a new bash credential pre-flight table (`scripts/preflight.sh`), a new CI test job, README/workflow doc updates, and a majordomo dependency bump. Checked specifically for the things in scope for this lens: - `resolveModel()`/`endpointProvider()` are called exactly once per model process (verified: single call site at `cmd/gadfly/main.go:171`; each model in the swarm is a separate OS process per the concurrency model in CLAUDE.md), so the added `slices.Contains` calls over 2–4 element slices and the extra `os.Getenv`/string-concat in `openAICompatOptions` (`cmd/gadfly/model.go:66-85`) are not hot-path — negligible one-time cost. - `gadfly_preflight_key` (`scripts/preflight.sh`) forks a subshell/`awk` to scan an 8-row table, but is invoked once per model in `scripts/run.sh:173`, not per lens or per LLM call — no meaningful overhead. - The new `test:` job in `.gitea/workflows/build-image.yml` runs alongside (not gating) `build-and-push`, so it doesn't add serial latency to the image publish path. - No new loops over PRs/findings/diffs, no new network calls in a loop, no unbounded growth, no missing pagination — the diff doesn't touch any of gadfly's actual hot paths (agent loop, compaction, diff pagination, fan-out concurrency). Nothing to flag in this lens. </details> <details><summary><b>🧯 Error handling & edge cases</b> — No material issues found</summary> **VERDICT: No material issues found** I reviewed this diff specifically for ignored/swallowed errors, missing cleanup, panics on bad input, and unhandled edge cases (nil/empty, zero/negative, boundary conditions) in the unhappy paths this PR introduces. What I checked and verified against the actual checked-out code (`cmd/gadfly/model.go`, `scripts/preflight.sh`, `scripts/run.sh`, `.gitea/workflows/build-image.yml`): - **`openAICompatOptions` (model.go:68-89)**: with no explicit key and no own-vendor env var set, it still appends `openai.WithAPIKey("")` + `openai.WithAPIKeyName(keyHint)` rather than silently doing nothing or falling back to a foreign key — the empty-key/fail-closed path is deliberate and is exercised by `TestBuiltinCompatProvidersNeverInheritOpenAIKey` (model_test.go:229-273), which asserts both no leaked `Authorization` header and a non-nil error naming the hint variable. - **`gadfly_preflight_key` (preflight.sh:26-79)**: empty-string keys, whitespace-only `GADFLY_BASE_URL`, unmatched providers (fails open to "no check" by design, not silently blocking), and the `claude-code` exemption are all handled and match the accompanying table test (`preflight_test.sh`). I traced the `OLLAMA_CLOUD_API_KEY`→`OLLAMA_API_KEY` copy in `run.sh:165-167` and confirmed it happens *before* the `gadfly_preflight_key` call at `run.sh:173`, so the hint-vs-check variable mismatch documented in the comment can't actually fire as written. - **`build-image.yml` credential scrub (lines 74-126)**: `trap 'rm -f "$GIT_CONFIG_GLOBAL"' EXIT` is registered immediately after `mktemp`, before any command that can fail under `set -e`, so a failed `go mod download` still cleans up. The grep-based post-scrub check explicitly distinguishes grep's three exit codes (0/1/≥2) and fails closed on an error rather than treating "grep couldn't run" as "nothing found" — a real fail-open bug this avoids. - **Two-switch parity** (`resolveModel` / `endpointProvider`) is enforced by a shared predicate (`isOpenAICompatProvider`) plus `TestOpenAICompatProvidersResolveOnBothPaths` and `TestEndpointProviderNamesAreAllAccepted`, so the previously-described "works one way, errors the other" class of bug is now test-pinned. I looked for but did not find a genuine gap: empty/whitespace inputs, missing keys, and the provider/model boundary cases are all deliberately tested (`preflight_test.sh` has ~25 cases; `model_test.go` adds several targeted tests for exactly this lens). The one asymmetry I traced further — a bare `opencode` MODEL combined with an operator-set `GADFLY_PROVIDER` other than `ollama-cloud` could preflight-check the wrong variable — is unchanged pre-existing logic in `run.sh:75-78`, not introduced by this diff, so I'm not reporting it. </details> </details> <details><summary><b>glm-5.2:cloud</b> (ollama-cloud) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — Minor issues</summary> The finding is confirmed. Let me verify the exact behavior once more by confirming the keyHint flow on the GADFLY_ENDPOINT_* path when both the explicit key and own-env are absent. I've verified: 1. **model.go:337-338** — `endpointProvider` passes `keyHint = "GADFLY_ENDPOINT_"+strings.ToUpper(name)` into `openAICompatOptions`. 2. **model.go:77-81** — on the `isBuiltinCompatProvider` branch, `keyHint` is only reassigned to `builtinCompatKeyEnv(provider)` *inside* the `if own := os.Getenv(...); own != ""` block. When both the explicit DSN key field is absent *and* the provider's own env (`QWEN_API_KEY`/`KIMI_API_KEY`) is unset, `keyHint` stays `"GADFLY_ENDPOINT_EP"`. 3. **model.go:82** — that hint is passed to `openai.WithAPIKeyName`, so the resulting missing-key failure message names the Gitea **variable** holding the endpoint URL. 4. **README.md:103-106** — explicitly warns that `GADFLY_ENDPOINT_*` are Gitea *variables*, **not masked**, and that the key must stay out of them; the secure fallback is the masked `QWEN_API_KEY`/`KIMI_API_KEY` secret. 5. **model_test.go:286** — `assertFailsClosed(t, m, seen, foreign, "GADFLY_ENDPOINT_EP")` pins the insecure hint, exactly as the draft claims. So the hint in the genuine failure case (where the operator actually needs the pointer) names the non-masked endpoint variable rather than the masked secret — contradicting the README's own warning. The proposed fix (set `keyHint = builtinCompatKeyEnv(provider)` unconditionally before the own-env check) is sound, and the test's expected hint would need updating. --- **Verdict: Minor issues** - **`cmd/gadfly/model.go:82` / `cmd/gadfly/model.go:337-338` — missing-key hint steers operators toward an unmasked credential variable.** On the `GADFLY_ENDPOINT_*` path, when a builtin (qwen/kimi) has no explicit `|<key>` field *and* its own env var (`QWEN_API_KEY`/`KIMI_API_KEY`) is unset, `openAICompatOptions` leaves `keyHint` as `"GADFLY_ENDPOINT_"+strings.ToUpper(name)` (set at model.go:338, never reassigned because the reassignment at model.go:78-80 is guarded by `own != ""`). The resulting skip/error message names the Gitea **variable** that holds the endpoint URL — which, per README.md:103-106's own warning ("Leave the key out of the endpoint var… Gitea variables are not masked"), is exactly where a credential must *not* go. The intended secure path (and the fallback the code supports) is the masked `QWEN_API_KEY`/`KIMI_API_KEY` secret, but the hint only names that secret when it is *already set* (model.go:78-80) — i.e., never in the failure case where the operator actually needs the pointer. The test `TestBuiltinCompatProvidersNeverInheritOpenAIKey` at model_test.go:286 pins `wantHint="GADFLY_ENDPOINT_EP"`, baking in the insecure steering. Fix: for `isBuiltinCompatProvider`, set `keyHint = builtinCompatKeyEnv(provider)` unconditionally (before the own-env check), so the failure message always points at the masked secret; update the test's expected hint accordingly. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **Verdict: No material issues found** (with one unverified dependency-semantics risk noted) I reviewed the change through the correctness lens, verifying against the checked-out code (`cmd/gadfly/model.go`, `scripts/run.sh`, `scripts/preflight.sh`, `scripts/preflight_test.sh`, `go.mod`). What I verified as correct: - The two provider switches (`resolveModel` GADFLY_BASE_URL path at `model.go:159` and `endpointProvider` GADFLY_ENDPOINT_* path at `model.go:334`) both route `kimi`/`qwen` through the shared `isOpenAICompatProvider` predicate and `openAICompatOptions`, so the "resolves one way, errors the other" failure mode is genuinely closed. `endpointProviderNames` is consistent with both switches (every listed name — including `ollama-cloud`, `gemini`, all llama-swap spellings — has a matching `case` in both). - `builtinCompatKeyEnv` derives `QWEN_API_KEY`/`KIMI_API_KEY` correctly (`model.go:56`); both names have no hyphen so the `ReplaceAll` is a no-op and the derivation is sound. - The preflight `claude-code` exemption (`preflight.sh`) correctly short-circuits before the table lookup, so a bare `claude-code` whose provider falls back to `ollama-cloud` is not wrongly skipped; `opencode`/`open-code` are deliberately *not* exempted and do hit the `OLLAMA_API_KEY`/`OLLAMA_CLOUD_API_KEY` row. The `run.sh` ordering (OLLAMA_CLOUD_API_KEY → OLLAMA_API_KEY copy at line 165 *before* the preflight call at 173) matches the assumption documented in `preflight.sh`. - The whitespace-only `GADFLY_BASE_URL` handling in preflight (`tr -d '[:space:]'`) matches `resolveModel`'s `strings.TrimSpace` semantics — both treat it as unset → registry path. Correct. - For `openai`/`openai-compatible` with no explicit key, `openAICompatOptions` appends no `WithAPIKey`, preserving `openai.New`'s `OPENAI_API_KEY` default — which is the *correct* key for those names. Correct. One risk I could not verify (and per instructions flag as unverified rather than assert as a bug): - The entire no-cross-vendor-leak guarantee rests on majordomo's `openai.New` treating an explicitly-passed `openai.WithAPIKey("")` as "set to empty / fail" rather than "unset → fall back to the OPENAI_API_KEY default," and on `openai.WithAPIKeyName(...)` surfacing that name in the resulting error. The repo has no vendored copy of majordomo (no `vendor/`, module not checked in), so I could not open the `openai` provider source to confirm these semantics. `TestBuiltinCompatProvidersNeverInheritOpenAIKey` asserts this behavior, so if the test passes the assumption holds — but from this repo alone I cannot independently confirm it. If `WithAPIKey("")` is ignored as "unset," the foreign-key leak the PR exists to prevent would still occur for keyless `kimi`/`qwen` on both paths. (No confirmed in-lens defects; the one dependency-semantics risk above is unverified against the external majordomo source and therefore not emitted as a finding.) </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — Minor issues</summary> All three findings are confirmable against the actual code. Let me verify the specifics: **Finding 1** (rationale restated): Confirmed — the no-cross-vendor-fallback/credential-rule rationale appears in `openAICompatOptions` doc (model.go:60-67), `resolveModel` inline (model.go:152-158), `preflight.sh` header (lines 16-25), `preflight.sh` inline comment (lines 71-73), and `preflight_test.sh` section headers. Note: the draft's claim that `registerEnvProviders`' doc restates it is inaccurate (lines 265-288 describe the endpoint format only, not the credential rule), but the core observation holds. **Finding 2** (test overlap): Confirmed — `endpointProviderNames` (model.go:100-101) is a strict superset of `openAICompatProviders` (model.go:34), and `TestEndpointProviderNamesAreAllAccepted` (model_test.go:120-144) asserts both `endpointProvider` and `resolveModel` resolve for every name, subsuming the resolve assertions in `TestOpenAICompatProvidersResolveOnBothPaths` (model_test.go:90-113). The only unique assertion is `p.Name() == "ep"` (line 99-101). **Finding 3** (awk|cut|cut): Confirmed — preflight.sh:62 uses `awk`, lines 67-68 use `cut` twice. A trivial style observation on an 8-row table. **VERDICT: Minor issues** - **`cmd/gadfly/model.go` / `scripts/preflight.sh` / `scripts/preflight_test.sh` — the no-cross-vendor-fallback rationale is restated across multiple sites.** The "two resolution paths have different credential rules / built-ins read only their own variable / no cross-vendor fallback" rationale appears in `openAICompatOptions`'s doc comment (model.go:60-67), `resolveModel`'s inline comment (model.go:152-158), `preflight.sh`'s header (lines 16-25) and inline comment (lines 71-73), and `preflight_test.sh`'s section headers. The *code* has a single source (the slices/table); the *rationale prose* does not. If the rule ever changes, these comments drift independently and silently — exactly the failure mode the slices were centralized to prevent. Suggested fix: state the rule once at `openAICompatOptions` and have the other sites reference it rather than re-deriving it each time. (Note: the draft's claim that `registerEnvProviders`' doc restates the rule is not borne out — that doc describes the endpoint format only.) - **`cmd/gadfly/model_test.go:90` `TestOpenAICompatProvidersResolveOnBothPaths` is largely subsumed by `TestEndpointProviderNamesAreAllAccepted` (line 120).** The latter ranges `endpointProviderNames` (model.go:100-101), which is a strict superset of `openAICompatProviders` (model.go:34 — `openai/openai-compatible/kimi/qwen` … plus `ollama/ollama-cloud/llama-swap/...`), and asserts both `endpointProvider` and `resolveModel` resolve without error for every name — the same "both paths" assertion the former makes for the four compat names. The only assertion unique to the first test is `p.Name() == "ep"` (lines 99-101). Two tests covering overlapping ground for overlapping reasons is maintenance noise: a future provider added to the compat slice will touch both, and a reader has to parse both to learn what's actually pinned. Suggested fix: fold the `Name()` check into the stronger test and drop the redundant one, or keep just the `Name()` assertion as a tiny focused test and delete the duplicate resolve assertions. - **`scripts/preflight.sh:61-69` — the `awk | cut | cut` lookup is heavier than the data warrants.** For an 8-row table, `_gadfly_preflight_table | awk -F: -v p="$provider" '$1 == p {print; exit}'` (line 62) then `cut -d: -f2` / `-f3` (lines 67-68) is three pipeline stages to read two fields from one matched row. It's justified by the `gadfly_preflight_providers` contract (single source, queryable), so this is a deliberate tradeoff rather than a bug — but a plain `case`/`read` on a here-string would be more readable for the lookup half while still sourcing from the same table function. Trivial; only worth changing if touching the file anyway. No dead code, no undefined symbols, and no copy-pasted *logic* (the helper consolidation is genuine) — the issues above are prose/overlap rather than structural duplication. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> VERDICT: No material issues found - `cmd/gadfly/model.go` — The new `openAICompatOptions`/`isOpenAICompatProvider` paths add negligible constant-time work (a `slices.Contains` over a 4-element slice) on the model-resolution path, which runs once per review invocation, not in a hot loop. No N+1, no allocations on a hot path. Verified by reading `resolveModel` (model.go:126) and `endpointProvider` (model.go:317). - `scripts/preflight.sh` / `run.sh` — The credential pre-flight runs once per model in `run.sh:173` (one `awk`/`cut` pipeline over an 8-row static table). This is outside any inner loop and is a no-op (returns `""`) when not needed; no measurable cost on the review path. The subshell/`awk` overhead per model is trivial compared to the LLM call that follows. Verified the call site at run.sh:173. - The `GADFLY_BASE_URL` trim (`tr -d '[:space:]'` via `printf`) in preflight.sh:49 runs once per model; not a hot path. No performance regressions introduced. The change adds at most O(1) constant work per review model, all on setup paths that already block on an LLM call. </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> I have enough to verify. The finding's core claim is confirmed: 1. **preflight.sh:16-25** comment explicitly states the override path's credential is "GADFLY_API_KEY (falling back to the client's own default, OPENAI_API_KEY for the openai family) and a built-in's own variable is never consulted." 2. **model.go:77-81** (`openAICompatOptions`) *does* consult the built-in's own variable via `os.Getenv(builtinCompatKeyEnv(provider))` when `key == ""`. 3. This function is called on the GADFLY_BASE_URL override path (model.go:159-161), not just the endpoint path — so the comment's "never consulted" claim is factually contradicted by the code on exactly the path the comment is describing. 4. `TestBuiltinCompatOwnKeyFallback` (model_test.go:333) confirms the own-var fallback runs via `endpointProvider`, and the same `openAICompatOptions` is shared by the GADFLY_BASE_URL path. 5. preflight.sh:50-53 echoes `""` (skips the check) whenever `GADFLY_BASE_URL` is set, so a keyless override-path `qwen`/`kimi` config (no `GADFLY_API_KEY`, no own-var) passes preflight and fails at runtime. One nuance: the draft says the runtime failure surfaces as "five identical per-lens agent failures that name no variable." The test (`TestBuiltinCompatProvidersNeverInheritOpenAIKey`, "via GADFLY_BASE_URL" subtest) shows the keyless override path actually fails closed and the error *names* `GADFLY_API_KEY`. So the "names no variable" characterization is imprecise — but the substantive issue (the comment is contradicted by the code, and the override path is unpreflighted, leaving a runtime 401 instead of a preflight notice) is real and verified. I'll keep the finding with the accurate framing. **Verdict: Minor issues** - **`scripts/preflight.sh:16-25` / `cmd/gadfly/model.go:77-81,159-161` — the preflight's stated rationale for skipping the override path is contradicted by the code.** The preflight comment justifies excluding the `GADFLY_BASE_URL`-set path by claiming that on it "a built-in's own variable is never consulted." But `openAICompatOptions` — called on exactly that override path at `model.go:159-161` — *does* consult the built-in's own variable: `os.Getenv(builtinCompatKeyEnv(provider))` at `model.go:78`, falling back to `QWEN_API_KEY`/`KIMI_API_KEY` when `GADFLY_API_KEY` is empty. `TestBuiltinCompatOwnKeyFallback` (`model_test.go:333`) pins this fallback via `endpointProvider`, and the same `openAICompatOptions` is shared by the `GADFLY_BASE_URL` branch. Because the "rules are unknowable on this path" premise is false, a `qwen`/`kimi` entry configured through `GADFLY_BASE_URL` with neither `GADFLY_API_KEY` nor the own-var set passes the preflight (`gadfly_preflight_key` echoes `""` at `preflight.sh:50-53`) and fails at runtime — the override path does fail closed (the error names `GADFLY_API_KEY`, per `TestBuiltinCompatProvidersNeverInheritOpenAIKey`'s "via GADFLY_BASE_URL" subtest), but as a runtime 401 rather than the single clear preflight notice the preflight exists to produce. The credential rules on this path *are* statable (`GADFLY_API_KEY` → own var → fail closed), so the exclusion is neither necessary nor accurately justified. Suggested fix: extend the table/`gadfly_preflight_key` to cover the override path, or correct the comment so the exclusion isn't justified by a claim the code contradicts. Everything else in the draft's "checks out" paragraph verifies: `${!key_env:-}` is `set -u`-safe; the fixed table rows can't yield an empty `key_env` via `cut`; the `env -i` `probe` captures `GADFLY_TEST_MODEL` before clearing env; `trap … EXIT` scrubs the git credential on any exit; `TestBuiltinCompatProvidersNeverInheritOpenAIKey` pins the keyless-built-in fail-closed behavior. </details> </details> <details><summary><b>kimi-k2.6:cloud</b> (ollama-cloud) — Minor issues</summary> **Verdict: Minor issues** — 5 reviewers: security, correctness, maintainability, performance, error-handling <details><summary><b>🔒 Security</b> — No material issues found</summary> **No material issues found** After tracing tainted input through all changed paths and scrutinizing credential handling, the PR is clean from a security perspective. The changes are deliberately defensive: - **Cross-vendor credential leakage is prevented.** `openAICompatOptions` unconditionally passes an explicit key (even empty) for `kimi`/`qwen`, and tests (`TestBuiltinCompatProvidersNeverInheritOpenAIKey`) verify that a missing own-key fails closed rather than silently falling back to `OPENAI_API_KEY`. Verified by reading `cmd/gadfly/model.go:68-89` and the test at `cmd/gadfly/model_test.go:261-310`. - **Secrets are properly forwarded, not logged.** `QWEN_API_KEY` and `KIMI_API_KEY` are declared as `workflow_call` secrets in `.gitea/workflows/review-reusable.yml` and referenced via `${{ secrets.… }}` in `.gitea/workflows/adversarial-review.yml`. No interpolation into shell commands or logs. Verified by reading both workflow files. - **Credential pre-flight is clear and scoped.** `scripts/preflight.sh` only covers the registry path (where rules are exact), deliberately skips the override path (where rules differ), and exempts `google` because it accepts either of two variables. Verified by reading `scripts/preflight.sh:13-79`. - **CI credential scrubbing is defense-in-depth.** The new `test` job in `.gitea/workflows/build-image.yml` writes registry credentials to a temp `GIT_CONFIG_GLOBAL`, scrubs it with an `EXIT` trap, and `grep`s `$HOME` for the raw password before running `go test`. The base64-encoded form lives only in the explicitly removed gitconfig file. Verified by reading `.gitea/workflows/build-image.yml:48-145`. </details> <details><summary><b>🎯 Correctness</b> — No material issues found</summary> **Verdict: No material issues found** I scrutinized the provider-resolution logic, credential handling, and cross-vendor fallback rules through the correctness lens. The change is methodically structured with shared predicates (`isOpenAICompatProvider`, `isBuiltinCompatProvider`) to keep the two resolution paths (`GADFLY_BASE_URL` override and `GADFLY_ENDPOINT_*` parser) in sync, and the extensive test suite (`TestOpenAICompatProvidersResolveOnBothPaths`, `TestBuiltinCompatProvidersNeverInheritOpenAIKey`, `TestBuiltinCompatOwnKeyFallback`, `TestBuiltinCompatProvidersResolveViaRegistry`) exercises the exact failure modes that have regressed before. Key correctness checks verified: - **No cross-vendor key leakage**: Built-in compat providers (`kimi`, `qwen`) unconditionally get `openai.WithAPIKey(key)` even when `key == ""`, overriding `openai.New`'s default `OPENAI_API_KEY` fallback. The `GADFLY_BASE_URL` and `GADFLY_ENDPOINT_*` paths both use the shared `openAICompatOptions` helper, so fixes can't drift between the two switches. - **Own-key fallback works**: When no explicit key is provided in a named endpoint DSN, `openAICompatOptions` falls back to `QWEN_API_KEY` / `KIMI_API_KEY` via `builtinCompatKeyEnv`, letting credentials stay in masked secrets while endpoint URLs live in unmasked vars. - **Registry vs override paths keep separate credential rules**: The pre-flight table deliberately covers only the registry path (`GADFLY_BASE_URL` unset), because the override path uses `GADFLY_API_KEY` with a different fallback semantics. This prevents the false-pass/false-skip problem described in the PR. - **Whitespace-only `GADFLY_BASE_URL` treated as unset**: Both Go (`strings.TrimSpace`) and bash (`tr -d '[:space:]'`) agree on emptiness, so a whitespace-only value takes the registry path and gets pre-flight checked. - **Test coverage connects Go and shell**: `TestOpenAICompatProvidersAreFullyWired` queries the live `preflight.sh` to ensure every openai-compat provider has a credential arm, preventing the "half-wired" problem where code accepts a provider but the pre-flight skips it. No logic bugs, incorrect fallbacks, or semantic errors were found. </details> <details><summary><b>🧹 Code cleanliness & maintainability</b> — No material issues found</summary> No material issues found After reviewing the PR through the code cleanliness & maintainability lens, I find the changes well-structured. The extraction of `openAICompatProviders`, `builtinCompatProviders`, `isOpenAICompatProvider`, `isBuiltinCompatProvider`, and `openAICompatOptions` successfully eliminates the copy-paste hazard the PR description calls out ("fixed the first and missed the second on the first pass"). The single `endpointProviderNames` constant with a matching `TestEndpointProviderNamesAreAllAccepted` table test is a solid anti-drift pattern. The shell-to-Go cross-check (`TestOpenAICompatProvidersAreFullyWired` sourcing `preflight.sh` rather than regexing it) avoids making bash formatting a cross-language contract. Comments are substantive and explain *why*, not just what. </details> <details><summary><b>⚡ Performance</b> — No material issues found</summary> No material issues found </details> <details><summary><b>🧯 Error handling & edge cases</b> — Minor issues</summary> **Verdict: Minor issues** * `scripts/preflight.sh:49` — Unicode whitespace edge case in `GADFLY_BASE_URL` "unset" detection. The bash side uses `tr -d '[:space:]'`, which only strips POSIX whitespace characters (space, tab, newline, carriage return, form-feed, vertical tab). Go's `strings.TrimSpace` removes all Unicode space characters as defined by `unicode.IsSpace` (e.g., non-breaking space `\u00A0`, zero-width space, etc.). If `GADFLY_BASE_URL` is set to a value containing only non-ASCII whitespace, Go treats it as empty and takes the registry path, but the preflight treats it as non-empty and skips the key check. A missing key on the registry path then surfaces as a bare 401 instead of the intended skip notice — the exact failure mode the preflight was designed to prevent. **Suggested fix:** Add a comment documenting that only ASCII whitespace is considered "unset" for preflight purposes, or add a regex-based trim that closer approximates Go's behavior (e.g., `sed 's/^[[:space:]]*//;s/[[:space:]]*$//'`), accepting that perfect Unicode parity between bash and Go is impractical. * `scripts/run.sh:173` / `scripts/preflight.sh:39` — The `claude-code` case exemption in `gadfly_preflight_key` does not trim whitespace from the `model` parameter, while the Go binary trims `GADFLY_MODEL`. If a model spec has leading/trailing whitespace (e.g., `"claude-code "`), the case statement won't match and the preflight will fall through to check the provider's key, potentially requiring an `OLLAMA_CLOUD_API_KEY` that the engine doesn't need. **Suggested fix:** Trim whitespace from `model` before the case statement, e.g. `model="$(printf '%s' "$model" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"`. </details> </details> </details> <sub>Automated adversarial review by Gadfly — consensus across the model swarm. Advisory only — does not block merge.</sub>
steve added 1 commit 2026-08-12 20:43:01 +00:00
fix(qwen): gadfly round 1 — three real findings, all sibling drift
Build & push image / build-and-push (pull_request) Successful in 3s
2367e696b5
The pre-flight comment was the worst of them, and three models agreed. It said
providers absent from the table "need no key or carry it in their endpoint/DSN"
— false for google, which needs a key and is absent for an entirely different
reason: it accepts GOOGLE_API_KEY *or* GEMINI_API_KEY, so a single-variable arm
would silently skip a correctly-configured reviewer. That reasoning was in the
PR description and not in the code, so the comment invited exactly the wrong
edit. It now states both exclusion reasons and names google's.

Forwarded KIMI_API_KEY alongside QWEN_API_KEY in the dogfooding stub. This PR
argues that sibling call sites must move together, and I declared both secrets
in the reusable workflow and forwarded one — a config that looks complete and
401s on the model you didn't wire.

The two endpoint-provider error messages listed the same accepted set in
different order and spelling. Both functions accept an identical set, so they
now share one endpointProviderNames constant and cannot disagree.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Author
Owner

Round 1: all five findings real (two pairs were the same defect from two lenses), all fixed in 2367e69.

  • run.sh:169, 3-model agreement — the best finding here. My comment claimed absent providers "need no key or carry it in their endpoint/DSN". False for google: it needs a key and is absent for a different reason — it accepts GOOGLE_API_KEY or GEMINI_API_KEY, so a one-variable arm would silently skip a correctly-configured reviewer. I had that reasoning in the PR description and not in the code, which means the comment actively invited the wrong edit. Now states both exclusion reasons and names google's, including what a correct google arm would have to do.
  • adversarial-review.yml:53 — I declared both QWEN_API_KEY and KIMI_API_KEY in the reusable workflow and then forwarded only qwen in the dogfooding stub. A PR whose entire thesis is "sibling call sites must move together" shipped a config that looks complete and 401s on the model you didn't wire. Both now forwarded, with a comment saying why they travel as a pair.
  • model.go:118 / :291 — the two error messages listed the same accepted set in different order and spelling, hand-maintained. Since both functions accept an identical set, they now share one endpointProviderNames constant and cannot disagree.

Gates green; the 17-case pre-flight table still passes (google still correctly unblocked).

@gadfly review

Round 1: all five findings real (two pairs were the same defect from two lenses), all fixed in 2367e69. - **`run.sh:169`, 3-model agreement — the best finding here.** My comment claimed absent providers "need no key or carry it in their endpoint/DSN". False for google: it needs a key and is absent for a *different* reason — it accepts `GOOGLE_API_KEY` **or** `GEMINI_API_KEY`, so a one-variable arm would silently skip a correctly-configured reviewer. I had that reasoning in the PR description and not in the code, which means the comment actively invited the wrong edit. Now states both exclusion reasons and names google's, including what a correct google arm would have to do. - **`adversarial-review.yml:53`** — I declared both `QWEN_API_KEY` and `KIMI_API_KEY` in the reusable workflow and then forwarded only qwen in the dogfooding stub. A PR whose entire thesis is "sibling call sites must move together" shipped a config that looks complete and 401s on the model you didn't wire. Both now forwarded, with a comment saying why they travel as a pair. - **`model.go:118` / `:291`** — the two error messages listed the same accepted set in different order and spelling, hand-maintained. Since both functions accept an identical set, they now share one `endpointProviderNames` constant and cannot disagree. Gates green; the 17-case pre-flight table still passes (google still correctly unblocked). @gadfly review
steve added 1 commit 2026-08-12 20:59:03 +00:00
fix(qwen): gadfly round 2 — the anti-drift list had already drifted
Build & push image / build-and-push (pull_request) Successful in 3s
1d6eaa08c5
Eight findings, all real, and the sharpest ones are about this PR's own fixes.

GADFLY_API_KEY was treated as a universal substitute in the pre-flight. It is
not: resolveModel reads it only AFTER the `baseURL == ""` early return, so on
the registry path — the documented primary path — a qwen/kimi built-in reads
its own variable and GADFLY_API_KEY is never consulted. A mis-set
GADFLY_API_KEY therefore passed pre-flight and 401'd five times anyway, which
is precisely the failure this check exists to prevent. It now only substitutes
when GADFLY_BASE_URL is also set.

`openai-compatible` was missing from the pre-flight table while both switches
accept it as an OPENAI_API_KEY alias, so that one spelling still fell through
to the cryptic five-failure mode.

endpointProviderNames — the constant I introduced *to stop* the two error
messages drifting — omitted the `gemini` alias both switches accept. It now
lists every accepted spelling.

And the case list itself was still duplicated across both switches plus the
test that pins them: three copies of the thing whose duplication started this.
Both switches now call isOpenAICompatProvider over one shared slice, and
endpointProvider's doc comment points at endpointProviderNames instead of
carrying a fourth hand-written copy.

scripts/preflight_test.sh moves into the repo (20 cases, up from 17, covering
openai-compatible and both GADFLY_API_KEY directions). It carries a drift guard
that diffs its copy of the provider table against run.sh's and aborts if they
differ — break-checked by deleting an arm from run.sh, which fails it loudly.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Author
Owner

Round 2: all eight real (three pairs were duplicates across lenses), fixed in 1d6eaa0. The best ones are about this PR's own fixes.

  • run.sh:193GADFLY_API_KEY was a false pass. resolveModel reads it only after the baseURL == "" early return, so on the registry path — the documented primary path — a qwen/kimi built-in reads its own variable and GADFLY_API_KEY is never consulted. Treating it as a universal substitute meant a mis-set GADFLY_API_KEY passed pre-flight and then 401'd five times anyway: the exact failure the pre-flight exists to prevent, introduced by the pre-flight. Now requires GADFLY_BASE_URL alongside it, with a test case pinning the false-pass specifically.
  • openai-compatible missing from the table (3 reports, 2 models, 3 lenses) — both switches accept it as an OPENAI_API_KEY alias, so that one spelling still fell through to the cryptic mode. Added.
  • endpointProviderNames omitted gemini — the constant I added to stop two messages drifting had itself already drifted from the switches it describes. Now lists every accepted spelling.
  • The case list was still in three places — both switches plus the test pinning them. isOpenAICompatProvider over one shared slice now; the doc comment references endpointProviderNames instead of being a fourth copy.

scripts/preflight_test.sh moves into the repo (20 cases, up from 17). It carries a drift guard: it diffs its copy of the provider table against run.sh's and aborts if they differ — because that test script is itself a duplicated pair, and this PR has now been bitten by that shape five separate times. Break-checked by deleting an arm from run.sh: the guard fails loudly.

Gates green.

@gadfly review

Round 2: all eight real (three pairs were duplicates across lenses), fixed in 1d6eaa0. The best ones are about this PR's own fixes. - **`run.sh:193` — `GADFLY_API_KEY` was a false pass.** `resolveModel` reads it only *after* the `baseURL == ""` early return, so on the registry path — the documented primary path — a qwen/kimi built-in reads its own variable and `GADFLY_API_KEY` is never consulted. Treating it as a universal substitute meant a mis-set `GADFLY_API_KEY` passed pre-flight and then 401'd five times anyway: the exact failure the pre-flight exists to prevent, introduced by the pre-flight. Now requires `GADFLY_BASE_URL` alongside it, with a test case pinning the false-pass specifically. - **`openai-compatible` missing from the table** (3 reports, 2 models, 3 lenses) — both switches accept it as an `OPENAI_API_KEY` alias, so that one spelling still fell through to the cryptic mode. Added. - **`endpointProviderNames` omitted `gemini`** — the constant I added *to stop* two messages drifting had itself already drifted from the switches it describes. Now lists every accepted spelling. - **The case list was still in three places** — both switches plus the test pinning them. `isOpenAICompatProvider` over one shared slice now; the doc comment references `endpointProviderNames` instead of being a fourth copy. `scripts/preflight_test.sh` moves into the repo (20 cases, up from 17). It carries a **drift guard**: it diffs its copy of the provider table against `run.sh`'s and aborts if they differ — because that test script is itself a duplicated pair, and this PR has now been bitten by that shape five separate times. Break-checked by deleting an arm from `run.sh`: the guard fails loudly. Gates green. @gadfly review
steve added 1 commit 2026-08-12 21:20:47 +00:00
fix(qwen): gadfly round 3 — stop guarding a duplicate, delete it
Build & push image / test (pull_request) Canceled after 7m27s
Build & push image / build-and-push (pull_request) Successful in 5s
67a73616e1
Twelve findings, all real, and the two that matter are about the pre-flight I
added rather than about qwen.

The credential check had a false pass in the OTHER direction from round 2's: on
the GADFLY_BASE_URL override path, resolveModel builds the client with
GADFLY_API_KEY and never reads QWEN_API_KEY/KIMI_API_KEY, so treating the
provider's own key as sufficient there let a doomed run proceed. Having now
been wrong about these rules in both directions, the check no longer tries to
model both paths: it covers the REGISTRY path, whose rules it can state
exactly, and says nothing about the override path — which is hand-configured by
definition, while the registry path is the one you hit by adding a model id to
a var and forgetting the secret.

The logic moves to scripts/preflight.sh, sourced by both run.sh and the test.
The previous answer to "this test duplicates production logic" was a regex
drift-guard, and that guard compared only the provider table — not the decision
logic, which is precisely the half that carried the bug. A duplicate you guard
is still a duplicate; this deletes it, and the test now runs under `set -u`
like production does.

Also: the test that pins the shared provider slice held its own copy of the
list (now ranges the slice); endpointProviderNames had nothing tying it to the
switches it describes, which is how it shipped without "gemini" (a new test
asserts every advertised name resolves); two godoc lists had drifted; and the
"sanity" line that asserted nothing is gone.

And the repo had NO test job — `go test` and the pre-flight table both existed
and neither was ever executed by CI, which reads as coverage while providing
none. Added one (build/vet/gofmt/test/pre-flight), running alongside the image
build rather than gating it, so red is loud without standing between a push and
a rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Author
Owner

Round 3: all twelve real, fixed in 67a7361. The two that matter are about the pre-flight I added, not about qwen.

  • run.sh:197 — the false pass in the other direction. Round 2 caught "GADFLY_API_KEY isn't sufficient on the registry path"; this is "the provider's own key isn't sufficient on the override path", where resolveModel builds the client with GADFLY_API_KEY and never reads QWEN_API_KEY/KIMI_API_KEY. Having now been wrong about these rules in both directions, I stopped trying to model both: the check covers the registry path, whose rules it can state exactly, and says nothing about the override path — hand-configured by definition, whereas the registry path is the one you hit by adding a model id to a var and forgetting the secret.
  • preflight_test.sh duplicated production logic, and my answer was a regex drift-guard that compared only the provider table — not the decision logic, which is exactly the half that carried the bug above. A duplicate you guard is still a duplicate. The logic now lives in scripts/preflight.sh, sourced by both run.sh and the test, so the tested bytes and the running bytes are the same. The test also runs under set -u now, like production.

The rest, all correct: the test pinning the shared provider slice held its own copy of the list (now ranges it); endpointProviderNames had nothing tying it to the switches it describes, which is how it shipped without gemini (new test asserts every advertised name actually resolves); two godoc lists had drifted; the "sanity" line asserted nothing; and a duplicated -u flag.

One finding was bigger than reported: this repo had no test job at allgo test and the pre-flight table both existed and neither was ever run by CI. That's worse than having no tests, since it reads as coverage. Added a test job (build / vet / gofmt / go test / pre-flight table) running alongside build-and-push rather than gating it, so red is loud without standing between a push and a rebuild. Say the word if you'd rather it gate.

Break-checked: dropping the qwen arm, pre-flighting the override path, and allowing cross-provider key fallback each fail their case.

@gadfly review

Round 3: all twelve real, fixed in 67a7361. The two that matter are about the pre-flight I added, not about qwen. - **`run.sh:197` — the false pass in the *other* direction.** Round 2 caught "GADFLY_API_KEY isn't sufficient on the registry path"; this is "the provider's own key isn't sufficient on the override path", where `resolveModel` builds the client with `GADFLY_API_KEY` and never reads `QWEN_API_KEY`/`KIMI_API_KEY`. Having now been wrong about these rules in both directions, I stopped trying to model both: the check covers the **registry path**, whose rules it can state exactly, and says nothing about the override path — hand-configured by definition, whereas the registry path is the one you hit by adding a model id to a var and forgetting the secret. - **`preflight_test.sh` duplicated production logic**, and my answer was a regex drift-guard that compared only the provider table — *not* the decision logic, which is exactly the half that carried the bug above. A duplicate you guard is still a duplicate. The logic now lives in `scripts/preflight.sh`, sourced by both `run.sh` and the test, so the tested bytes and the running bytes are the same. The test also runs under `set -u` now, like production. The rest, all correct: the test pinning the shared provider slice held its own copy of the list (now ranges it); `endpointProviderNames` had nothing tying it to the switches it describes, which is how it shipped without `gemini` (new test asserts every advertised name actually resolves); two godoc lists had drifted; the "sanity" line asserted nothing; and a duplicated `-u` flag. **One finding was bigger than reported:** this repo had *no test job at all* — `go test` and the pre-flight table both existed and neither was ever run by CI. That's worse than having no tests, since it reads as coverage. Added a `test` job (build / vet / gofmt / go test / pre-flight table) running **alongside** `build-and-push` rather than gating it, so red is loud without standing between a push and a rebuild. Say the word if you'd rather it gate. Break-checked: dropping the qwen arm, pre-flighting the override path, and allowing cross-provider key fallback each fail their case. @gadfly review
steve added 2 commits 2026-08-12 21:28:16 +00:00
A workspace-scoped Qwen endpoint rejects a key issued for the shared
international host with a genuine 'Incorrect API key provided', so a valid key
reads as invalid and the obvious next move — checking the key — confirms it is
fine and leads nowhere. Watched this cost real debugging time on a live
deployment today; gadfly would hit it identically. Documents the
GADFLY_ENDPOINT_* form, which reaches a workspace host with no code change.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
fix(ci): scrub the registry credential before running repo code
Build & push image / test (pull_request) Successful in 9m47s
Build & push image / build-and-push (pull_request) Successful in 4s
14f8533e38
Both Claude reviewers caught this independently, and they are right. The test
job I added wrote a PUSH-CAPABLE REGISTRY_PASSWORD into a plaintext
~/.gitconfig and then ran `go build`/`go vet`/`go test` — repository code — on
pull_request events. This repo is public, so a fork PR could ship a test whose
only job is to print that file. The image build had already answered this
question correctly: its credentials are BuildKit secrets scoped to the
module-download RUN and are never present while code executes. I bolted on a
job that skipped the boundary its neighbour maintains.

Dependencies are now fetched in their own step which deletes ~/.gitconfig
before anything else runs, and asserts the scrub — against the whole home
directory, not against the file it just removed, because the credential can
also land in ~/.netrc or ~/.config/go/env. Verified the assertion is not
vacuous: planting the secret in ~/.netrc trips it. Later steps run with
GOPROXY=off, so any attempt to reach the network fails loudly rather than
quietly hunting for the credential that is now gone.

Also from round 4: TestEndpointProviderNamesAreAllAccepted pinned only
endpointProvider, while the constant is the error text for BOTH resolution
paths — it now asserts each advertised name resolves either way (break-checked
by dropping the gemini alias from resolveModel alone). preflight.sh documents
that ollama-cloud is checked on OLLAMA_API_KEY but hinted as
OLLAMA_CLOUD_API_KEY because run.sh copies one to the other first, an ordering
dependency that was invisible from the file.

And the comments that narrated this PR's own edit history ("the first version
of this change...") are rewritten as invariants. That history stops being true
the moment this merges, and the repo's doc policy says as much.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Author
Owner

Round 4: all six real, fixed in 14f8533. One of them is the most valuable finding of this PR and both Claude models found it independently.

🔒 The test job I added leaked a push-capable credential to untrusted code. It wrote REGISTRY_PASSWORD into a plaintext ~/.gitconfig and then ran go build/go vet/go test — repository code — on pull_request events. This repo is public, so a fork PR could ship a test whose only job is to print that file. The image build had already answered this exact question correctly: its credentials are BuildKit secrets scoped to the module-download RUN, never present while code executes. I bolted on a job that skipped the boundary its neighbour maintains.

Fixed: dependencies fetch in their own step which deletes ~/.gitconfig before anything else runs, then asserts the scrub — against the whole home directory, not the file it just deleted, since the credential can also reach ~/.netrc or ~/.config/go/env. My first draft of that assertion grepped the deleted file and therefore always passed; I caught it and replaced it. Verified non-vacuous by planting the secret in ~/.netrc — it trips. Remaining steps run GOPROXY=off, so any network attempt fails loudly instead of quietly hunting for the credential that is now gone.

The rest:

  • model_test.go:112 — I wrote a test to keep endpointProviderNames honest and checked it against only one of the two switches the constant serves. Now asserts both; break-checked by dropping the gemini alias from resolveModel alone, which the endpointProvider half doesn't notice.
  • Comments narrating this PR's edit history ("the first version of this change…") — correct, and the general form of it. That history stops being true at merge, and the repo's doc policy says so. Rewritten as invariants naming the enforcing test rather than the anecdote.
  • preflight.sh now documents that ollama-cloud is checked on OLLAMA_API_KEY but hinted as OLLAMA_CLOUD_API_KEY because run.sh copies one to the other first — an ordering dependency invisible from the file, which would report "missing key" for configured runs if that copy ever moved.

Also pushed the README warning that Model Studio keys are endpoint-scoped: a workspace-scoped host rejects a key issued for the shared one with a real 401 Incorrect API key provided, so a valid key reads as invalid. That cost real debugging time on a live deployment today, and gadfly would hit it identically.

@gadfly review

Round 4: all six real, fixed in 14f8533. One of them is the most valuable finding of this PR and both Claude models found it independently. **🔒 The test job I added leaked a push-capable credential to untrusted code.** It wrote `REGISTRY_PASSWORD` into a plaintext `~/.gitconfig` and then ran `go build`/`go vet`/`go test` — repository code — on `pull_request` events. This repo is public, so a fork PR could ship a test whose only job is to print that file. The image build had already answered this exact question correctly: its credentials are BuildKit secrets scoped to the module-download `RUN`, never present while code executes. I bolted on a job that skipped the boundary its neighbour maintains. Fixed: dependencies fetch in their own step which deletes `~/.gitconfig` before anything else runs, then asserts the scrub — **against the whole home directory**, not the file it just deleted, since the credential can also reach `~/.netrc` or `~/.config/go/env`. My first draft of that assertion grepped the deleted file and therefore always passed; I caught it and replaced it. Verified non-vacuous by planting the secret in `~/.netrc` — it trips. Remaining steps run `GOPROXY=off`, so any network attempt fails loudly instead of quietly hunting for the credential that is now gone. The rest: - **`model_test.go:112`** — I wrote a test to keep `endpointProviderNames` honest and checked it against only *one* of the two switches the constant serves. Now asserts both; break-checked by dropping the `gemini` alias from `resolveModel` alone, which the `endpointProvider` half doesn't notice. - **Comments narrating this PR's edit history** ("the first version of this change…") — correct, and the general form of it. That history stops being true at merge, and the repo's doc policy says so. Rewritten as invariants naming the enforcing test rather than the anecdote. - **`preflight.sh`** now documents that `ollama-cloud` is checked on `OLLAMA_API_KEY` but hinted as `OLLAMA_CLOUD_API_KEY` because `run.sh` copies one to the other first — an ordering dependency invisible from the file, which would report "missing key" for configured runs if that copy ever moved. Also pushed the README warning that Model Studio keys are endpoint-scoped: a workspace-scoped host rejects a key issued for the shared one with a real `401 Incorrect API key provided`, so a valid key reads as invalid. That cost real debugging time on a live deployment today, and gadfly would hit it identically. @gadfly review
steve added 1 commit 2026-08-12 21:40:05 +00:00
fix(ci): make the credential scrub failure-safe, and stop the lists drifting
Build & push image / test (pull_request) Successful in 9m41s
Build & push image / build-and-push (pull_request) Successful in 1m13s
0abcd16e9e
Round 5, and the best findings are again about the fix from round 4.

The scrub only ran on success. `set -e` aborts the step when `go mod download`
fails, so the cleanup line after it never executed — leaving a push-capable
credential on a long-lived self-hosted runner for whatever job landed there
next. It is now a `trap ... EXIT`, verified against a simulated failure.

It also scrubbed the wrong file in principle: `git config --global` writes to
GIT_CONFIG_GLOBAL, else $XDG_CONFIG_HOME/git/config when that exists, else
~/.gitconfig — so deleting ~/.gitconfig can scrub a path the credential was
never in. The step now names GIT_CONFIG_GLOBAL itself, leaving exactly one file
to remove.

And the verification failed open in the case that matters most: `grep -F ""`
matches every file, so a run WITHOUT the secret — a fork PR, the threat model —
failed the check with a message accusing it of leaking a credential it never
had. Guarded on a non-empty secret.

Credentials move to an Authorization header instead of being embedded in the
URL, so a password containing @ : / or # can no longer break URL parsing in a
way that reads as a bad password.

Two list-drift holes closed with one test that reads across languages:
TestOpenAICompatProvidersAreFullyWired asserts every openAICompatProviders
entry is both advertised in endpointProviderNames and has a credential arm in
scripts/preflight.sh. Adding a compat provider touches three places in two
languages and nothing connected them. Break-checked in both directions.

Finally, a whitespace-only GADFLY_BASE_URL disagreed across the boundary: Go
TrimSpaces it and takes the registry path, bash called it "set" and skipped the
pre-flight, so the missing key arrived as a bare 401 with no notice. Both now
agree on what unset means.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Author
Owner

Round 5: all ten real, fixed in 0abcd16. The best findings are again about the previous round's fix — three separate holes in my credential scrub, each of which would have made it useless in exactly the case it was written for.

  • The scrub only ran on success. set -e aborts the step when go mod download fails, so the cleanup line after it never executed — leaving a push-capable credential on a long-lived self-hosted runner for whatever job landed there next. Now a trap … EXIT, verified against a simulated failure.
  • It scrubbed a file the credential might never be in. git config --global writes to GIT_CONFIG_GLOBAL, else $XDG_CONFIG_HOME/git/config when that exists, else ~/.gitconfig. Deleting the last one and checking it proves nothing about the first two. The step now sets GIT_CONFIG_GLOBAL itself, so there is exactly one file and we own it. (kimi-k2.6 — sharpest finding it's made on this PR.)
  • The verification failed open on the threat model. grep -F "" matches every file, so a run without the secret — a fork PR, the exact case — failed with a message accusing it of leaking a credential it never had, burying the real signal in a false one. Guarded on a non-empty secret.

Also: credentials moved out of the URL into an Authorization header, so a password containing @ : / # can't break URL parsing in a way that presents as a bad password.

And two list-drift holes closed by one test that reads across languages — TestOpenAICompatProvidersAreFullyWired asserts every openAICompatProviders entry is both advertised in endpointProviderNames and has a credential arm in scripts/preflight.sh. Adding a compat provider touches three places in two languages and nothing connected them; break-checked in both directions. Plus a whitespace-only GADFLY_BASE_URL, which Go trims (registry path) while bash called it set (skip the check) — the two now agree on what "unset" means.

Worth stating plainly: 41 findings across five rounds, every one real. Zero false positives in this repo. Most of them are not about qwen at all — they're about the pre-flight check and CI job I volunteered on top of the actual request, which is its own lesson about scope.

@gadfly review

Round 5: all ten real, fixed in 0abcd16. The best findings are again about the previous round's fix — three separate holes in my credential scrub, each of which would have made it useless in exactly the case it was written for. - **The scrub only ran on success.** `set -e` aborts the step when `go mod download` fails, so the cleanup line after it never executed — leaving a push-capable credential on a long-lived self-hosted runner for whatever job landed there next. Now a `trap … EXIT`, verified against a simulated failure. - **It scrubbed a file the credential might never be in.** `git config --global` writes to `GIT_CONFIG_GLOBAL`, else `$XDG_CONFIG_HOME/git/config` when that exists, else `~/.gitconfig`. Deleting the last one and checking it proves nothing about the first two. The step now sets `GIT_CONFIG_GLOBAL` itself, so there is exactly one file and we own it. (kimi-k2.6 — sharpest finding it's made on this PR.) - **The verification failed open on the threat model.** `grep -F ""` matches every file, so a run *without* the secret — a fork PR, the exact case — failed with a message accusing it of leaking a credential it never had, burying the real signal in a false one. Guarded on a non-empty secret. Also: credentials moved out of the URL into an `Authorization` header, so a password containing `@ : / #` can't break URL parsing in a way that presents as a bad password. And two list-drift holes closed by one test that reads across languages — `TestOpenAICompatProvidersAreFullyWired` asserts every `openAICompatProviders` entry is both advertised in `endpointProviderNames` *and* has a credential arm in `scripts/preflight.sh`. Adding a compat provider touches three places in two languages and nothing connected them; break-checked in both directions. Plus a whitespace-only `GADFLY_BASE_URL`, which Go trims (registry path) while bash called it set (skip the check) — the two now agree on what "unset" means. Worth stating plainly: **41 findings across five rounds, every one real.** Zero false positives in this repo. Most of them are not about qwen at all — they're about the pre-flight check and CI job I volunteered on top of the actual request, which is its own lesson about scope. @gadfly review
steve added 1 commit 2026-08-12 21:51:57 +00:00
fix(qwen): bump majordomo, and stop handing keys to the wrong vendor
Build & push image / test (pull_request) Canceled after 59s
Build & push image / build-and-push (pull_request) Successful in 9s
a3d3a45e7e
Round 6, and one finding exposed something no reviewer mentioned: the majordomo
bump this whole PR depends on was never made. Every test here builds the openai
client directly, so all of them passed against a majordomo release that had
never heard of qwen — a plain "qwen/<model>" in GADFLY_MODELS, the primary way
anyone will use this, would not have resolved at all. A compile error caught it,
which is luck. TestBuiltinCompatProvidersResolveViaRegistry now exercises that
path; the build is what guards the dep itself, since the old release cannot
compile the code below.

On the endpoint-override path, kimi and qwen fell through to openai.New's
OPENAI_API_KEY default whenever GADFLY_API_KEY was unset — sending an OpenAI
key to Moonshot or Alibaba. That is a credential handed to the wrong vendor,
and it is the exact failure majordomo's built-ins are written to prevent; I
reintroduced it one layer up. Both now pass the key unconditionally, so an
absent key is a 401 naming GADFLY_API_KEY rather than a foreign credential on
the wire.

The test job scrubbed the registry credential and left the checkout token in
.git/config, readable by the `go test` it then runs — fixing one credential
while its neighbour sat in the open. persist-credentials: false; nothing in
that job talks to git after checkout.

The cross-language wiring test now QUERIES preflight.sh via a new
gadfly_preflight_providers function instead of regexing its case statement.
Parsing made that file's formatting a contract no linter enforces, where a
harmless reformat breaks a test in another language. Two models flagged it.

Also: grep for the scrub check takes -e, so a password starting with a hyphen
is not read as options; and key_hint stopped repeating key_env in four of five
arms.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
steve added 1 commit 2026-08-12 21:52:58 +00:00
fix(ci): actually add grep -e (a3d3a45 claimed it and did not)
Build & push image / test (pull_request) Successful in 9m38s
Build & push image / build-and-push (pull_request) Successful in 4s
274451e89c
The previous commit message listed this fix; the edit never made it into the
tree. Checked because I have done exactly that before, and the message is not
the change.

Verified rather than assumed, and the behaviour is worth recording: with a
password beginning with "-", `grep -rqF "$PW"` returns 0 against a directory
containing no such string, so the scrub check would have reported a leak that
did not exist and failed the step with a misleading message. With -e it
correctly returns 1 when clean and 0 when the credential really is present.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Author
Owner

Round 6: all five real, fixed in a3d3a45 + 274451e. Two were security-relevant, and chasing one of them uncovered something no reviewer mentioned.

The majordomo bump this PR depends on was never made. Every test in model_test.go builds the openai client directly, so all of them passed against a majordomo release that had never heard of qwen — a plain qwen/<model> in GADFLY_MODELS, the primary way anyone will use this, would not have resolved at all. A compile error caught it while I was applying a different fix. That is luck, not coverage. TestBuiltinCompatProvidersResolveViaRegistry now exercises the registry path; the build itself guards the dep, since the old release can't compile the code.

  • 🔒 kimi/qwen sent keys to the wrong vendor. On the endpoint-override path, an unset GADFLY_API_KEY fell through to openai.New's OPENAI_API_KEY default — putting an OpenAI key on the wire to Moonshot or Alibaba. That's exactly what majordomo's built-ins are written to prevent, reintroduced one layer up in the consumer. Both now pass the key unconditionally, so absence is a 401 naming GADFLY_API_KEY.
  • 🔒 I scrubbed one credential and left its neighbour. The test job cleaned REGISTRY_PASSWORD and left the checkout token in .git/config, in the workspace the subsequent go test reads. persist-credentials: false.
  • The cross-language test regexed the shell file (2 models). Fair: that made preflight.sh's formatting a contract no linter enforces. It now exports gadfly_preflight_providers and the Go test queries it.
  • grep -e for hyphen-leading passwords, and key_hint no longer repeats key_env in 4 of 5 arms.

One correction on myself: a3d3a45's message claimed the grep -e fix and the edit wasn't in the tree. I checked because I've done that before, found it missing, and 274451e is the actual change — plus, testing it, the bug was worse than reported: without -e the grep returns 0 against a clean directory, so the check would report a leak that didn't exist.

46 findings, six rounds, still zero false positives.

@gadfly review

Round 6: all five real, fixed in a3d3a45 + 274451e. Two were security-relevant, and chasing one of them uncovered something no reviewer mentioned. **The majordomo bump this PR depends on was never made.** Every test in `model_test.go` builds the openai client directly, so all of them passed against a majordomo release that had never heard of qwen — a plain `qwen/<model>` in `GADFLY_MODELS`, the primary way anyone will use this, would not have resolved at all. A compile error caught it while I was applying a *different* fix. That is luck, not coverage. `TestBuiltinCompatProvidersResolveViaRegistry` now exercises the registry path; the build itself guards the dep, since the old release can't compile the code. - **🔒 kimi/qwen sent keys to the wrong vendor.** On the endpoint-override path, an unset `GADFLY_API_KEY` fell through to `openai.New`'s `OPENAI_API_KEY` default — putting an OpenAI key on the wire to Moonshot or Alibaba. That's exactly what majordomo's built-ins are written to prevent, reintroduced one layer up in the consumer. Both now pass the key unconditionally, so absence is a 401 naming `GADFLY_API_KEY`. - **🔒 I scrubbed one credential and left its neighbour.** The test job cleaned `REGISTRY_PASSWORD` and left the checkout token in `.git/config`, in the workspace the subsequent `go test` reads. `persist-credentials: false`. - **The cross-language test regexed the shell file** (2 models). Fair: that made `preflight.sh`'s formatting a contract no linter enforces. It now exports `gadfly_preflight_providers` and the Go test *queries* it. - **`grep -e`** for hyphen-leading passwords, and `key_hint` no longer repeats `key_env` in 4 of 5 arms. One correction on myself: **a3d3a45's message claimed the `grep -e` fix and the edit wasn't in the tree.** I checked because I've done that before, found it missing, and 274451e is the actual change — plus, testing it, the bug was worse than reported: without `-e` the grep returns 0 against a *clean* directory, so the check would report a leak that didn't exist. 46 findings, six rounds, still zero false positives. @gadfly review
steve added 1 commit 2026-08-12 22:09:19 +00:00
fix(qwen): one credential rule for both paths — seven findings said so
Build & push image / test (pull_request) Successful in 9m40s
Build & push image / build-and-push (pull_request) Successful in 5s
3af0f09387
Fourteen findings, and seven of them from all four models are the same one:
endpointProvider was missing the no-cross-vendor-fallback guard I had just
added to resolveModel. I fixed a credential leak on one path and left its
sibling leaking, in the commit whose own message argued those two paths must
move together. That is the third time in this PR.

So it is no longer a rule written twice. openAICompatOptions owns it and both
paths call it; builtinCompatProviders names the vendors that must never inherit
OPENAI_API_KEY, replacing a `provider == "kimi" || provider == "qwen"` literal
that was a fourth uncounted copy of the list.

The test drives a real request at a local server and demands two things: that
no request arrives carrying the OpenAI key, AND that the call fails closed
naming the variable to set — the second half because my first draft pointed the
provider at vendor.example, so the server saw nothing and the assertion held
for a reason unrelated to the fix. Break-checked: removing the guard puts
"Bearer sk-openai-must-not-travel" on the wire to the other vendor.

The scrub check failed open. As a bare condition, a grep ERROR (exit >= 2)
reads as "not found" and skips the guard — a credential check that passes
precisely when it cannot see the filesystem it is searching. It now
distinguishes 0/1/>=2 and refuses to continue on error.

A bare "claude-code" spec has no "/", so the provider fell back to ollama-cloud
and the pre-flight would skip a reviewer that authenticates with
CLAUDE_CODE_OAUTH_TOKEN and needs no Ollama key. Engine specs are now exempt.

preflight.sh's provider list duplicated its own case arms; both now read one
table. And its comment claimed the Go cross-check fails if either list misses
an entry from the other, when only one direction is checked — the reverse is
not even desirable, since ollama-cloud and anthropic belong in that table and
not in the Go one. The comment now says what is enforced.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Author
Owner

Round 7: 13 of 14 real, fixed in 3af0f09. Seven of them, from all four models, are the same finding — and it's the worst one in this PR.

🔒 I fixed the cross-vendor credential leak on resolveModel and left endpointProvider leaking. In the commit whose own message argued those two paths must move together. For the third time in this PR. A GADFLY_ENDPOINT_QWEN="qwen|https://…" with no key field sends OPENAI_API_KEY to Alibaba.

It is no longer a rule written twice: openAICompatOptions owns it and both paths call it, and builtinCompatProviders replaces the provider == "kimi" || provider == "qwen" literal that was a fourth uncounted copy of the list. The test drives a real request at a local server and asserts both that no request carries the foreign key and that the call fails closed naming the variable to set — the second half because my first draft pointed the provider at vendor.example, so the server saw nothing and the assertion passed for a reason unrelated to the fix. Break-checked: removing the guard puts Bearer sk-openai-must-not-travel on the wire to the other vendor.

Also fixed:

  • The scrub check failed open. As a bare condition, a grep error (exit ≥2) reads as "not found" and skips the guard — a credential check that passes precisely when it can't see the filesystem it's searching. Now distinguishes 0/1/≥2 and refuses to continue on error.
  • A bare claude-code spec got a false skip. No /, so the provider fell back to ollama-cloud and the pre-flight would disable a reviewer authenticating with CLAUDE_CODE_OAUTH_TOKEN. Engine specs are exempt now. (claude-code/opus has a slash, which is why the live swarm never hit it.)
  • gadfly_preflight_providers hand-repeated its own case arms — both now read one table.
  • Another reassuring comment that was wrong: it claimed the Go cross-check fails if either list misses an entry from the other, when only one direction is checked — and the reverse isn't desirable, since ollama-cloud/anthropic belong in the shell table and not the Go one.

One graded false: d01f65bc — its title is leaked drafting text ("The other items the draft lists were already 'checked out, no issue' observations…") and the location points at the credential table, where I found nothing after going through it line by line. Its sibling 06962ddd has the same garbled title but points at the real endpointProvider line, so I graded that one real with low usefulness. Worth a look at glm-5.2's title generation.

59 findings, seven rounds, one false positive.

@gadfly review

Round 7: 13 of 14 real, fixed in 3af0f09. **Seven of them, from all four models, are the same finding** — and it's the worst one in this PR. **🔒 I fixed the cross-vendor credential leak on `resolveModel` and left `endpointProvider` leaking.** In the commit whose own message argued those two paths must move together. For the third time in this PR. A `GADFLY_ENDPOINT_QWEN="qwen|https://…"` with no key field sends `OPENAI_API_KEY` to Alibaba. It is no longer a rule written twice: `openAICompatOptions` owns it and both paths call it, and `builtinCompatProviders` replaces the `provider == "kimi" || provider == "qwen"` literal that was a fourth uncounted copy of the list. The test drives a real request at a local server and asserts both that no request carries the foreign key *and* that the call fails closed naming the variable to set — the second half because my first draft pointed the provider at `vendor.example`, so the server saw nothing and the assertion passed for a reason unrelated to the fix. Break-checked: removing the guard puts `Bearer sk-openai-must-not-travel` on the wire to the other vendor. Also fixed: - **The scrub check failed open.** As a bare condition, a grep *error* (exit ≥2) reads as "not found" and skips the guard — a credential check that passes precisely when it can't see the filesystem it's searching. Now distinguishes 0/1/≥2 and refuses to continue on error. - **A bare `claude-code` spec got a false skip.** No `/`, so the provider fell back to `ollama-cloud` and the pre-flight would disable a reviewer authenticating with `CLAUDE_CODE_OAUTH_TOKEN`. Engine specs are exempt now. (`claude-code/opus` has a slash, which is why the live swarm never hit it.) - **`gadfly_preflight_providers` hand-repeated its own case arms** — both now read one table. - **Another reassuring comment that was wrong**: it claimed the Go cross-check fails if either list misses an entry from the other, when only one direction is checked — and the reverse isn't desirable, since ollama-cloud/anthropic belong in the shell table and not the Go one. One graded false: `d01f65bc` — its title is leaked drafting text ("The other items the draft lists were already 'checked out, no issue' observations…") and the location points at the credential table, where I found nothing after going through it line by line. Its sibling `06962ddd` has the same garbled title but points at the real `endpointProvider` line, so I graded that one real with low usefulness. Worth a look at glm-5.2's title generation. **59 findings, seven rounds, one false positive.** @gadfly review
steve added 1 commit 2026-08-12 22:23:35 +00:00
fix(qwen): un-exempt opencode, and keep the Qwen key in a secret
Build & push image / test (pull_request) Successful in 9m40s
Build & push image / build-and-push (pull_request) Successful in 4s
e67f95d777
Two findings this round contradicted each other — one asked me to extend the
engine-spec exemption to a bare "opencode", the other said opencode should not
be exempt at all. The code settles it: that engine drives an ollama-cloud model
through the bundled CLI and authenticates with OLLAMA_API_KEY, so it needs
exactly the key the pre-flight checks. Exempting it, which I did last round,
switched the check off for the one engine it could still help. Only claude-code
is exempt now — it carries CLAUDE_CODE_OAUTH_TOKEN and needs no Ollama key —
and opencode/open-code get table rows so both spellings are covered.

The README told operators to embed the Qwen key in a GADFLY_ENDPOINT_* var,
while the workflow that forwards those vars warns in its own comments that vars
are NOT masked. Rather than only rewording the docs, a keyless kimi/qwen
endpoint now falls back to its own QWEN_API_KEY / KIMI_API_KEY — the same
vendor's key, so the no-cross-vendor rule is untouched — which lets the URL live
in a var and the credential in a secret. Break-checked by pointing that fallback
at OPENAI_API_KEY: the leak test catches it.

Smaller: isBuiltinCompatProvider mirrors isOpenAICompatProvider instead of an
inline slices.Contains, with a test that every builtin is also in the compat
list (a builtin missing from it would never reach the branch that protects it);
the preflight.sh rationale is stated once rather than in two comment blocks;
the Go test locates the shell script relative to its own source file; and the
gofmt step takes GOPROXY=off like its neighbours.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Author
Owner

Round 8: all eight real, fixed in e67f95d. Two of them contradicted each other, which was the interesting part.

  • ed177217 asked me to extend the engine-spec exemption to a bare opencode; 9878a142 said opencode shouldn't be exempt at all. The code settles it: that engine drives an ollama-cloud model through the bundled CLI on OLLAMA_API_KEY, so it needs exactly the key the pre-flight checks. Exempting it — which I did last round — switched the check off for the one engine it could still help. Only claude-code is exempt now (it carries CLAUDE_CODE_OAUTH_TOKEN), and opencode/open-code get table rows so both spellings are covered. Graded 9878a142 higher: following ed177217's prescription would have widened the bug.
  • 🔒 My README contradicted the repo's own security note. It told operators to embed the Qwen key in a GADFLY_ENDPOINT_* var, while the workflow that forwards those vars warns in its comments that vars are not masked. Fixed in code, not just prose: a keyless kimi/qwen endpoint now falls back to its own QWEN_API_KEY/KIMI_API_KEY — same vendor, so the no-cross-vendor rule is untouched — letting the URL live in a var and the credential in a secret. Break-checked by pointing that fallback at OPENAI_API_KEY: the leak test catches it.

Smaller, all correct: isBuiltinCompatProvider mirrors its twin instead of an inline slices.Contains (plus a test that every builtin is in the compat list, since one that isn't would never reach the branch protecting it); the rationale is stated once rather than in two comment blocks; the Go test locates the shell script relative to its own source; gofmt takes GOPROXY=off like its neighbours.

67 findings, eight rounds, one false positive.

Round 8's findings are materially smaller than round 7's — the last two security issues were both mine from the round before, and this round's are consistency and docs. That reads like convergence. If round 9 turns up nothing structural I'll merge.

@gadfly review

Round 8: all eight real, fixed in e67f95d. Two of them **contradicted each other**, which was the interesting part. - `ed177217` asked me to extend the engine-spec exemption to a bare `opencode`; `9878a142` said opencode shouldn't be exempt at all. The code settles it: that engine drives an **ollama-cloud** model through the bundled CLI on `OLLAMA_API_KEY`, so it needs exactly the key the pre-flight checks. **Exempting it — which I did last round — switched the check off for the one engine it could still help.** Only `claude-code` is exempt now (it carries `CLAUDE_CODE_OAUTH_TOKEN`), and `opencode`/`open-code` get table rows so both spellings are covered. Graded `9878a142` higher: following `ed177217`'s prescription would have widened the bug. - **🔒 My README contradicted the repo's own security note.** It told operators to embed the Qwen key in a `GADFLY_ENDPOINT_*` var, while the workflow that forwards those vars warns in its comments that vars are **not masked**. Fixed in code, not just prose: a keyless `kimi`/`qwen` endpoint now falls back to its **own** `QWEN_API_KEY`/`KIMI_API_KEY` — same vendor, so the no-cross-vendor rule is untouched — letting the URL live in a var and the credential in a secret. Break-checked by pointing that fallback at `OPENAI_API_KEY`: the leak test catches it. Smaller, all correct: `isBuiltinCompatProvider` mirrors its twin instead of an inline `slices.Contains` (plus a test that every builtin is in the compat list, since one that isn't would never reach the branch protecting it); the rationale is stated once rather than in two comment blocks; the Go test locates the shell script relative to its own source; `gofmt` takes `GOPROXY=off` like its neighbours. **67 findings, eight rounds, one false positive.** Round 8's findings are materially smaller than round 7's — the last two security issues were both mine from the round before, and this round's are consistency and docs. That reads like convergence. If round 9 turns up nothing structural I'll merge. @gadfly review
gitea-actions bot reviewed 2026-08-12 22:32:55 +00:00
gitea-actions bot left a comment

🪰 Gadfly consensus review — 10 inline findings on changed lines. See the consensus comment for the full ranked summary.

Advisory only — does not block merge.

<!-- gadfly-inline-review --> 🪰 **Gadfly consensus review** — 10 inline findings on changed lines. See the consensus comment for the full ranked summary. <sub>Advisory only — does not block merge.</sub>
@@ -48,0 +50,4 @@
# this repo had NO test job at all — `go test` and scripts/preflight_test.sh
# both existed and neither was ever executed by CI, which is worse than
# having no tests, since it reads as coverage.
test:

🟠 New pull_request-triggered go test job is the first job in the repo to execute PR-authored code (vs. compile-only or text-only LLM review); no permissions: block or actor gate, though this matches (not deviates from) the repo's existing pattern for other pull_request-triggered secret-bearing jobs

security · flagged by 1 model

🪰 Gadfly · advisory

🟠 **New pull_request-triggered `go test` job is the first job in the repo to execute PR-authored code (vs. compile-only or text-only LLM review); no permissions: block or actor gate, though this matches (not deviates from) the repo's existing pattern for other pull_request-triggered secret-bearing jobs** _security · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
@@ -48,0 +75,4 @@
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |

🟡 Complex credential-scrub bash embedded inline in YAML instead of extracted to a testable script, inconsistent with how preflight.sh was factored out in the same PR

maintainability · flagged by 1 model

  • .gitea/workflows/build-image.yml:78-126 — The new test job's "Fetch private modules" step embeds a ~49-line, non-trivial bash routine (temp GIT_CONFIG_GLOBAL, trap-based cleanup, base64 auth-header construction, then a $HOME-wide grep scrub-verification with three-way exit-code handling) directly in the YAML run: | block. Confirmed by reading the full file: the workflow's other run: steps (Set up Docker Buildx, Log in to the registry, Compute tags at lines 162-181, the tw…

🪰 Gadfly · advisory

🟡 **Complex credential-scrub bash embedded inline in YAML instead of extracted to a testable script, inconsistent with how preflight.sh was factored out in the same PR** _maintainability · flagged by 1 model_ - `.gitea/workflows/build-image.yml:78-126` — The new `test` job's "Fetch private modules" step embeds a ~49-line, non-trivial bash routine (temp `GIT_CONFIG_GLOBAL`, `trap`-based cleanup, base64 auth-header construction, then a `$HOME`-wide `grep` scrub-verification with three-way exit-code handling) directly in the YAML `run: |` block. Confirmed by reading the full file: the workflow's other `run:` steps (`Set up Docker Buildx`, `Log in to the registry`, `Compute tags` at lines 162-181, the tw… <sub>🪰 Gadfly · advisory</sub>
@@ -22,0 +57,4 @@
return strings.ToUpper(strings.ReplaceAll(provider, "-", "_")) + "_API_KEY"
}
// openAICompatOptions builds the option set for an openai-compat provider, and

🟡 No-cross-vendor-fallback rationale is restated in multiple places across Go and shell; prose has no single source of truth the way the code does

maintainability · flagged by 1 model

🪰 Gadfly · advisory

🟡 **No-cross-vendor-fallback rationale is restated in multiple places across Go and shell; prose has no single source of truth the way the code does** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
@@ -22,0 +79,4 @@
key, keyHint = own, builtinCompatKeyEnv(provider)
}
}
opts = append(opts, openai.WithAPIKey(key), openai.WithAPIKeyName(keyHint))

🟠 Missing-key hint on the GADFLY_ENDPOINT_ path for builtins names the unmasked endpoint var, steering operators to store a credential in a non-masked Gitea variable (contradicts README's own warning); should name QWEN_API_KEY/KIMI_API_KEY instead*

security · flagged by 1 model

  • cmd/gadfly/model.go:82 / cmd/gadfly/model.go:337-338 — missing-key hint steers operators toward an unmasked credential variable. On the GADFLY_ENDPOINT_* path, when a builtin (qwen/kimi) has no explicit |<key> field and its own env var (QWEN_API_KEY/KIMI_API_KEY) is unset, openAICompatOptions leaves keyHint as "GADFLY_ENDPOINT_"+strings.ToUpper(name) (set at model.go:338, never reassigned because the reassignment at model.go:78-80 is guarded by own != ""). The resulti…

🪰 Gadfly · advisory

🟠 **Missing-key hint on the GADFLY_ENDPOINT_* path for builtins names the unmasked endpoint var, steering operators to store a credential in a non-masked Gitea variable (contradicts README's own warning); should name QWEN_API_KEY/KIMI_API_KEY instead** _security · flagged by 1 model_ - **`cmd/gadfly/model.go:82` / `cmd/gadfly/model.go:337-338` — missing-key hint steers operators toward an unmasked credential variable.** On the `GADFLY_ENDPOINT_*` path, when a builtin (qwen/kimi) has no explicit `|<key>` field *and* its own env var (`QWEN_API_KEY`/`KIMI_API_KEY`) is unset, `openAICompatOptions` leaves `keyHint` as `"GADFLY_ENDPOINT_"+strings.ToUpper(name)` (set at model.go:338, never reassigned because the reassignment at model.go:78-80 is guarded by `own != ""`). The resulti… <sub>🪰 Gadfly · advisory</sub>
@@ -71,0 +87,4 @@
// a provider that works when configured one way and errors the other, for no
// reason a user could guess. Asserting both from one table makes the pair fail
// together.
func TestOpenAICompatProvidersResolveOnBothPaths(t *testing.T) {

🟡 TestOpenAICompatProvidersResolveOnBothPaths is mostly redundant with the stronger TestEndpointProviderNamesAreAllAccepted (only the Name() check is unique)

maintainability · flagged by 1 model

  • cmd/gadfly/model_test.go:90 TestOpenAICompatProvidersResolveOnBothPaths is largely subsumed by TestEndpointProviderNamesAreAllAccepted (line 120). The latter ranges endpointProviderNames (model.go:100-101), which is a strict superset of openAICompatProviders (model.go:34 — openai/openai-compatible/kimi/qwen … plus ollama/ollama-cloud/llama-swap/...), and asserts both endpointProvider and resolveModel resolve without error for every name — the same "both paths" assertion t…

🪰 Gadfly · advisory

🟡 **TestOpenAICompatProvidersResolveOnBothPaths is mostly redundant with the stronger TestEndpointProviderNamesAreAllAccepted (only the Name() check is unique)** _maintainability · flagged by 1 model_ - **`cmd/gadfly/model_test.go:90` `TestOpenAICompatProvidersResolveOnBothPaths` is largely subsumed by `TestEndpointProviderNamesAreAllAccepted` (line 120).** The latter ranges `endpointProviderNames` (model.go:100-101), which is a strict superset of `openAICompatProviders` (model.go:34 — `openai/openai-compatible/kimi/qwen` … plus `ollama/ollama-cloud/llama-swap/...`), and asserts both `endpointProvider` and `resolveModel` resolve without error for every name — the same "both paths" assertion t… <sub>🪰 Gadfly · advisory</sub>
@@ -0,0 +13,4 @@
# gadfly_preflight_key <provider> -> echoes "" when the run may proceed, or the
# name of the environment variable the operator must set.
#
# Scope: the REGISTRY path only — GADFLY_BASE_URL unset — and deliberately so.

🟠 Override-path built-ins (GADFLY_BASE_URL set) are not pre-flighted; the stated rationale ('a built-in's own variable is never consulted on this path') is contradicted by openAICompatOptions consulting os.Getenv(builtinCompatKeyEnv(provider)) at model.go:78 on exactly the GADFLY_BASE_URL override path (model.go:159-161), so a keyless qwen/kimi override config passes preflight and fails at runtime as a 401 instead of a preflight notice

error-handling · flagged by 1 model

  • scripts/preflight.sh:16-25 / cmd/gadfly/model.go:77-81,159-161 — the preflight's stated rationale for skipping the override path is contradicted by the code. The preflight comment justifies excluding the GADFLY_BASE_URL-set path by claiming that on it "a built-in's own variable is never consulted." But openAICompatOptions — called on exactly that override path at model.go:159-161does consult the built-in's own variable: os.Getenv(builtinCompatKeyEnv(provider)) at `model.go…

🪰 Gadfly · advisory

🟠 **Override-path built-ins (GADFLY_BASE_URL set) are not pre-flighted; the stated rationale ('a built-in's own variable is never consulted on this path') is contradicted by openAICompatOptions consulting os.Getenv(builtinCompatKeyEnv(provider)) at model.go:78 on exactly the GADFLY_BASE_URL override path (model.go:159-161), so a keyless qwen/kimi override config passes preflight and fails at runtime as a 401 instead of a preflight notice** _error-handling · flagged by 1 model_ - **`scripts/preflight.sh:16-25` / `cmd/gadfly/model.go:77-81,159-161` — the preflight's stated rationale for skipping the override path is contradicted by the code.** The preflight comment justifies excluding the `GADFLY_BASE_URL`-set path by claiming that on it "a built-in's own variable is never consulted." But `openAICompatOptions` — called on exactly that override path at `model.go:159-161` — *does* consult the built-in's own variable: `os.Getenv(builtinCompatKeyEnv(provider))` at `model.go… <sub>🪰 Gadfly · advisory</sub>
@@ -0,0 +36,4 @@
# needs exactly the key the table checks. Exempting it — which an earlier
# version of this guard did — turns the pre-flight off for the one engine
# whose missing key it could still catch.
case "$model" in

🟡 Untrimmed model parameter in claude-code case: whitespace in GADFLY_MODEL bypasses the claude-code exemption and falls through to provider key check

error-handling · flagged by 1 model

  • scripts/run.sh:173 / scripts/preflight.sh:39 — The claude-code case exemption in gadfly_preflight_key does not trim whitespace from the model parameter, while the Go binary trims GADFLY_MODEL. If a model spec has leading/trailing whitespace (e.g., "claude-code "), the case statement won't match and the preflight will fall through to check the provider's key, potentially requiring an OLLAMA_CLOUD_API_KEY that the engine doesn't need. Suggested fix: Trim whitespace from `mode…

🪰 Gadfly · advisory

🟡 **Untrimmed model parameter in claude-code case: whitespace in GADFLY_MODEL bypasses the claude-code exemption and falls through to provider key check** _error-handling · flagged by 1 model_ * `scripts/run.sh:173` / `scripts/preflight.sh:39` — The `claude-code` case exemption in `gadfly_preflight_key` does not trim whitespace from the `model` parameter, while the Go binary trims `GADFLY_MODEL`. If a model spec has leading/trailing whitespace (e.g., `"claude-code "`), the case statement won't match and the preflight will fall through to check the provider's key, potentially requiring an `OLLAMA_CLOUD_API_KEY` that the engine doesn't need. **Suggested fix:** Trim whitespace from `mode… <sub>🪰 Gadfly · advisory</sub>
@@ -0,0 +46,4 @@
# value here would call it "set", skip the check, and let the missing key
# arrive as a 401 with no notice — the two must agree on what "unset" means.
local base_url
base_url="$(printf '%s' "${GADFLY_BASE_URL:-}" | tr -d '[:space:]')"

🟡 Unicode whitespace mismatch: bash tr -d [:space:] strips only POSIX whitespace while Go strings.TrimSpace strips all Unicode space, causing preflight to skip key checks for registry-path runs when GADFLY_BASE_URL contains only non-ASCII whitespace

error-handling · flagged by 1 model

  • scripts/preflight.sh:49 — Unicode whitespace edge case in GADFLY_BASE_URL "unset" detection. The bash side uses tr -d '[:space:]', which only strips POSIX whitespace characters (space, tab, newline, carriage return, form-feed, vertical tab). Go's strings.TrimSpace removes all Unicode space characters as defined by unicode.IsSpace (e.g., non-breaking space \u00A0, zero-width space, etc.). If GADFLY_BASE_URL is set to a value containing only non-ASCII whitespace, Go treats it as em…

🪰 Gadfly · advisory

🟡 **Unicode whitespace mismatch: bash tr -d [:space:] strips only POSIX whitespace while Go strings.TrimSpace strips all Unicode space, causing preflight to skip key checks for registry-path runs when GADFLY_BASE_URL contains only non-ASCII whitespace** _error-handling · flagged by 1 model_ * `scripts/preflight.sh:49` — Unicode whitespace edge case in `GADFLY_BASE_URL` "unset" detection. The bash side uses `tr -d '[:space:]'`, which only strips POSIX whitespace characters (space, tab, newline, carriage return, form-feed, vertical tab). Go's `strings.TrimSpace` removes all Unicode space characters as defined by `unicode.IsSpace` (e.g., non-breaking space `\u00A0`, zero-width space, etc.). If `GADFLY_BASE_URL` is set to a value containing only non-ASCII whitespace, Go treats it as em… <sub>🪰 Gadfly · advisory</sub>
@@ -0,0 +59,4 @@
# If that copy ever moves after this call, this arm reports a missing key for
# a configured run.
local row
row="$(_gadfly_preflight_table | awk -F: -v p="$provider" '$1 == p {print; exit}')"

awk|cut|cut lookup is heavier than the 8-row table warrants; deliberate tradeoff but harder to read than a case/read

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **awk|cut|cut lookup is heavier than the 8-row table warrants; deliberate tradeoff but harder to read than a case/read** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
@@ -0,0 +81,4 @@
# _gadfly_preflight_table is the single source for both the credential lookup
# and the provider list: "<provider>:<env-var-read>:<env-var-to-suggest>".
#
# The third field is normally empty, meaning "same as the second". ollama-cloud

Duplicated ollama-cloud hint-vs-copy rationale comment in two adjacent blocks (also at lines 55-60); one copy can rot

maintainability · flagged by 1 model

🪰 Gadfly · advisory

⚪ **Duplicated ollama-cloud hint-vs-copy rationale comment in two adjacent blocks (also at lines 55-60); one copy can rot** _maintainability · flagged by 1 model_ <sub>🪰 Gadfly · advisory</sub>
steve added 1 commit 2026-08-12 22:36:14 +00:00
fix(qwen): the own-key fallback made the override path checkable
Build & push image / test (pull_request) Successful in 9m35s
Build & push image / build-and-push (pull_request) Successful in 4s
d8b023efd6
Round 9's best finding is that my own round-8 change falsified a rationale I
wrote in round 5. The pre-flight skips the endpoint-override path because "a
built-in's own variable is never consulted there" — then I gave kimi/qwen an
own-key fallback that consults exactly that variable on exactly that path. So a
keyless override config sailed past the check and failed as a 401, which is the
failure the check exists to replace.

Now that the rule is statable for those two providers, they are checked on both
paths ("own key or GADFLY_API_KEY"), while everything else stays silent on the
override path because its rules still are not.

The missing-key hint on the GADFLY_ENDPOINT_* path named the endpoint variable
— telling a keyless operator to put a credential in a Gitea var, which is not
masked, and contradicting the README warning added one round earlier. It now
always names the provider's own masked secret.

Also: the model argument is trimmed, since Go trims GADFLY_MODEL and padding
would otherwise slip past the claude-code exemption; the test job takes
`permissions: contents: read`, being the one job that executes PR-authored
code; and the ollama-cloud rationale is stated once.

Deliberately not taken, with reasons rather than silence: the credential-scrub
bash could be extracted to a testable script like preflight.sh was — fair, and
a follow-up, since moving it now would be a fresh untested surface at merge
time. `tr -d [:space:]` strips POSIX whitespace where Go strips Unicode, which
differs only for a GADFLY_BASE_URL made entirely of non-ASCII spaces. And the
two provider tests overlap but assert different contracts that should be able
to fail independently.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
steve merged commit b850e35b35 into main 2026-08-12 22:46:04 +00:00
steve deleted branch feat/qwen-provider 2026-08-12 22:46:04 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: steve/gadfly#30