13 Commits
Author SHA1 Message Date
steveandClaude Opus 5 8adeeeabe0 fix: drop the accidentally committed worktree gitlink, ignore .claude/worktrees
Build & push image / build-and-push (push) Successful in 6s
Build & push image / test (push) Successful in 9m42s
The pin bump used `git add -A` from a checkout that had a git worktree under
.claude/worktrees/, so the previous commit recorded it as a gitlink — a
submodule pointer to a commit no clone can resolve. Removed, and .gitignore now
covers the directory so it cannot recur.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 18:57:53 -04:00
steveandClaude Opus 5 88f44cf141 chore(reusable): bump the fallback reviewer pin to sha-b850e35 [skip ci]
PR #30 (qwen/kimi built-ins) is merged and the image is published, so the
last-resort pin points at it. Verified the tag exists in the registry before
pointing consumers at it — a pin to a tag that was never pushed breaks every
consumer at once, and the build succeeding is not by itself proof the tag
landed.

GADFLY_REVIEWER_TAG still overrides this, and an explicit reviewer_tag input
overrides both.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-12 18:57:34 -04:00
steve b850e35b35 Merge pull request 'feat(qwen): let Qwen (and Kimi) join the swarm' (#30)
Build & push image / build-and-push (push) Successful in 39s
Build & push image / test (push) Successful in 9m42s
2026-08-12 22:46:04 +00:00
steveandClaude Opus 5 d8b023efd6 fix(qwen): the own-key fallback made the override path checkable
Build & push image / build-and-push (pull_request) Successful in 4s
Build & push image / test (pull_request) Successful in 9m35s
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]>
2026-08-12 18:36:12 -04:00
steveandClaude Opus 5 e67f95d777 fix(qwen): un-exempt opencode, and keep the Qwen key in a secret
Build & push image / build-and-push (pull_request) Successful in 4s
Build & push image / test (pull_request) Successful in 9m40s
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]>
2026-08-12 18:23:33 -04:00
steveandClaude Opus 5 3af0f09387 fix(qwen): one credential rule for both paths — seven findings said so
Build & push image / build-and-push (pull_request) Successful in 5s
Build & push image / test (pull_request) Successful in 9m40s
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]>
2026-08-12 18:09:18 -04:00
steveandClaude Opus 5 274451e89c fix(ci): actually add grep -e (a3d3a45 claimed it and did not)
Build & push image / build-and-push (pull_request) Successful in 4s
Build & push image / test (pull_request) Successful in 9m38s
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]>
2026-08-12 17:52:57 -04:00
steveandClaude Opus 5 a3d3a45e7e fix(qwen): bump majordomo, and stop handing keys to the wrong vendor
Build & push image / build-and-push (pull_request) Successful in 9s
Build & push image / test (pull_request) Canceled after 59s
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]>
2026-08-12 17:51:55 -04:00
steveandClaude Opus 5 0abcd16e9e fix(ci): make the credential scrub failure-safe, and stop the lists drifting
Build & push image / build-and-push (pull_request) Successful in 1m13s
Build & push image / test (pull_request) Successful in 9m41s
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]>
2026-08-12 17:40:03 -04:00
steveandClaude Opus 5 14f8533e38 fix(ci): scrub the registry credential before running repo code
Build & push image / build-and-push (pull_request) Successful in 4s
Build & push image / test (pull_request) Successful in 9m47s
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]>
2026-08-12 17:28:08 -04:00
steveandClaude Opus 5 ebfaeba07e docs(qwen): warn that Model Studio keys are endpoint-scoped
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]>
2026-08-12 17:22:08 -04:00
steve d272695c16 Merge pull request 'feat(reusable): resolve the reviewer image tag at runtime (GADFLY_REVIEWER_TAG)' (#28) from reviewer-tag-runtime-var into main
Build & push image / build-and-push (push) Successful in 6s
Reviewed-on: #28
2026-07-27 03:32:45 +00:00
steveandClaude Fable 5 4dafac0d13 feat(reusable): resolve the reviewer image tag at runtime (GADFLY_REVIEWER_TAG)
Build & push image / build-and-push (pull_request) Successful in 5s
Gadfly review (reusable) / review (pull_request) Successful in 6m42s
Adversarial Review (Gadfly) / review (pull_request) Successful in 6m43s
Nearly every consumer re-pin of review-reusable.yml was a reviewer IMAGE bump,
not a structural change — but the tag was baked into a `uses: docker://` step,
which is parsed before any expression context exists and so can't read a
variable. Run the reviewer as the JOB container instead (container.image DOES
accept expressions) with an explicit `run: /entrypoint.sh` step: the tag now
resolves per-run via inputs.reviewer_tag → user var GADFLY_REVIEWER_TAG → the
baked fallback pin (sha-b37cd09, unchanged).

A Gadfly release becomes: build the image, update the ONE user-level variable —
every consumer pinned to this file's @<sha> picks it up on its next review with
no re-pin. Workflow re-pins remain only for structural yml changes (this is one
— intended to be the last routine one).

- Entrypoint contract unchanged: same env block, same /entrypoint.sh brains
  (bash + entrypoint already live in the image; ENTRYPOINT was /entrypoint.sh).
- Keep the variable on immutable sha- tags, never :latest (act_runner caches
  :latest and often does not re-pull a moved one).
- Tradeoff noted in comments: vars are unmasked and move without a commit
  trail — same blast radius as editing this file, minus the audit trail.
- README (image/pinning para + Central config table) and examples/reusable.yml
  updated per the maintenance rule.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-22 22:43:37 -04:00
12 changed files with 614 additions and 101 deletions
+73 -2
View File
@@ -53,25 +53,96 @@ jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
# This job executes repository code (`go test`) on pull_request, so it gets
# the narrowest token the platform will give it. Nothing here writes.
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
# Scrubbing the registry credential while leaving the checkout token
# in .git/config would just move the prize: `go test` below runs
# repository code with the workspace readable. Nothing in this job
# talks to git after checkout, so the token has no reason to persist.
persist-credentials: false
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Configure private module access
# Fetch dependencies, then DESTROY the credential before any step that
# executes repository code. REGISTRY_PASSWORD is push-capable, this repo
# is public so pull_request runs can carry attacker-authored code, and
# `go test` runs that code — a plaintext ~/.gitconfig left in place is a
# credential any test could print. The image build faces the same
# question and answers it the same way: its creds are BuildKit secrets
# scoped to the module-download RUN, never present while code runs.
- name: Fetch private modules
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
git config --global url."https://${REGISTRY_USER}:${REGISTRY_PASSWORD}@gitea.stevedudenhoeffer.com/".insteadOf "https://gitea.stevedudenhoeffer.com/"
set -euo pipefail
# Own the config path outright. `git config --global` writes to
# GIT_CONFIG_GLOBAL, else $XDG_CONFIG_HOME/git/config when that
# directory exists, else ~/.gitconfig — so "delete ~/.gitconfig"
# scrubs a file the credential may never have been in. Naming the
# path leaves exactly one file to remove.
export GIT_CONFIG_GLOBAL="$(mktemp)"
# Scrub on ANY exit, not just success. `set -e` means a failed
# `go mod download` aborts this step, and a cleanup written as the
# next line would never run — leaving a push-capable credential on a
# long-lived self-hosted runner for whatever job lands there next.
trap 'rm -f "$GIT_CONFIG_GLOBAL"' EXIT
go env -w GOPRIVATE=gitea.stevedudenhoeffer.com/*
# Basic-auth header rather than credentials inside the URL: a
# password containing @ : / or # breaks URL parsing, and the failure
# would look like a bad password rather than a quoting bug.
git config --global \
"http.https://gitea.stevedudenhoeffer.com/.extraheader" \
"Authorization: Basic $(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_PASSWORD" | base64 | tr -d '\n')"
go mod download
rm -f "$GIT_CONFIG_GLOBAL"
test ! -e "$GIT_CONFIG_GLOBAL"
# Prove the scrub across the whole home dir, not just the file we
# deleted — that check would pass no matter what, and git/go can also
# write ~/.netrc or ~/.config/go/env. Guarded on a non-empty secret:
# `grep -F ""` matches every file, so a secretless run (fork PR) would
# fail here with a message accusing it of leaking nothing.
# -e, so a password beginning with "-" is a pattern and not options.
# And distinguish grep's three exits: 0 found, 1 clean, >=2 ERROR. As
# a bare condition an error reads as "not found" and the guard is
# skipped — a check that fails OPEN in exactly the case where it can no
# longer see the filesystem it is supposed to be searching.
if [ -n "${REGISTRY_PASSWORD:-}" ]; then
set +e
grep -rqF -e "$REGISTRY_PASSWORD" "$HOME" 2>/dev/null
rc=$?
set -e
case "$rc" in
0) echo "::error::registry credential still present under \$HOME after scrub"; exit 1 ;;
1) : ;; # clean
*) echo "::error::credential scrub check could not run (grep exit $rc); refusing to continue"; exit 1 ;;
esac
fi
# GOPROXY=off from here on: the module cache is already warm, so any
# attempt to reach the network is a bug — and it fails loudly instead of
# quietly looking for the credential that is now gone.
- name: go build
env: { GOPROXY: "off" }
run: go build ./...
- name: go vet
env: { GOPROXY: "off" }
run: go vet ./...
- name: gofmt
env: { GOPROXY: "off" }
run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; }
- name: go test
env: { GOPROXY: "off" }
run: go test -count=1 ./...
- name: pre-flight credential table
run: bash scripts/preflight_test.sh
+33 -11
View File
@@ -24,11 +24,14 @@
# if you accept that exposure; the explicit form is recommended. GITEA_TOKEN is
# the automatic job token (no need to forward it).
#
# Advisory only — never blocks a merge. The image is pinned to an immutable
# :sha- tag here (act_runner caches :latest); bump it per Gadfly release.
# Consumers should pin `uses: ...@v1` — a curated release tag moved on deliberate
# releases, so central tuning here propagates without per-consumer edits — or a
# full `@<sha>` for an immutable pin. Avoid `@main` (moves on every push).
# Advisory only — never blocks a merge. The reviewer image tag ALSO resolves at
# runtime (inputs.reviewer_tag → user var GADFLY_REVIEWER_TAG → the fallback pin
# baked into the `container:` line below), so a Gadfly release is: build the
# image, update ONE variable — no consumer re-pin. Re-pin the workflow ref only
# for structural changes to this file.
# Consumers should pin `uses: ...@<sha>` — long-lived act_runners cache this file
# by ref, so a moved tag (@v1) or @main is often NOT re-fetched and silently runs
# a stale copy.
name: Gadfly review (reusable)
@@ -43,6 +46,7 @@ on:
# NOT re-fetched; only a runtime value or a fresh @<sha> bypasses the cache).
#
# Owner-set user-scope variables (see README "Central config via variables"):
# GADFLY_REVIEWER_TAG (the reviewer image tag this reusable runs),
# GADFLY_DEFAULT_MODELS, GADFLY_DEFAULT_SPECIALISTS,
# GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY (the provider-wide lens budget),
# GADFLY_ENDPOINT_NETHERSTORM (the local GPU box endpoint).
@@ -59,6 +63,7 @@ on:
max_steps: { type: string, default: "14" } # GADFLY_MAX_STEPS
worker_model: { type: string, default: "" } # GADFLY_WORKER_MODEL
allowed_users: { type: string, default: "" } # GADFLY_ALLOWED_USERS (consumer-specific; set in your stub)
reviewer_tag: { type: string, default: "" } # reviewer image tag (e.g. "sha-b850e35") — empty falls back to user var GADFLY_REVIEWER_TAG, then the pin baked into the container: line
# Gitea >= 1.27 does not propagate the CALLER's workflow_dispatch inputs into a
# called workflow's github.event, so a manual "review PR #N" dispatch arrived
# here with an empty PR and died at the entrypoint's "PR required" check. The
@@ -107,13 +112,30 @@ jobs:
review:
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout_minutes }}
# The reviewer runs as the JOB container (steps exec inside it), not a
# `uses: docker://` step: a job container image accepts ${{ }} expressions,
# while a `uses:` ref is parsed before any context exists. That lets the tag
# resolve per-run — inputs.reviewer_tag → user var GADFLY_REVIEWER_TAG → the
# baked fallback — so bumping the reviewer image is a ONE-variable edit that
# reaches every consumer despite their cached workflow ref.
#
# Always point the variable at an immutable :sha-<short> tag, never :latest —
# act_runner caches :latest and often does NOT re-pull a moved one; a fresh
# unique tag forces the pull. NB: vars are editable at will — whoever can edit
# the owner's variables redirects every consumer's reviewer image (same blast
# radius as editing this file, but without a commit trail).
#
# Fallback pin: sha-b850e35 — adds the qwen/kimi built-ins (PR #30: majordomo
# bumped for the qwen provider, a credential pre-flight that names the missing
# variable, and one no-cross-vendor-fallback rule so a keyless kimi/qwen
# endpoint can never send an OpenAI key to another vendor) on top of the
# provider-wide lens budget (PR #27). Keep it current-ish when touching this
# file anyway.
container:
image: gitea.stevedudenhoeffer.com/steve/gadfly:${{ inputs.reviewer_tag || vars.GADFLY_REVIEWER_TAG || 'sha-b850e35' }}
steps:
# Pin the reviewer image to an immutable sha (act_runner caches :latest, so a
# moved :latest is often NOT re-pulled). sha-b37cd09 adds the provider-wide
# lens budget (PR #27: one shared lens-permit pool per provider, the model cap
# removed) on top of the opencode CLI engine (PR #26) and the Gitea >= 1.27
# workflow_call reclassification. Bump per Gadfly release.
- uses: docker://gitea.stevedudenhoeffer.com/steve/gadfly:sha-b37cd09
- name: Run the gadfly reviewer
run: /entrypoint.sh
env:
# --- event context (from the CALLER's github.*) -------------------
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
+3
View File
@@ -1,3 +1,6 @@
/gadfly
/out
*.orig
# Local worktrees created for agent work — never part of the repo.
.claude/worktrees/
+28
View File
@@ -86,6 +86,27 @@ gets you a skip notice naming it, not a mis-keyed call. Note `kimi/<model>` (Moo
API, `KIMI_API_KEY`) is a different route than the `kimi-k2.6:cloud` entry in the
default swarm, which is Ollama Cloud and keyed by `OLLAMA_CLOUD_API_KEY`.
> **Qwen keys are endpoint-scoped, and the failure looks like a bad key.**
> Alibaba Model Studio issues *workspace-scoped* endpoints of the form
> `https://<workspace>.<region>.maas.aliyuncs.com/compatible-mode/v1`. A key
> issued for one host is rejected by another with a genuine
> `401 Incorrect API key provided` — so a perfectly good key reads as invalid if
> the endpoint doesn't match. The built-in defaults to the shared international
> host; point at your own with a named endpoint, which needs no code change:
>
> ```
> GADFLY_ENDPOINT_QWENWS = "qwen|https://<workspace>.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1"
> GADFLY_MODELS = "qwenws/qwen3.8-max,..."
> QWEN_API_KEY = <secret>
> ```
>
> **Leave the key out of the endpoint var.** `GADFLY_ENDPOINT_*` are Gitea
> *variables*, which are not masked in logs; the third `|<key>` field would put
> a credential there. Omit it and a `qwen`/`kimi` endpoint falls back to its own
> `QWEN_API_KEY` / `KIMI_API_KEY` secret — its own vendor's key, never another's.
>
> (Verified the hard way against a live deployment.)
> ### 🧪 Honest status
> Only the **Ollama** paths above are actually exercised. The OpenAI / Qwen / Kimi /
> Anthropic / Google
@@ -412,6 +433,12 @@ pinned version (plus `:latest`). Pin full-stub consumers to a `:vN` image tag fo
(`@v1`) or `@main` is often **not** re-fetched and silently runs a stale copy. A fresh `@<sha>` is the
only reliable way to roll out a *structural* change to the reusable.
Structural changes are the rare case, though: the reviewer **image tag** the reusable runs resolves at
runtime (`reviewer_tag` input → user var `GADFLY_REVIEWER_TAG` → the fallback pin baked into the
reusable), so a routine Gadfly release is *build the image → update `GADFLY_REVIEWER_TAG` to the new
`sha-<short>`* — every pinned consumer picks it up on its next review, no re-pin. Always point the
variable at an immutable `sha-` tag, never `:latest` (the runner caches `:latest`).
### Central config via variables
So you don't have to re-pin every consumer just to retune the swarm, the reusable resolves its config
@@ -421,6 +448,7 @@ on its next review **without** a re-pin or a tag move:
| Variable (user/org scope) | Sets |
|---|---|
| `GADFLY_REVIEWER_TAG` | the reviewer **image tag** the reusable runs (e.g. `sha-b37cd09`); empty ⇒ the fallback pin baked into the reusable |
| `GADFLY_DEFAULT_MODELS` | `GADFLY_MODELS` (csv) |
| `GADFLY_DEFAULT_SPECIALISTS` | the lens suite |
| `GADFLY_DEFAULT_PROVIDER_LENS_CONCURRENCY` | the per-provider lens budget (lens passes in flight per provider, shared across its models) |
+78 -25
View File
@@ -26,25 +26,80 @@ const defaultProvider = "ollama-cloud"
// built-ins that ARE that client pointed elsewhere, so an explicit endpoint for
// either belongs on the same branch.
//
// This is a slice rather than three copies of a case list because there are
// three places that must agree — resolveModel's switch, endpointProvider's
// switch, and the test that pins them — and the first version of this change
// added the names to one switch and not the other.
// One slice, because three places must agree: resolveModel's endpoint
// override, endpointProvider's GADFLY_ENDPOINT_* parser, and the test that
// pins them. A name accepted by one and rejected by another is a config that
// works when written one way and errors the other, for no reason a user could
// guess.
var openAICompatProviders = []string{"openai", "openai-compatible", "kimi", "qwen"}
// builtinCompatProviders are the openai-compat names that belong to a DIFFERENT
// vendor. openai.New defaults its credential to OPENAI_API_KEY, so any of these
// constructed without an explicit key would put an OpenAI key on the wire to
// Moonshot or Alibaba. Membership here means "pass the key unconditionally,
// even empty" — an absent key must be a 401, never a foreign credential.
var builtinCompatProviders = []string{"kimi", "qwen"}
func isOpenAICompatProvider(name string) bool {
return slices.Contains(openAICompatProviders, name)
}
// isBuiltinCompatProvider mirrors isOpenAICompatProvider rather than testing
// the slice inline, so both memberships are asked the same way.
func isBuiltinCompatProvider(name string) bool {
return slices.Contains(builtinCompatProviders, name)
}
// builtinCompatKeyEnv is the provider's own credential variable, matching the
// name majordomo's built-in reads on the registry path — so the same secret
// works whether or not an explicit endpoint is configured.
func builtinCompatKeyEnv(provider string) string {
return strings.ToUpper(strings.ReplaceAll(provider, "-", "_")) + "_API_KEY"
}
// openAICompatOptions builds the option set for an openai-compat provider, and
// is the ONE place the no-cross-vendor-fallback rule lives.
//
// Both resolution paths call it — resolveModel's GADFLY_BASE_URL override and
// endpointProvider's GADFLY_ENDPOINT_* parser. They had separate copies of this
// decision once, the guard was added to one of them, and the other kept leaking
// OPENAI_API_KEY to another vendor. keyHint names the variable to set when the
// key is absent, since the two paths take it from different places.
func openAICompatOptions(provider, baseURL, key, keyHint string) []openai.Option {
opts := []openai.Option{openai.WithBaseURL(baseURL)}
switch {
case isBuiltinCompatProvider(provider):
// With no explicit key, fall back to the provider's OWN variable
// (QWEN_API_KEY, KIMI_API_KEY). That is not the cross-vendor fallback
// this function exists to prevent — it is the same vendor's key — and
// it lets an operator keep the credential in a masked secret while the
// endpoint URL lives in a var, which is NOT masked.
//
// The hint always names that secret, never the caller's keyHint: on the
// GADFLY_ENDPOINT_* path the caller's is the endpoint variable, and
// pointing a keyless operator at it advises them to put a credential
// somewhere Gitea does not mask.
if key == "" {
key = os.Getenv(builtinCompatKeyEnv(provider))
}
opts = append(opts, openai.WithAPIKey(key), openai.WithAPIKeyName(builtinCompatKeyEnv(provider)))
case key != "":
opts = append(opts, openai.WithAPIKey(key))
// openai/openai-compatible with no explicit key keep openai.New's
// OPENAI_API_KEY default: for those names it IS the right key.
}
return opts
}
// endpointProviderNames is the operator-facing list of providers that accept an
// explicit endpoint. resolveModel and endpointProvider accept the SAME set, so
// they share one message rather than each carrying a hand-maintained copy that
// drifts in order and spelling — which is exactly what happened when kimi/qwen
// were added to both switches.
// one message serves both rather than each carrying a copy that drifts in
// order and spelling.
//
// Keep every accepted spelling here, including aliases: the first version of
// this constant dropped "gemini", so the list written to prevent drift had
// already drifted from the switches it describes.
// Every accepted spelling belongs here, aliases included —
// TestEndpointProviderNamesAreAllAccepted asserts that each name listed
// actually resolves, so an omission fails the build rather than misleading an
// operator who is already debugging.
const endpointProviderNames = "openai/openai-compatible/kimi/qwen/ollama/ollama-cloud/" +
"llama-swap/llama-swaps/llamaswap/llamaswaps/foreman/anthropic/google/gemini"
@@ -98,16 +153,14 @@ func resolveModel() (llm.Model, error) {
}
// Endpoint override: construct the provider directly at the given URL.
// The openai-compat family (openai/openai-compatible/kimi/qwen) is matched
// by the shared predicate, not a repeated case list. The credential here is
// GADFLY_API_KEY; the built-ins' own KIMI_API_KEY / QWEN_API_KEY apply only
// on the registry path above, where GADFLY_BASE_URL is unset.
// The openai-compat family is matched by the shared predicate, not a
// repeated case list. The credential on THIS path is GADFLY_API_KEY; the
// built-ins' own KIMI_API_KEY / QWEN_API_KEY are read only on the registry
// path above, where GADFLY_BASE_URL is unset. The two paths never share a
// credential rule — assuming they do produces a config that passes every
// check and then 401s.
if isOpenAICompatProvider(provider) {
opts := []openai.Option{openai.WithBaseURL(baseURL)}
if apiKey != "" {
opts = append(opts, openai.WithAPIKey(apiKey))
}
return openai.New(opts...).Model(model)
return openai.New(openAICompatOptions(provider, baseURL, apiKey, "GADFLY_API_KEY")...).Model(model)
}
switch provider {
@@ -279,13 +332,13 @@ func endpointProvider(name, raw string) (llm.Provider, error) {
return nil, fmt.Errorf("missing base URL in %q", raw)
}
// Same shared predicate as resolveModel the two must accept an identical
// set, and hand-copied case lists are how they drifted apart before.
// Same shared predicate as resolveModel: the two must accept an identical
// set, and a hand-copied case list cannot guarantee that.
if isOpenAICompatProvider(provider) {
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)}
if key != "" {
opts = append(opts, openai.WithAPIKey(key))
}
// The key for a named endpoint comes from the third DSN field, so that
// is what an absent one points at.
opts := append([]openai.Option{openai.WithName(name)},
openAICompatOptions(provider, baseURL, key, "GADFLY_ENDPOINT_"+strings.ToUpper(name))...)
return openai.New(opts...), nil
}
+248 -11
View File
@@ -1,8 +1,16 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
func TestEndpointProvider(t *testing.T) {
@@ -75,13 +83,13 @@ func TestEndpointProvider(t *testing.T) {
// together. kimi and qwen are majordomo built-ins that ARE the openai client at
// a different base URL, and two independent places have to know it:
// resolveModel's GADFLY_BASE_URL override, and endpointProvider's
// GADFLY_ENDPOINT_* parser. Adding a name to one and not the other yields a
// provider that works when configured one way and errors the other, for no
// reason a user could guess — which is exactly what happened here on the first
// pass. Asserting both in one table is what makes the pair fail together.
// GADFLY_ENDPOINT_* parser. A name accepted by one and rejected by the other is
// 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) {
// Ranges the SHARED slice rather than a fourth copy of the names: a test
// that pins a list against drift must not be able to drift from it.
// Ranges the SHARED slice: a test that pins a list against drift must not
// be able to drift from it.
for _, provider := range openAICompatProviders {
t.Run(provider+" via GADFLY_ENDPOINT_*", func(t *testing.T) {
p, err := endpointProvider("ep", provider+"|https://host.example/v1|sk-x")
@@ -105,21 +113,33 @@ func TestOpenAICompatProvidersResolveOnBothPaths(t *testing.T) {
}
// TestEndpointProviderNamesAreAllAccepted keeps the operator-facing list
// honest. endpointProviderNames exists to stop two error messages drifting
// apart, but nothing tied it to the switches it describes — and its first
// version had already dropped the "gemini" alias, so the anti-drift list was
// itself drifted. Every name it advertises must actually resolve.
// honest: every name endpointProviderNames advertises must actually resolve.
// The constant is read by somebody whose config just failed, so a name listed
// there and rejected by the code sends them to debug a spelling that was never
// going to work.
func TestEndpointProviderNamesAreAllAccepted(t *testing.T) {
for _, name := range strings.Split(endpointProviderNames, "/") {
name = strings.TrimSpace(name)
if name == "" {
continue
}
t.Run(name, func(t *testing.T) {
// Both switches, not one: this constant is the error text for BOTH
// GADFLY_ENDPOINT_* and GADFLY_BASE_URL, so a name accepted by only
// half of them still misleads whichever operator hits the other path.
t.Run(name+" via GADFLY_ENDPOINT_*", func(t *testing.T) {
if _, err := endpointProvider("ep", name+"|https://host.example/v1|sk-x"); err != nil {
t.Errorf("endpointProviderNames advertises %q but endpointProvider rejects it: %v", name, err)
}
})
t.Run(name+" via GADFLY_BASE_URL", func(t *testing.T) {
t.Setenv("GADFLY_PROVIDER", name)
t.Setenv("GADFLY_BASE_URL", "https://host.example/v1")
t.Setenv("GADFLY_API_KEY", "sk-x")
t.Setenv("GADFLY_MODEL", "some-model")
if _, err := resolveModel(); err != nil {
t.Errorf("endpointProviderNames advertises %q but resolveModel rejects it: %v", name, err)
}
})
}
}
@@ -144,3 +164,220 @@ func TestBuildSpec(t *testing.T) {
})
}
}
// TestOpenAICompatProvidersAreFullyWired closes the two remaining ways this
// provider family can be half-added. Adding one means touching three places in
// two languages, and nothing but this test connects them:
//
// - endpointProviderNames is the operator-facing list. A provider the code
// accepts but the list omits sends someone debugging a name that works.
// - scripts/preflight.sh needs a credential arm, or a missing key for that
// provider skips the pre-flight and arrives as five unexplained per-lens
// failures — the exact thing the pre-flight exists to replace.
//
// The shell side is queried, not parsed: preflight.sh exports
// gadfly_preflight_providers precisely so this test asks it what it covers.
// Regexing the case statement would make that file's formatting a contract no
// linter enforces, and a harmless reformat would fail a test in another
// language.
func TestOpenAICompatProvidersAreFullyWired(t *testing.T) {
advertised := make(map[string]bool)
for _, n := range strings.Split(endpointProviderNames, "/") {
advertised[strings.TrimSpace(n)] = true
}
// Locate the script relative to THIS source file rather than the working
// directory, so moving the package does not silently break the lookup.
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed; cannot locate scripts/preflight.sh")
}
script := filepath.Join(filepath.Dir(thisFile), "..", "..", "scripts", "preflight.sh")
out, err := exec.Command("bash", "-c", ". "+script+"; gadfly_preflight_providers").Output()
if err != nil {
t.Fatalf("query gadfly_preflight_providers from %s: %v", script, err)
}
preflighted := make(map[string]bool)
for _, line := range strings.Fields(string(out)) {
preflighted[line] = true
}
if len(preflighted) == 0 {
t.Fatal("gadfly_preflight_providers returned nothing — this test would pass vacuously")
}
for _, p := range openAICompatProviders {
if !advertised[p] {
t.Errorf("openAICompatProviders has %q but endpointProviderNames does not list it — "+
"the error message operators read would omit a name that works", p)
}
if !preflighted[p] {
t.Errorf("openAICompatProviders has %q but scripts/preflight.sh has no credential arm for it — "+
"a missing key for %s would skip the pre-flight and surface as unexplained lens failures", p, p)
}
}
}
// TestBuiltinCompatProvidersResolveViaRegistry exercises the PRIMARY path:
// a plain "qwen/<model>" in GADFLY_MODELS, with no GADFLY_BASE_URL, resolved
// through majordomo's registry rather than constructed here.
//
// Every other test in this file builds the client directly, so all of them
// passed against a majordomo release that had never heard of qwen — the
// dependency bump this feature depends on was missing and nothing said so. A
// compile error eventually caught it, which is luck, not cover.
func TestBuiltinCompatProvidersResolveViaRegistry(t *testing.T) {
for _, spec := range []string{"qwen/qwen3.8-max", "kimi/kimi-k2-0711-preview"} {
t.Run(spec, func(t *testing.T) {
t.Setenv("GADFLY_MODEL", spec)
t.Setenv("GADFLY_BASE_URL", "")
t.Setenv("GADFLY_PROVIDER", "")
if _, err := resolveModel(); err != nil {
t.Fatalf("resolveModel(%q): %v — the pinned majordomo may not "+
"provide this built-in; a bump is required, not just gadfly-side wiring", spec, err)
}
})
}
}
// TestBuiltinCompatProvidersNeverInheritOpenAIKey pins the rule that has now
// been broken on one path or the other three separate times: kimi and qwen are
// other vendors, openai.New defaults its credential to OPENAI_API_KEY, and a
// provider built without an explicit key therefore puts an OpenAI key on the
// wire to Moonshot or Alibaba.
//
// Both construction paths are asserted from one loop deliberately. Each time
// this was fixed on a single path the sibling kept leaking, so a test covering
// one of them would have passed through every one of those bugs.
//
// The provider is pointed at a LOCAL server, and the test demands two things:
// that no request arrives carrying the foreign key, and that the call fails
// closed naming the variable to set. Without the second half the test would
// pass on a provider that simply did nothing.
func TestBuiltinCompatProvidersNeverInheritOpenAIKey(t *testing.T) {
const foreign = "sk-openai-must-not-travel"
for _, provider := range builtinCompatProviders {
t.Run(provider+" via GADFLY_BASE_URL", func(t *testing.T) {
srv, seen := leakServer(t)
t.Setenv("OPENAI_API_KEY", foreign)
t.Setenv("GADFLY_PROVIDER", provider)
t.Setenv("GADFLY_BASE_URL", srv.URL+"/v1")
t.Setenv("GADFLY_API_KEY", "") // the operator forgot the key
t.Setenv("GADFLY_MODEL", "some-model")
m, err := resolveModel()
if err != nil {
t.Fatalf("resolveModel: %v", err)
}
assertFailsClosed(t, m, seen, foreign, "QWEN_API_KEY", "KIMI_API_KEY")
})
t.Run(provider+" via GADFLY_ENDPOINT_*", func(t *testing.T) {
srv, seen := leakServer(t)
t.Setenv("OPENAI_API_KEY", foreign)
p, err := endpointProvider("ep", provider+"|"+srv.URL+"/v1") // no key field
if err != nil {
t.Fatalf("endpointProvider: %v", err)
}
m, err := p.Model("some-model")
if err != nil {
t.Fatalf("Model: %v", err)
}
assertFailsClosed(t, m, seen, foreign, "QWEN_API_KEY", "KIMI_API_KEY")
})
}
}
// leakServer returns a server that records every Authorization header it is
// sent. A request arriving at all means the client did not fail closed.
func leakServer(t *testing.T) (*httptest.Server, *[]string) {
t.Helper()
var seen []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"c1","object":"chat.completion","choices":[{"index":0,` +
`"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`))
}))
t.Cleanup(srv.Close)
return srv, &seen
}
func assertFailsClosed(t *testing.T, m llm.Model, seen *[]string, foreign string, wantAnyHint ...string) {
t.Helper()
_, err := m.Generate(context.Background(), llm.Request{Messages: []llm.Message{llm.UserText("hi")}})
for _, auth := range *seen {
if strings.Contains(auth, foreign) {
t.Errorf("Authorization carried the OpenAI key to another vendor: %q", auth)
}
}
if len(*seen) > 0 {
t.Errorf("a keyless provider reached the network (%d request(s)) instead of failing closed", len(*seen))
}
// The positive half: prove it refused for the right reason, so the test
// cannot pass on a provider that quietly did nothing at all.
if err == nil {
t.Fatal("keyless provider returned no error; expected a missing-key failure")
}
// The hint must name a MASKED secret the operator can set, never the
// unmasked GADFLY_ENDPOINT_* variable.
named := false
for _, h := range wantAnyHint {
if strings.Contains(err.Error(), h) {
named = true
}
}
if !named {
t.Errorf("error = %v, want it to name one of %v so the operator knows what to set", err, wantAnyHint)
}
}
// TestBuiltinCompatOwnKeyFallback: with no key in the endpoint definition, a
// built-in falls back to its OWN variable (QWEN_API_KEY, KIMI_API_KEY) — never
// to another vendor's. This is what lets the credential live in a masked
// secret while the endpoint URL lives in a GADFLY_ENDPOINT_* var, which Gitea
// does not mask; the README used to advise embedding the key in that var.
func TestBuiltinCompatOwnKeyFallback(t *testing.T) {
const own, foreign = "sk-qwen-own", "sk-openai-must-not-travel"
srv, seen := leakServer(t)
t.Setenv("OPENAI_API_KEY", foreign)
t.Setenv("QWEN_API_KEY", own)
p, err := endpointProvider("ep", "qwen|"+srv.URL+"/v1") // no key field
if err != nil {
t.Fatalf("endpointProvider: %v", err)
}
m, err := p.Model("some-model")
if err != nil {
t.Fatalf("Model: %v", err)
}
if _, err := m.Generate(context.Background(), llm.Request{Messages: []llm.Message{llm.UserText("hi")}}); err != nil {
t.Fatalf("Generate: %v", err)
}
if len(*seen) == 0 {
t.Fatal("no request reached the server — the own-key fallback did not take effect")
}
for _, auth := range *seen {
if strings.Contains(auth, foreign) {
t.Errorf("Authorization carried the OpenAI key: %q", auth)
}
if !strings.Contains(auth, own) {
t.Errorf("Authorization = %q, want the provider's own QWEN_API_KEY", auth)
}
}
}
// TestBuiltinCompatProvidersAreOpenAICompat: the two slices are parallel, and a
// built-in missing from openAICompatProviders would never reach the branch that
// applies its unconditional-key rule — it would fall through to the generic
// switch and silently lose the protection.
func TestBuiltinCompatProvidersAreOpenAICompat(t *testing.T) {
for _, p := range builtinCompatProviders {
if !isOpenAICompatProvider(p) {
t.Errorf("%q is in builtinCompatProviders but not openAICompatProviders, so the "+
"no-cross-vendor-fallback branch never runs for it", p)
}
}
}
+3 -2
View File
@@ -17,8 +17,9 @@
# review never touches), so prefer the explicit form. Pin to an immutable
# @<sha>: long-lived act_runners CACHE the reusable by ref, so a moved tag (@v1)
# or @main is often not re-fetched and silently runs a stale copy. Bump the @<sha>
# to adopt a structural change; routine swarm tuning rides owner variables (see
# the gadfly README "Central config via variables") with no re-pin needed.
# only to adopt a structural change; routine swarm tuning AND reviewer image
# releases ride owner variables (GADFLY_DEFAULT_*, GADFLY_REVIEWER_TAG — see the
# gadfly README "Central config via variables") with no re-pin needed.
#
# For custom named endpoints (GADFLY_ENDPOINT_<NAME>) or a provider the reusable
# doesn't map, use the full stub in adversarial-review.yml instead.
+1 -1
View File
@@ -4,7 +4,7 @@ go 1.26.2
require (
gitea.stevedudenhoeffer.com/steve/executus v0.1.4
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260812210334-f837115a55c9
gopkg.in/yaml.v3 v3.0.1
)
+2 -2
View File
@@ -6,8 +6,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
gitea.stevedudenhoeffer.com/steve/executus v0.1.4 h1:4F99uCV3OVaE9ITFp0FjPiYxLUQO+WpE+wU2HCnpXNM=
gitea.stevedudenhoeffer.com/steve/executus v0.1.4/go.mod h1:WQP/lH+meU06OSNF0TQO/wQLcJCrMwpi0EMj5vSpVtk=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462 h1:1crjE1YkWHLZ91tUDOxN/Y5cuOnJ56e0U9UADoFfEPY=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260627225659-aa25b2c33462/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260812210334-f837115a55c9 h1:ExY2S6RN1UaA97ju4jzkuEGpfBx0p3vv9FY8B7Npy2I=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260812210334-f837115a55c9/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Regular → Executable
+105 -36
View File
@@ -2,10 +2,8 @@
# Credential pre-flight for the agentic reviewer, in ONE definition.
#
# Sourced by run.sh (production) and by preflight_test.sh (the table test), so
# there is no second copy to drift. An earlier version of this change had the
# logic in run.sh and a duplicate in the test reconciled by a regex diff — that
# guard only covered the provider table and not the decision logic below, which
# is precisely the half that had the bug.
# the tested bytes and the running bytes are the same. Keep it that way: a test
# that reimplements this logic can agree with a stale copy of it.
#
# Why pre-flight at all, when majordomo already fails closed with a 401:
# without it a missing key surfaces as five identical per-lens agent failures
@@ -15,46 +13,75 @@
# 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 — i.e. GADFLY_BASE_URL unset. That is deliberate.
# With an explicit endpoint, resolveModel constructs the client directly and the
# credential is GADFLY_API_KEY, falling back to the client's own default
# (OPENAI_API_KEY for the openai family) — while the built-ins' own variables are
# never consulted. Checking one path's rules against the other produced a
# false-pass in BOTH directions across successive fixes here, so this checks the
# path whose rules it can state exactly and stays silent on the other. An
# override-path config is hand-written by definition; the registry path is the
# one somebody hits by adding a model id to a var and forgetting the secret.
# Scope: the REGISTRY path only — GADFLY_BASE_URL unset — and deliberately so.
# The two resolution paths have DIFFERENT credential rules: with an explicit
# endpoint the 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; without one, the reverse. Applying either path's rule to
# the other yields a check that passes a run which then 401s — the precise
# failure this exists to prevent. So it covers the path whose rules it can state
# exactly and stays silent on the other. That is also the useful half: an
# override-path config is hand-written, while the registry path is what somebody
# hits by adding a model id to a var and forgetting the secret.
gadfly_preflight_key() {
local provider="$1" key_env="" key_hint=""
local provider="$1" model="${2:-}" key_env="" key_hint=""
# Only the registry path has knowable credential rules — see above.
if [ -n "${GADFLY_BASE_URL:-}" ]; then
echo ""
# claude-code carries its OWN auth (CLAUDE_CODE_OAUTH_TOKEN, else
# ANTHROPIC_API_KEY) and needs no Ollama key. A bare "claude-code" has no "/",
# so the caller's provider falls back to ollama-cloud and the table below
# would skip a perfectly configured reviewer.
#
# opencode is deliberately NOT exempt: that engine drives an ollama-cloud
# model through the bundled CLI and authenticates with OLLAMA_API_KEY, so it
# 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.
model="$(printf '%s' "$model" | tr -d '[:space:]')" # Go trims GADFLY_MODEL
case "$model" in
claude-code|claude-code/*) echo ""; return 0 ;;
esac
# Trim before testing: resolveModel does strings.TrimSpace on GADFLY_BASE_URL,
# so a whitespace-only value takes the REGISTRY path there. Testing the raw
# 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:]')"
if [ -n "$base_url" ]; then
# Endpoint-override path. Most providers take their credential from
# GADFLY_API_KEY here with a client-specific fallback, and those rules are
# not worth restating — this stays silent for them.
#
# The built-ins are the exception, and only since they gained an own-key
# fallback: a keyless kimi/qwen endpoint reads QWEN_API_KEY / KIMI_API_KEY
# on THIS path too, so "own key or GADFLY_API_KEY" is a rule that can be
# stated exactly. Leaving them unchecked here would let a keyless override
# config sail past the pre-flight and fail as a 401 — the failure the
# pre-flight exists to replace.
case "$provider" in
qwen|kimi) ;;
*) echo ""; return 0 ;;
esac
local own_env="$(printf '%s' "$provider" | tr '[:lower:]-' '[:upper:]_')_API_KEY"
if [ -n "${!own_env:-}" ] || [ -n "${GADFLY_API_KEY:-}" ]; then
echo ""
else
echo "$own_env"
fi
return 0
fi
# A provider is absent from this table for one of TWO different reasons — do
# not assume the first one and add an arm:
# 1. It needs no key, or carries it in its endpoint/DSN: local ollama,
# llama-swap, foreman.
# 2. It needs a key but accepts more than one variable, so a single-name
# check would skip a correctly-configured run. **google** is this case:
# GOOGLE_API_KEY *or* GEMINI_API_KEY. Adding
# `google) key_env="GOOGLE_API_KEY"` would silently skip every reviewer
# configured with GEMINI_API_KEY. Pre-flighting google needs an
# either-variable check, not this table's one-name shape.
case "$provider" in
ollama-cloud) key_env="OLLAMA_API_KEY"; key_hint="OLLAMA_CLOUD_API_KEY" ;;
qwen) key_env="QWEN_API_KEY"; key_hint="QWEN_API_KEY" ;;
kimi) key_env="KIMI_API_KEY"; key_hint="KIMI_API_KEY" ;;
openai|openai-compatible) key_env="OPENAI_API_KEY"; key_hint="OPENAI_API_KEY" ;;
anthropic) key_env="ANTHROPIC_API_KEY"; key_hint="ANTHROPIC_API_KEY" ;;
esac
if [ -z "$key_env" ]; then
local row
row="$(_gadfly_preflight_table | awk -F: -v p="$provider" '$1 == p {print; exit}')"
if [ -z "$row" ]; then
echo "" # provider needs no pre-flight
return 0
fi
key_env="$(printf '%s' "$row" | cut -d: -f2)"
key_hint="$(printf '%s' "$row" | cut -d: -f3)"
[ -n "$key_hint" ] || key_hint="$key_env"
# Indirect expansion (bash). Each majordomo built-in reads ONLY its own
# variable — cross-provider fallback is refused by design — so the named hint
# is always the actual fix.
@@ -64,3 +91,45 @@ gadfly_preflight_key() {
fi
echo "$key_hint"
}
# _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
# is the exception: run.sh copies the consumer-facing OLLAMA_CLOUD_API_KEY onto
# the OLLAMA_API_KEY the provider reads BEFORE calling in here, so the check and
# the hint name different variables on purpose. If that copy ever moves after
# the call, this arm reports a missing key for a configured run.
#
# A provider absent from this table is absent for one of TWO reasons — do not
# assume the first and add a row:
# 1. It needs no key, or carries one in its endpoint/DSN: local ollama,
# llama-swap, foreman.
# 2. It needs a key but accepts more than one variable, so a single-name check
# would skip a correctly-configured run. **google** is this case
# (GOOGLE_API_KEY *or* GEMINI_API_KEY); pre-flighting it needs an
# either-variable check, not this table's one-name shape.
_gadfly_preflight_table() {
printf '%s\n' \
'ollama-cloud:OLLAMA_API_KEY:OLLAMA_CLOUD_API_KEY' \
'opencode:OLLAMA_API_KEY:OLLAMA_CLOUD_API_KEY' \
'open-code:OLLAMA_API_KEY:OLLAMA_CLOUD_API_KEY' \
'qwen:QWEN_API_KEY:' \
'kimi:KIMI_API_KEY:' \
'openai:OPENAI_API_KEY:' \
'openai-compatible:OPENAI_API_KEY:' \
'anthropic:ANTHROPIC_API_KEY:'
}
# gadfly_preflight_providers echoes every provider covered above, one per line.
# Callers ASK rather than parse: a Go test cross-checks this against the
# openai-compat provider table in cmd/gadfly/model.go, and regexing this file
# would make its formatting a contract no linter enforces.
#
# The cross-check runs ONE direction — every openai-compat provider in Go must
# appear here. The reverse is not required and must not be asserted:
# ollama-cloud and anthropic belong in this table and are deliberately not in
# that Go list.
gadfly_preflight_providers() {
_gadfly_preflight_table | cut -d: -f1
}
Regular → Executable
+37 -10
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env bash
# Table test for the credential pre-flight in preflight.sh.
#
# It SOURCES the real implementation rather than copying it. An earlier version
# duplicated the logic and reconciled the copies with a regex diff — which only
# covered the provider table and not the decision logic, i.e. exactly the half
# that had the bug. Sourcing removes the second copy entirely.
# It SOURCES the real implementation rather than copying it, so there is no
# second definition that can pass while production fails.
#
# Run: scripts/preflight_test.sh (exit 0 = all cases pass)
set -u
@@ -27,11 +25,11 @@ check() { # description, want, got
# under the same shell options production uses (set -u), so an unset-variable
# bug surfaces here instead of in a live review.
probe() {
local provider="$1"; shift
local provider="$1" model="${GADFLY_TEST_MODEL:-}"; shift
env -i PATH="$PATH" HOME="$HOME" "$@" bash -c "
set -u
. '$SCRIPT_DIR/preflight.sh'
gadfly_preflight_key '$provider'
gadfly_preflight_key '$provider' '$model'
"
}
@@ -62,15 +60,22 @@ echo "== GADFLY_API_KEY does NOT substitute on the registry path =="
# GADFLY_API_KEY changes nothing. Treating it as sufficient was a false pass.
check "qwen w/ GADFLY_API_KEY only" "QWEN_API_KEY" "$(probe qwen GADFLY_API_KEY=k)"
echo "== override path (GADFLY_BASE_URL set) is deliberately not pre-flighted =="
echo "== override path: built-ins ARE checked; others are not =="
# The credential there is GADFLY_API_KEY with a client-specific fallback, and
# the built-ins' own variables are never read. Checking one path's rules
# against the other produced a false pass in BOTH directions, so this path is
# left alone rather than guessed at.
check "qwen + BASE_URL, no keys" "" "$(probe qwen GADFLY_BASE_URL=https://x)"
check "qwen + BASE_URL + own key" "" "$(probe qwen GADFLY_BASE_URL=https://x QWEN_API_KEY=k)"
# A built-in reads its own key on the override path too (openAICompatOptions
# falls back to QWEN_API_KEY/KIMI_API_KEY there), so "own key or GADFLY_API_KEY"
# is statable and worth checking — leaving it unchecked let a keyless config
# sail past and fail as a 401.
check "qwen + BASE_URL, no keys" "QWEN_API_KEY" "$(probe qwen GADFLY_BASE_URL=https://x)"
check "qwen + BASE_URL + own key" "" "$(probe qwen GADFLY_BASE_URL=https://x QWEN_API_KEY=k)"
check "qwen + BASE_URL + GADFLY key" "" "$(probe qwen GADFLY_BASE_URL=https://x GADFLY_API_KEY=k)"
check "openai + BASE_URL, no keys" "" "$(probe openai GADFLY_BASE_URL=https://x)"
check "kimi + BASE_URL, no keys" "KIMI_API_KEY" "$(probe kimi GADFLY_BASE_URL=https://x)"
# Other providers' override-path rules are not statable, so this stays quiet.
check "openai + BASE_URL, no keys" "" "$(probe openai GADFLY_BASE_URL=https://x)"
check "anthropic + BASE_URL, none" "" "$(probe anthropic GADFLY_BASE_URL=https://x)"
echo "== providers needing no key are never blocked, with nothing set =="
for p in ollama llama-swap llama-swaps llamaswap llamaswaps foreman google gemini some-dsn-name; do
@@ -81,6 +86,28 @@ done
# GEMINI_API_KEY, so a one-name arm would skip a correctly-configured run.
check "google w/ only GEMINI_API_KEY" "" "$(probe google GEMINI_API_KEY=k)"
echo "== a whitespace-only GADFLY_BASE_URL counts as unset, as it does in Go =="
# resolveModel TrimSpaces it and takes the registry path; if this check
# disagreed, the missing key would arrive as a bare 401 with no skip notice.
check "qwen + blank BASE_URL" "QWEN_API_KEY" "$(probe qwen GADFLY_BASE_URL=" ")"
echo "== engine specs carry their own auth and are never pre-flighted =="
# A bare "claude-code" has no "/", so the caller's provider falls back to
# ollama-cloud; judging it by that would skip a reviewer using
# CLAUDE_CODE_OAUTH_TOKEN, which needs no Ollama key.
check "bare claude-code, no ollama key" "" "$(GADFLY_TEST_MODEL=claude-code probe ollama-cloud)"
# Go trims GADFLY_MODEL, so padding must not bypass the exemption.
check "claude-code w/ whitespace" "" "$(GADFLY_TEST_MODEL=" claude-code " probe ollama-cloud)"
check "claude-code/opus, no ollama key" "" "$(GADFLY_TEST_MODEL=claude-code/opus probe ollama-cloud)"
# opencode is NOT exempt: it drives an ollama-cloud model and needs that key,
# so skipping it would disable the pre-flight for the one engine it can help.
check "opencode/x, no ollama key" "OLLAMA_CLOUD_API_KEY" "$(GADFLY_TEST_MODEL=opencode/x probe opencode)"
check "bare opencode, no ollama key" "OLLAMA_CLOUD_API_KEY" "$(GADFLY_TEST_MODEL=opencode probe ollama-cloud)"
check "open-code/x, no ollama key" "OLLAMA_CLOUD_API_KEY" "$(GADFLY_TEST_MODEL=open-code/x probe open-code)"
check "opencode/x, keyed" "" "$(GADFLY_TEST_MODEL=opencode/x probe opencode OLLAMA_API_KEY=k)"
# ...but a genuine ollama-cloud model still is.
check "ollama-cloud model, no key" "OLLAMA_CLOUD_API_KEY" "$(GADFLY_TEST_MODEL=glm-5.2:cloud probe ollama-cloud)"
if [ "$fail" -ne 0 ]; then
echo "RESULT: preflight table FAILED"
exit 1
+3 -1
View File
@@ -168,7 +168,9 @@ case "$PROVIDER" in
GADFLY_PROVIDER_EFF="$MODEL_PROVIDER"
# Credential pre-flight — one definition, shared with preflight_test.sh.
MISSING_KEY="$(gadfly_preflight_key "$GADFLY_PROVIDER_EFF")"
# Pass the raw spec too: engine specs (claude-code/opencode) carry their
# own auth and must not be judged by the provider fallback.
MISSING_KEY="$(gadfly_preflight_key "$GADFLY_PROVIDER_EFF" "$MODEL")"
if [ -n "$MISSING_KEY" ]; then
REVIEW="⚠️ No API key configured for provider \`${GADFLY_PROVIDER_EFF}\` (set \`${MISSING_KEY}\`); this reviewer was skipped."
else