Files
Linux_post_install/tests/t-ai-key-resolution.sh
T
Your Name 9ef42c5fd1
gates / consistency-and-conventions (push) Successful in 27s
fix: pos ai — honor legacy AI_API_KEY as fallback (provider key stays primary)
Architect decision C on the API-key contract mismatch: docs claimed
AI_API_KEY was the required primary key, but resolve_key() only read
provider-specific keys (7ae2e77 removed shared-key priority to fix
cross-provider leakage; docs never updated).

- bin/pos-ai resolve_key(): provider key wins, legacy AI_API_KEY honored
  read-only when the provider's own key is empty, llamacpp unchanged;
  cmd_providers() configured check mirrors the same set
- require_key() error messages byte-stable (test-locked)
- AI_API_KEY NOT re-added to the # POS_CONFIG:/# PROVIDER_CONFIG: registry
- Docs reworded: POS.md rows 91/96/98 + precedence sentence, howto/ai.md
  first-run hints, HOWTO.md row, AGENT_Context prose (2 spots), config/ai.env
  legacy comment
- New regression tests/t-ai-key-resolution.sh: 24 checks / 10 cases
  (provider-key-only, AI_API_KEY-only, both -> provider wins, env-wins,
  llamacpp no-key, missing-key message, providers configured status)

Verified: suite 19 files / 440 checks / 0 fail / 0 skip; make gen
byte-idempotent; make check OK; make lint 0 FAIL, 0 WARN; bash -n clean;
git diff --check clean; Reviewer APPROVE_WITH_NOTES (mutation disproof:
inverted precedence -> C3/C6 fail)
2026-09-07 13:32:42 -04:00

