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]>
This commit is contained in:
2026-08-12 18:09:18 -04:00
co-authored by Claude Opus 5
parent 274451e89c
commit 3af0f09387
6 changed files with 205 additions and 56 deletions
+15 -6
View File
@@ -108,12 +108,21 @@ jobs:
# write ~/.netrc or ~/.config/go/env. Guarded on a non-empty secret: # write ~/.netrc or ~/.config/go/env. Guarded on a non-empty secret:
# `grep -F ""` matches every file, so a secretless run (fork PR) would # `grep -F ""` matches every file, so a secretless run (fork PR) would
# fail here with a message accusing it of leaking nothing. # fail here with a message accusing it of leaking nothing.
# -e, so a password beginning with "-" is a pattern and not options: # -e, so a password beginning with "-" is a pattern and not options.
# without it the check errors out and, under `set -e`, fails the step # And distinguish grep's three exits: 0 found, 1 clean, >=2 ERROR. As
# with a message about grep usage rather than about credentials. # a bare condition an error reads as "not found" and the guard is
if [ -n "${REGISTRY_PASSWORD:-}" ] && grep -rqF -e "$REGISTRY_PASSWORD" "$HOME" 2>/dev/null; then # skipped — a check that fails OPEN in exactly the case where it can no
echo "::error::registry credential still present under \$HOME after scrub" # longer see the filesystem it is supposed to be searching.
exit 1 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 fi
# GOPROXY=off from here on: the module cache is already warm, so any # GOPROXY=off from here on: the module cache is already warm, so any
+33 -23
View File
@@ -33,10 +33,38 @@ const defaultProvider = "ollama-cloud"
// guess. // guess.
var openAICompatProviders = []string{"openai", "openai-compatible", "kimi", "qwen"} 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 { func isOpenAICompatProvider(name string) bool {
return slices.Contains(openAICompatProviders, name) return slices.Contains(openAICompatProviders, name)
} }
// 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 slices.Contains(builtinCompatProviders, provider):
opts = append(opts, openai.WithAPIKey(key), openai.WithAPIKeyName(keyHint))
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 // endpointProviderNames is the operator-facing list of providers that accept an
// explicit endpoint. resolveModel and endpointProvider accept the SAME set, so // explicit endpoint. resolveModel and endpointProvider accept the SAME set, so
// one message serves both rather than each carrying a copy that drifts in // one message serves both rather than each carrying a copy that drifts in
@@ -106,25 +134,7 @@ func resolveModel() (llm.Model, error) {
// credential rule — assuming they do produces a config that passes every // credential rule — assuming they do produces a config that passes every
// check and then 401s. // check and then 401s.
if isOpenAICompatProvider(provider) { if isOpenAICompatProvider(provider) {
opts := []openai.Option{openai.WithBaseURL(baseURL)} return openai.New(openAICompatOptions(provider, baseURL, apiKey, "GADFLY_API_KEY")...).Model(model)
switch {
case provider == "kimi" || provider == "qwen":
// Pass the key UNCONDITIONALLY, even when empty. openai.New defaults
// its credential to OPENAI_API_KEY, so omitting the option sends an
// OpenAI key to Moonshot or Alibaba — a credential handed to the
// wrong vendor, which is exactly what majordomo's built-ins go out
// of their way to prevent. An empty key instead yields a synthetic
// 401 naming the knob that fixes it.
opts = append(opts,
openai.WithAPIKey(apiKey),
openai.WithAPIKeyName("GADFLY_API_KEY"),
)
case apiKey != "":
opts = append(opts, openai.WithAPIKey(apiKey))
// openai/openai-compatible with no explicit key keep the
// OPENAI_API_KEY default: for those names it IS the right key.
}
return openai.New(opts...).Model(model)
} }
switch provider { switch provider {
@@ -299,10 +309,10 @@ func endpointProvider(name, raw string) (llm.Provider, error) {
// Same shared predicate as resolveModel: the two must accept an identical // Same shared predicate as resolveModel: the two must accept an identical
// set, and a hand-copied case list cannot guarantee that. // set, and a hand-copied case list cannot guarantee that.
if isOpenAICompatProvider(provider) { if isOpenAICompatProvider(provider) {
opts := []openai.Option{openai.WithName(name), openai.WithBaseURL(baseURL)} // The key for a named endpoint comes from the third DSN field, so that
if key != "" { // is what an absent one points at.
opts = append(opts, openai.WithAPIKey(key)) opts := append([]openai.Option{openai.WithName(name)},
} openAICompatOptions(provider, baseURL, key, "GADFLY_ENDPOINT_"+strings.ToUpper(name))...)
return openai.New(opts...), nil return openai.New(opts...), nil
} }
+91
View File
@@ -1,10 +1,15 @@
package main package main
import ( import (
"context"
"net/http"
"net/http/httptest"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
llm "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
) )
func TestEndpointProvider(t *testing.T) { func TestEndpointProvider(t *testing.T) {
@@ -226,3 +231,89 @@ func TestBuiltinCompatProvidersResolveViaRegistry(t *testing.T) {
}) })
} }
} }
// 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, "GADFLY_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, "GADFLY_ENDPOINT_EP")
})
}
}
// 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, wantHint 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 %s provider reached the network (%d request(s)) instead of failing closed", wantHint, 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")
}
if !strings.Contains(err.Error(), wantHint) {
t.Errorf("error = %v, want it to name %s so the operator knows what to set", err, wantHint)
}
}
+51 -24
View File
@@ -24,7 +24,15 @@
# override-path config is hand-written, while the registry path is what somebody # 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. # hits by adding a model id to a var and forgetting the secret.
gadfly_preflight_key() { gadfly_preflight_key() {
local provider="$1" key_env="" key_hint="" local provider="$1" model="${2:-}" key_env="" key_hint=""
# Engine specs are not majordomo providers and carry their own auth. A bare
# "claude-code" has no "/" so the caller's provider falls back to
# ollama-cloud, which would skip a reviewer that authenticates with
# CLAUDE_CODE_OAUTH_TOKEN and needs no Ollama key at all.
case "$model" in
claude-code|claude-code/*|opencode/*) echo ""; return 0 ;;
esac
# Only the registry path has knowable credential rules — see above. # Only the registry path has knowable credential rules — see above.
# Trim before testing: resolveModel does strings.TrimSpace on GADFLY_BASE_URL, # Trim before testing: resolveModel does strings.TrimSpace on GADFLY_BASE_URL,
@@ -54,22 +62,16 @@ gadfly_preflight_key() {
# variable the operator actually sets; the check reads the one the code uses. # variable the operator actually sets; the check reads the one the code uses.
# If that copy ever moves after this call, this arm reports a missing key for # If that copy ever moves after this call, this arm reports a missing key for
# a configured run. # a configured run.
case "$provider" in local row
ollama-cloud) key_env="OLLAMA_API_KEY" ;; row="$(_gadfly_preflight_table | awk -F: -v p="$provider" '$1 == p {print; exit}')"
qwen) key_env="QWEN_API_KEY" ;; if [ -z "$row" ]; then
kimi) key_env="KIMI_API_KEY" ;;
openai|openai-compatible) key_env="OPENAI_API_KEY" ;;
anthropic) key_env="ANTHROPIC_API_KEY" ;;
esac
# The hint is the variable the operator sets, which equals the one the code
# reads everywhere except ollama-cloud (see the note above).
key_hint="$key_env"
[ "$provider" = "ollama-cloud" ] && key_hint="OLLAMA_CLOUD_API_KEY"
if [ -z "$key_env" ]; then
echo "" # provider needs no pre-flight echo "" # provider needs no pre-flight
return 0 return 0
fi 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 # Indirect expansion (bash). Each majordomo built-in reads ONLY its own
# variable — cross-provider fallback is refused by design — so the named hint # variable — cross-provider fallback is refused by design — so the named hint
# is always the actual fix. # is always the actual fix.
@@ -80,17 +82,42 @@ gadfly_preflight_key() {
echo "$key_hint" echo "$key_hint"
} }
# gadfly_preflight_providers echoes every provider this file has a credential # _gadfly_preflight_table is the single source for both the credential lookup
# arm for, one per line. # and the provider list: "<provider>:<env-var-read>:<env-var-to-suggest>".
# #
# It exists so callers can ASK which providers are covered instead of parsing # The third field is normally empty, meaning "same as the second". ollama-cloud
# the case statement. A Go test cross-checks this list against the provider # is the exception: run.sh copies the consumer-facing OLLAMA_CLOUD_API_KEY onto
# table in cmd/gadfly/model.go; having it regex this file would make the shell # the OLLAMA_API_KEY the provider reads BEFORE calling in here, so the check and
# formatting a contract no linter enforces, where a reformat breaks a test in # the hint name different variables on purpose. If that copy ever moves after
# another language for no visible reason. # the call, this arm reports a missing key for a configured run.
# #
# Keep in step with the case arms above — the Go test fails if a provider in # A provider absent from this table is absent for one of TWO reasons — do not
# either list is missing from the other. # 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' \
'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_providers() {
printf '%s\n' ollama-cloud qwen kimi openai openai-compatible anthropic _gadfly_preflight_table | cut -d: -f1
} }
+12 -2
View File
@@ -25,11 +25,11 @@ check() { # description, want, got
# under the same shell options production uses (set -u), so an unset-variable # under the same shell options production uses (set -u), so an unset-variable
# bug surfaces here instead of in a live review. # bug surfaces here instead of in a live review.
probe() { probe() {
local provider="$1"; shift local provider="$1" model="${GADFLY_TEST_MODEL:-}"; shift
env -i PATH="$PATH" HOME="$HOME" "$@" bash -c " env -i PATH="$PATH" HOME="$HOME" "$@" bash -c "
set -u set -u
. '$SCRIPT_DIR/preflight.sh' . '$SCRIPT_DIR/preflight.sh'
gadfly_preflight_key '$provider' gadfly_preflight_key '$provider' '$model'
" "
} }
@@ -84,6 +84,16 @@ echo "== a whitespace-only GADFLY_BASE_URL counts as unset, as it does in Go =="
# disagreed, the missing key would arrive as a bare 401 with no skip notice. # 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=" ")" 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)"
check "claude-code/opus, no ollama key" "" "$(GADFLY_TEST_MODEL=claude-code/opus probe ollama-cloud)"
check "opencode/x, no ollama key" "" "$(GADFLY_TEST_MODEL=opencode/x probe ollama-cloud)"
# ...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 if [ "$fail" -ne 0 ]; then
echo "RESULT: preflight table FAILED" echo "RESULT: preflight table FAILED"
exit 1 exit 1
+3 -1
View File
@@ -168,7 +168,9 @@ case "$PROVIDER" in
GADFLY_PROVIDER_EFF="$MODEL_PROVIDER" GADFLY_PROVIDER_EFF="$MODEL_PROVIDER"
# Credential pre-flight — one definition, shared with preflight_test.sh. # 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 if [ -n "$MISSING_KEY" ]; then
REVIEW="⚠️ No API key configured for provider \`${GADFLY_PROVIDER_EFF}\` (set \`${MISSING_KEY}\`); this reviewer was skipped." REVIEW="⚠️ No API key configured for provider \`${GADFLY_PROVIDER_EFF}\` (set \`${MISSING_KEY}\`); this reviewer was skipped."
else else