diff --git a/AGENT_TODO.md b/AGENT_TODO.md index 854323d..2a6cffe 100644 --- a/AGENT_TODO.md +++ b/AGENT_TODO.md @@ -42,6 +42,8 @@ summary (newest last). ## Done +- **2026-09-07** — `pos ai` API-key contract mismatch fixed (Architect→Builder→Reviewer; docs/history evidence): docs claimed `AI_API_KEY` was the required primary key, but `resolve_key()` read only provider-specific keys (`7ae2e77` had removed shared-key priority to fix cross-provider leakage; docs never updated). Decision C: provider key stays primary (leakage guard intact), legacy `AI_API_KEY` honored as backward-compat fallback when the provider's own key is empty; `cmd_providers()` "configured" mirrors it; `require_key()` messages byte-stable; `AI_API_KEY` NOT re-added to `# POS_CONFIG:`/`# PROVIDER_CONFIG:` registry. Docs reworded (POS.md rows 91/96/98 + precedence, howto/ai.md first-run hints, HOWTO.md, AGENT_Context 2 prose spots, config/ai.env comment). New regression `tests/t-ai-key-resolution.sh` (24 checks / 10 cases: gemini+openrouter via provider key only, via 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; Reviewer APPROVE_WITH_NOTES with mutation-based disproof (inverted precedence → C3/C6 fail). + - **2026-09-07** — opencode project skill: created `.opencode/skills/linux-post-install/SKILL.md` (repo had no `.opencode/`). Skill encodes the repo's operational playbook for agents: repo shape, `pos` tool model (`# POS:` header system, exec-bit, deps-guard-before-help, INTERACTIVE_CMDS, determinism), doc authority order (MAINTENANCE Phase 0: templates → DEV.md → AGENTS.md → code), Definition of Done gates (`make gen` ×2 idempotent → `make check` → `make lint` 0/0 → `make test` 18 files/416 checks), test conventions (hard-skip contract, negative controls), and repo commands (ci-status, gitea API, pos tree/config). Frontmatter validated (name matches folder, description with trigger keywords); auto-discovered at `.opencode/skills/` — no opencode.json change needed; restart opencode to load. - **2026-09-06** — Repo cleanup: removed 114 temp/process files (AgentsReport/ 86 + reportAgents/ 28 agent reports), stale task/plan/audit docs (tmp_request.md, FINAL_SUMMARY.md, IMPLEMENTATION_PLAN.md, AUDIT.md, AUDIT_TABLE.md, registry-design doc), stray `To`/`.n`/`reports/` artifacts; gitignored AgentsReport/ so agent process reports stay local-only. DOC/, tools-docs/ytsync.md, AGENT_TODO.md, bin/lib/apps/tests/config/scripts etc. kept untouched. diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index bcbf1f1..81b99c3 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -488,7 +488,7 @@ All `.service` files in `systemd/` are automatically copied to `/etc/systemd/sys - `~/.config/linux_post_install/entertainment.env` — entertainment plugin defaults: weather location + `ENABLED` auto-trigger list (`plugin, interval` pairs scheduled via `pos entertainment enable/disable`, systemd user timers); auto-installed from `config/entertainment.env` by `postinstall.sh` (no clobber, template printed) - `~/.config/linux_post_install/system.env` — shared "system" tool settings (loaded by `pos system health` / `pos system backup` via `load_system_env()` in `lib/common.sh`; env already exported wins over the file); template `config/system.env` - `~/.config/linux_post_install/notify.env` — alerting platform selection (`NOTIFY_PLATFORM=telegram,matrix`, comma-separated = fan out); read by `lib/notify.sh`; template `config/notify.env` -- `~/.config/linux_post_install/ai.env` — AI provider config (`AI_PROVIDER`, `AI_API_KEY` secret, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_MAX_TOKENS`, `AI_SESSION_TURNS`, plus legacy fallbacks `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`); read by `pos ai`; template `config/ai.env`, auto-installed by postinstall, edit with `pos config ai` +- `~/.config/linux_post_install/ai.env` — AI provider config (`AI_PROVIDER`, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_MAX_TOKENS`, `AI_SESSION_TURNS`, provider keys `AI_GEMINI_API_KEY`/`OPENROUTER_API_KEY` (secrets), legacy shared fallback `AI_API_KEY` accepted when the provider's key is unset, plus model fallbacks `AI_GEMINI_MODEL`, `OPENROUTER_MODEL`); read by `pos ai`; template `config/ai.env`, auto-installed by postinstall, edit with `pos config ai` - `~/.bashrc` — Modified by postinstall (PATH, bash completion) ### Feature Flags @@ -655,7 +655,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-system-health` | 209 | Host health dashboard (disk, RAM, services, backup age, fail2ban, docker); exit 1 if any FAIL | | `bin/pos-system-schedule` | 151 | Scheduled jobs: run a command on a timer; notify on threshold/change/error/always or silently | | `bin/pos-system-uninstall` | 517 | Remove pos toolkit binaries, services, shell integration, config, and data | -| `bin/pos-ai` | 709 | AI assistant: ask, chat, sessions, capture, models, providers | +| `bin/pos-ai` | 714 | AI assistant: ask, chat, sessions, capture, models, providers | | `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | | `bin/pos-tree` | 118 | Show the pos CLI command tree: categories, commands, and subcommands | | `completions/pos.bash` | 314 | Dynamic bash completion | @@ -692,7 +692,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | Modify Music→USB sync logic | Edit `bin/pos-media-sync` / shared USB layer `lib/usb-lib.sh` (seams `MEDIA_SYNC_SOURCE`/`MEDIA_SYNC_DEST`/`USB_MOUNT_BASE`/`USB_BYID` in `~/.config/linux_post_install/system.env`) | | Modify YouTube channel sync logic | Edit `bin/pos-media-ytsync` (state in `~/.local/share/linux_post_install/ytsync`; config scope `ytsync` via `pos config ytsync`; research notes `tools-docs/ytsync.md`) | | Modify the scheduler / scheduled jobs | Edit `bin/pos-system-schedule` / `lib/scheduler-lib.sh` (jobs in `~/.config/linux_post_install/schedule.d/`) | -| Modify AI logic | Edit `bin/pos-ai` (main tool) + `lib/ai-providers/*.sh` (provider adapters); config scope `ai` via `pos config ai`; `AI_API_KEY`/`AI_MODEL`/`AI_PROVIDER` in `~/.config/linux_post_install/ai.env` | +| Modify AI logic | Edit `bin/pos-ai` (main tool) + `lib/ai-providers/*.sh` (provider adapters); config scope `ai` via `pos config ai`; `AI_PROVIDER`, `AI_MODEL`, per-provider `AI_GEMINI_API_KEY`/`OPENROUTER_API_KEY` (legacy shared `AI_API_KEY` accepted as fallback) in `~/.config/linux_post_install/ai.env` | | Modify UFW/firewall logic | Edit `bin/pos-system-firewall` | | Modify pos logging | Edit log setup in `bin/pos` | | Modify install phases/flags | Edit arg parsing in `install.sh` | diff --git a/DOC/HOWTO.md b/DOC/HOWTO.md index e698db1..acff18f 100644 --- a/DOC/HOWTO.md +++ b/DOC/HOWTO.md @@ -42,7 +42,7 @@ templates (without overwriting an existing file): | `system.env` | `pos system health`, `pos system backup` | `BACKUP_SERVICE_ROOTS`, `HEALTH_BACKUP_MAX_AGE_DAYS` | | `compose.env` | `pos docker compose` | `TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE` | | `entertainment.env` | `pos entertainment *` | plugin keys (`WEATHER_LAT`…), `ENABLED` | -| `ai.env` | `pos ai` | `AI_PROVIDER`, `AI_API_KEY`, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_MAX_TOKENS`, `AI_SESSION_TURNS`, `AI_GEMINI_API_KEY`, `AI_GEMINI_MODEL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL` | +| `ai.env` | `pos ai` | `AI_PROVIDER`, `AI_MODEL`, `AI_SYSTEM_PROMPT`, `AI_MAX_TOKENS`, `AI_SESSION_TURNS`, provider keys `AI_GEMINI_API_KEY` / `OPENROUTER_API_KEY` (+ legacy `AI_API_KEY` fallback) | | `schedule.d/` | `pos system schedule` | one `.env` per job: `INTERVAL`, `NOTIFY`, `MSG`, `RULE`, `COMMAND` | ```bash diff --git a/DOC/POS.md b/DOC/POS.md index fbe5765..98b3ec0 100644 --- a/DOC/POS.md +++ b/DOC/POS.md @@ -88,17 +88,17 @@ Backward compatibility: `pos ai gemini`, `pos ai openrouter`, and `pos ai llamac | Key | Required | Default | Purpose | |-----|----------|---------|---------| | `AI_PROVIDER` | no | `gemini` | Active provider (gemini\|openrouter\|llamacpp) | -| `AI_API_KEY` | yes | — | API key for the active provider (secret — masked in `pos config ai`) | +| `AI_API_KEY` | no (legacy fallback) | — | Legacy shared API key, used when the active provider's key is empty; not part of the `pos config ai` prompt (set via env or hand-edit); secret | | `AI_MODEL` | no | per provider | Model id used by `ask`/`chat`/`models` | | `AI_SYSTEM_PROMPT` | no | built-in terse prompt | Custom system prompt (overrides built-in; empty to reset) | | `AI_MAX_TOKENS` | no | `2048` | Max output tokens per request (OpenRouter/Gemini cost cap) | | `AI_SESSION_TURNS` | no | `40` | Session message cap — 2 per exchange; 10 = last 5 exchanges | -| `AI_GEMINI_API_KEY` | fallback | — | Legacy: Gemini API key (used when `AI_API_KEY` is empty) | +| `AI_GEMINI_API_KEY` | yes (gemini) | — | Gemini API key (the active key when provider is gemini; secret — masked in `pos config ai`) | | `AI_GEMINI_MODEL` | fallback | `gemini-2.5-flash` | Legacy: Gemini model id (used when `AI_MODEL` is empty) | -| `OPENROUTER_API_KEY` | fallback | — | Legacy: OpenRouter API key (used when `AI_API_KEY` is empty) | +| `OPENROUTER_API_KEY` | yes (openrouter) | — | OpenRouter API key (the active key when provider is openrouter; secret — masked in `pos config ai`) | | `OPENROUTER_MODEL` | fallback | `openrouter/auto` | Legacy: OpenRouter model id (used when `AI_MODEL` is empty) | -Model precedence: `--model` flag > `AI_MODEL` env > provider-specific fallback (`AI_GEMINI_MODEL`/`OPENROUTER_MODEL`) > provider default. API key precedence: `AI_API_KEY` env > provider-specific fallback (`AI_GEMINI_API_KEY`/`OPENROUTER_API_KEY`) > error. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES). Sessions are stored in OpenAI `messages` format universally; old Gemini-format sessions (`contents[]`) are auto-migrated on load. +Model precedence: `--model` flag > `AI_MODEL` env > provider-specific fallback (`AI_GEMINI_MODEL`/`OPENROUTER_MODEL`) > provider default. API key precedence: `_API_KEY` (`AI_GEMINI_API_KEY` for gemini / `OPENROUTER_API_KEY` for openrouter) > legacy `AI_API_KEY` fallback > error. `AI_API_KEY` is an internal adapter shim and a backward-compat input — it is not offered by `pos config ai`. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES). Sessions are stored in OpenAI `messages` format universally; old Gemini-format sessions (`contents[]`) are auto-migrated on load. **Messaging bridges:** the Telegram and Matrix listeners forward non-command messages starting with `ai ` (case-insensitive) to `pos ai ask` and reply with the model's answer — see [communication → listener](#communication). The Telegram bridge uses one session per chat (`telegram-`), the Matrix bridge one per room (`matrix-`). diff --git a/DOC/howto/ai.md b/DOC/howto/ai.md index 10d0743..8457da3 100644 --- a/DOC/howto/ai.md +++ b/DOC/howto/ai.md @@ -69,7 +69,7 @@ exactly as before (no added blank lines), so scripting stays byte-stable. 2. Configure it (masked input): ```bash - pos config ai # enter AI_API_KEY (or AI_GEMINI_API_KEY) + pos config ai # enter AI_GEMINI_API_KEY ``` 3. Test: @@ -81,7 +81,10 @@ exactly as before (no added blank lines), so scripting stays byte-stable. ``` `ai.env` lives at `~/.config/linux_post_install/ai.env` (chmod 600); `pos config ai` -is the only place the key is written. The key is never printed by `pos`. +is where the provider keys are written, and keys are never printed by `pos`. The +legacy shared `AI_API_KEY` is still honored as a fallback when the provider's own +key is unset — set it via the shell environment or by hand-editing `ai.env` (it is +not part of the `pos config ai` prompt). ## OpenRouter — many providers, one key @@ -98,7 +101,7 @@ pos ai openrouter ask "hi" Configure the API key: ```bash -pos config ai # enter AI_API_KEY (or OPENROUTER_API_KEY) +pos config ai # enter OPENROUTER_API_KEY ``` The default model is `openrouter/auto` (OpenRouter picks the best available diff --git a/bin/pos-ai b/bin/pos-ai index c101b1a..b1a2bd8 100755 --- a/bin/pos-ai +++ b/bin/pos-ai @@ -156,10 +156,14 @@ load_config() { resolve_key() { load_config local p="${PROVIDER:-gemini}" - # Each provider has its own API key — set AI_API_KEY internally for adapters + # Provider-specific key wins (leakage guard); legacy shared AI_API_KEY is a + # read-only fallback when the provider's own key is empty — load_config/env + # already exported it (env-wins), so never overwrite it here. case "$p" in - gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && export AI_API_KEY="$AI_GEMINI_API_KEY" && return 0 ;; - openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && export AI_API_KEY="$OPENROUTER_API_KEY" && return 0 ;; + gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && export AI_API_KEY="$AI_GEMINI_API_KEY" && return 0 + [ -n "${AI_API_KEY:-}" ] && return 0 ;; + openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && export AI_API_KEY="$OPENROUTER_API_KEY" && return 0 + [ -n "${AI_API_KEY:-}" ] && return 0 ;; llamacpp) return 0 ;; # No API key needed for local server esac return 1 @@ -626,9 +630,10 @@ cmd_providers() { [ -n "$pmodel" ] || pmodel="unknown" # Check if API key exists for this provider configured="not configured" + # Same key set as resolve_key(): provider key, or legacy AI_API_KEY fallback case "$name" in - gemini) [ -n "${AI_GEMINI_API_KEY:-}" ] && configured="configured" ;; - openrouter) [ -n "${OPENROUTER_API_KEY:-}" ] && configured="configured" ;; + gemini) { [ -n "${AI_GEMINI_API_KEY:-}" ] || [ -n "${AI_API_KEY:-}" ]; } && configured="configured" ;; + openrouter) { [ -n "${OPENROUTER_API_KEY:-}" ] || [ -n "${AI_API_KEY:-}" ]; } && configured="configured" ;; llamacpp) configured="configured" ;; # Local server — always configured esac current="" diff --git a/config/ai.env b/config/ai.env index fb4ffe0..085aba5 100644 --- a/config/ai.env +++ b/config/ai.env @@ -8,6 +8,7 @@ # API keys (each provider needs its own): # AI_GEMINI_API_KEY= # Gemini API key from aistudio.google.com # OPENROUTER_API_KEY= # OpenRouter API key from openrouter.ai +# Legacy fallback: AI_API_KEY= is honored when the provider's own key is unset # # Model overrides (optional, defaults per provider): # AI_GEMINI_MODEL= # Gemini model id (default: gemini-2.5-flash) diff --git a/tests/README.md b/tests/README.md index 63a6e7f..dd0c08d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -42,6 +42,7 @@ silently. | --- | --- | | `t-ai-server-flags.sh` | `pos-ai-server` ExecStart flag set: defaults, CLI, config, dedupe, one-token-per-flag | | `t-ai-hf-download.sh` | `pos ai hf download` success + failure honesty (no `.hf-meta` on partial failure, rc != 0) | +| `t-ai-key-resolution.sh` | `pos ai` API-key contract: provider key > legacy `AI_API_KEY` fallback > error; leakage guard, env-wins, providers status sync | | `t-ai-llama-detect.sh` | `pos ai-server status` version detection, "unknown", missing-binary failure | | `t-unsupported-flags.sh` | unsupported-option handling: CLI/config/env hard errors, dropped defaults, word-boundary match | | `t-systemd-unit.sh` | generated unit: one ExecStart, quoted paths, `systemd-analyze verify` | diff --git a/tests/t-ai-key-resolution.sh b/tests/t-ai-key-resolution.sh new file mode 100644 index 0000000..8baa42e --- /dev/null +++ b/tests/t-ai-key-resolution.sh @@ -0,0 +1,128 @@ +#!/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
pair; reply with provider-shaped JSON + + # the trailing \n%{http_code} line the adapters' --write-out expects. + cat > "$stubs/curl" <> "$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" +} \ No newline at end of file