128 lines
6.1 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
# t-ai-key-resolution.sh — `pos ai` API-key resolution contract:
# provider-specific key (AI_GEMINI_API_KEY / OPENROUTER_API_KEY)
# > legacy shared AI_API_KEY fallback > error; llamacpp needs no key.
#
# Regression for the 7ae2e77 "per-provider keys" migration: AI_API_KEY must be
# honored ONLY when the active provider's own key is empty (backward-compat for
# howto-taught single-key configs) and must NEVER override a provider key
# (cross-provider leakage guard). Also pins env-wins (exported env beats
# ai.env) and `pos ai providers` agreeing with resolve_key().
#
# Seam: `pos ai models` with a curl stub on PATH that records -H headers to a
# log and returns deterministic per-provider JSON (no sessions are written by
# `models`, unlike ask/chat).
run_test() {
require_cmd jq "pos ai key resolution" || return 0
local sandbox cfg home stubs curl_log
sandbox="$(mksandbox ai-key-resolution)"
cfg="$sandbox/cfg"
home="$sandbox/home"
stubs="$sandbox/stubs"
mkdir -p "$cfg" "$home" "$stubs"
curl_log="$sandbox/curl.log"
: > "$curl_log"
: > "$cfg/ai.env"
# curl stub: log every -H <header> pair; reply with provider-shaped JSON +
# the trailing \n%{http_code} line the adapters' --write-out expects.
cat > "$stubs/curl" <<STUB
#!/usr/bin/env bash
all="\$*"
while [ \$# -gt 0 ]; do
case "\$1" in
-H) printf 'HDR %s\n' "\$2" >> "$curl_log"; shift 2 ;;
*) shift ;;
esac
done
case "\$all" in
*generativelanguage.googleapis.com*)
printf '%s\n200' '{"models":[{"name":"models/gemini-2.5-flash","supportedGenerationMethods":["generateContent"]}]}' ;;
*openrouter.ai*)
printf '%s\n200' '{"data":[{"id":"openrouter/auto"}]}' ;;
*)
printf '%s\n200' '{"data":[{"id":"loaded-model"}]}' ;;
esac
STUB
chmod +x "$stubs/curl"
local posai="$ROOT/bin/pos-ai"
# Hermetic env: `env -i` drops every inherited variable (incl. any ambient
# AI_*), so each case exercises exactly the values declared below.
local path_env=(-i PATH="$stubs:/usr/bin:/bin" CONFIG_FILE="$cfg/ai.env" HOME="$home")
# ── Case 1: gemini via provider key only ──
printf 'AI_GEMINI_API_KEY=GEMFILEKEY\n' > "$cfg/ai.env"
test_run_env "${path_env[@]}" -- "$posai" models
check_rc "C1 gemini via AI_GEMINI_API_KEY works" 0 "$TR_RC"
check_contains "C1 gemini header carries provider key" "x-goog-api-key: GEMFILEKEY" "$(cat "$curl_log")"
# ── Case 2: gemini via legacy AI_API_KEY only (core regression) ──
: > "$curl_log"
printf 'AI_API_KEY=AIKEYONLY\n' > "$cfg/ai.env"
test_run_env "${path_env[@]}" -- "$posai" models
check_rc "C2 gemini via legacy AI_API_KEY works" 0 "$TR_RC"
check_contains "C2 gemini header carries legacy key" "x-goog-api-key: AIKEYONLY" "$(cat "$curl_log")"
# ── Case 3: gemini with both → provider key wins (leakage guard) ──
: > "$curl_log"
printf 'AI_GEMINI_API_KEY=GEMWIN\nAI_API_KEY=AIKEYLOSE\n' > "$cfg/ai.env"
test_run_env "${path_env[@]}" -- "$posai" models
check_rc "C3 both set still works" 0 "$TR_RC"
check_contains "C3 provider key wins" "x-goog-api-key: GEMWIN" "$(cat "$curl_log")"
check_not_contains "C3 shared key not leaked" "AIKEYLOSE" "$(cat "$curl_log")"
# ── Case 4: openrouter via provider key only ──
: > "$curl_log"
printf 'OPENROUTER_API_KEY=ORFILEKEY\n' > "$cfg/ai.env"
test_run_env "${path_env[@]}" -- "$posai" --provider openrouter models
check_rc "C4 openrouter via OPENROUTER_API_KEY works" 0 "$TR_RC"
check_contains "C4 openrouter auth carries provider key" "Authorization: Bearer ORFILEKEY" "$(cat "$curl_log")"
# ── Case 5: openrouter via legacy AI_API_KEY only ──
: > "$curl_log"
printf 'AI_API_KEY=AIKEYONLY\n' > "$cfg/ai.env"
test_run_env "${path_env[@]}" -- "$posai" --provider openrouter models
check_rc "C5 openrouter via legacy AI_API_KEY works" 0 "$TR_RC"
check_contains "C5 openrouter auth carries legacy key" "Authorization: Bearer AIKEYONLY" "$(cat "$curl_log")"
# ── Case 6: openrouter with both → provider key wins ──
: > "$curl_log"
printf 'OPENROUTER_API_KEY=ORWIN\nAI_API_KEY=AIKEYLOSE\n' > "$cfg/ai.env"
test_run_env "${path_env[@]}" -- "$posai" --provider openrouter models
check_rc "C6 both set still works" 0 "$TR_RC"
check_contains "C6 openrouter provider key wins" "Authorization: Bearer ORWIN" "$(cat "$curl_log")"
check_not_contains "C6 shared key not leaked" "AIKEYLOSE" "$(cat "$curl_log")"
# ── Case 7: env-wins — exported provider key beats ai.env file value ──
: > "$curl_log"
printf 'AI_GEMINI_API_KEY=FILEVALUE\n' > "$cfg/ai.env"
test_run_env "${path_env[@]}" AI_GEMINI_API_KEY=ENVVALUE -- "$posai" models
check_rc "C7 env-wins run works" 0 "$TR_RC"
check_contains "C7 exported env key used" "x-goog-api-key: ENVVALUE" "$(cat "$curl_log")"
check_not_contains "C7 file value suppressed by env" "FILEVALUE" "$(cat "$curl_log")"
# ── Case 8: llamacpp needs no key ──
: > "$curl_log"
: > "$cfg/ai.env"
test_run_env "${path_env[@]}" -- "$posai" --provider llamacpp models
check_rc "C8 llamacpp works with no key" 0 "$TR_RC"
check_contains "C8 llamacpp lists models" "Local llama.cpp models:" "$TR_OUT"
# ── Case 9: missing key → rc 1 + byte-stable message ──
test_run_env "${path_env[@]}" -- "$posai" models
check_rc "C9 missing key exits nonzero" 1 "$TR_RC"
check_contains "C9 stable error message" "No Gemini API key — run 'pos config ai' and set AI_GEMINI_API_KEY" "$TR_OUT"
# ── Case 10: providers status agrees with resolve_key (AI_API_KEY only) ──
local prov
printf 'AI_API_KEY=SHAREDKEY\n' > "$cfg/ai.env"
test_run_env "${path_env[@]}" -- "$posai" providers
prov="$(printf '%s\n' "$TR_OUT" | grep configured || true)"
check_contains "C10 gemini row shows configured" "gemini" "$prov"
check_contains "C10 openrouter row shows configured" "openrouter" "$prov"
check_not_contains "C10 no 'not configured' row" "not configured" "$TR_OUT"
}