Merge pull request #407 from DamienStevens/feat/macos-keychain-source
feat(env): macOS Keychain credential source
This commit is contained in:
@@ -260,6 +260,24 @@ These platforms don't have relationships with each other. X doesn't know what Re
|
|||||||
| Perplexity Sonar | OpenRouter key | Pay as you go |
|
| Perplexity Sonar | OpenRouter key | Pay as you go |
|
||||||
| Web search | Brave Search key | 2,000 free queries/month |
|
| Web search | Brave Search key | 2,000 free queries/month |
|
||||||
|
|
||||||
|
### macOS Keychain (optional)
|
||||||
|
|
||||||
|
On macOS you can store keys in the system Keychain instead of a `.env` file. The skill picks them up automatically as the lowest-priority source — `.env` files and process environment still win on collision.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactive setup — prompts for each known key, skip with empty input
|
||||||
|
skills/last30days/scripts/setup-keychain.sh
|
||||||
|
|
||||||
|
# Or store a single key by hand
|
||||||
|
security add-generic-password -a "$USER" -s last30days-XAI_API_KEY -w "xai-..."
|
||||||
|
|
||||||
|
# Inspect / clean up
|
||||||
|
skills/last30days/scripts/setup-keychain.sh --list
|
||||||
|
skills/last30days/scripts/setup-keychain.sh --delete XAI_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Items are stored under service name `last30days-<KEY>` for the current user. On non-Darwin platforms the loader is a no-op, so there is no behaviour change for Linux/Windows users.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. **You type a topic.** Person, company, product, technology, "X vs Y." Anything.
|
1. **You type a topic.** Person, company, product, technology, "X vs Y." Anything.
|
||||||
|
|||||||
@@ -29,6 +29,23 @@ else:
|
|||||||
|
|
||||||
CODEX_AUTH_FILE = Path(os.environ.get("CODEX_AUTH_FILE", str(Path.home() / ".codex" / "auth.json")))
|
CODEX_AUTH_FILE = Path(os.environ.get("CODEX_AUTH_FILE", str(Path.home() / ".codex" / "auth.json")))
|
||||||
|
|
||||||
|
# macOS Keychain integration: items stored with this service prefix are picked
|
||||||
|
# up automatically on Darwin as the lowest-priority credential source.
|
||||||
|
# Example: `security add-generic-password -a "$USER" -s last30days-XAI_API_KEY -w "xai-..."`.
|
||||||
|
KEYCHAIN_SERVICE_PREFIX = "last30days-"
|
||||||
|
|
||||||
|
# Single source of truth for which credentials the Keychain loader looks up.
|
||||||
|
# The setup-keychain.sh helper mirrors this list and is held in sync via
|
||||||
|
# tests/test_env_keychain.py::test_keychain_keys_match_setup_script.
|
||||||
|
KEYCHAIN_KEYS = (
|
||||||
|
"OPENAI_API_KEY", "XAI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY",
|
||||||
|
"GOOGLE_GENAI_API_KEY", "SCRAPECREATORS_API_KEY", "APIFY_API_TOKEN",
|
||||||
|
"AUTH_TOKEN", "CT0", "BSKY_HANDLE", "BSKY_APP_PASSWORD",
|
||||||
|
"TRUTHSOCIAL_TOKEN", "BRAVE_API_KEY", "EXA_API_KEY", "SERPER_API_KEY",
|
||||||
|
"OPENROUTER_API_KEY", "PARALLEL_API_KEY", "XQUIK_API_KEY",
|
||||||
|
"XIAOHONGSHU_API_BASE",
|
||||||
|
)
|
||||||
|
|
||||||
AuthSource = Literal["api_key", "codex", "none"]
|
AuthSource = Literal["api_key", "codex", "none"]
|
||||||
AuthStatus = Literal["ok", "missing", "expired", "missing_account_id"]
|
AuthStatus = Literal["ok", "missing", "expired", "missing_account_id"]
|
||||||
|
|
||||||
@@ -91,6 +108,46 @@ def load_env_file(path: Path) -> dict[str, str]:
|
|||||||
return env
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _load_keychain(keys: list[str]) -> dict[str, str]:
|
||||||
|
"""Load credentials from macOS Keychain (no-op on other platforms).
|
||||||
|
|
||||||
|
Each key is looked up as a generic password with service name
|
||||||
|
``f"{KEYCHAIN_SERVICE_PREFIX}{key}"`` for the current user. Missing items
|
||||||
|
and lookup failures are silent — Keychain is the lowest-priority source
|
||||||
|
and is meant to be additive over `.env` files and process environment.
|
||||||
|
"""
|
||||||
|
import platform
|
||||||
|
if platform.system() != "Darwin":
|
||||||
|
return {}
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
security = shutil.which("security")
|
||||||
|
if not security:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import pwd
|
||||||
|
# USER can be unset under sudo, in Docker without --env USER, or in some CI
|
||||||
|
# runners; fall back to the OS user record so lookups still match items
|
||||||
|
# stored by setup-keychain.sh (which uses $USER).
|
||||||
|
user = os.environ.get("USER") or pwd.getpwuid(os.getuid()).pw_name
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
for key in keys:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[security, "find-generic-password",
|
||||||
|
"-a", user,
|
||||||
|
"-s", f"{KEYCHAIN_SERVICE_PREFIX}{key}",
|
||||||
|
"-w"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
|
continue
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
env[key] = result.stdout.strip()
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
def _decode_jwt_payload(token: str) -> dict[str, Any] | None:
|
def _decode_jwt_payload(token: str) -> dict[str, Any] | None:
|
||||||
"""Decode JWT payload without verification."""
|
"""Decode JWT payload without verification."""
|
||||||
try:
|
try:
|
||||||
@@ -214,6 +271,7 @@ def get_config() -> dict[str, Any]:
|
|||||||
1. Environment variables (os.environ)
|
1. Environment variables (os.environ)
|
||||||
2. .claude/last30days.env (per-project config)
|
2. .claude/last30days.env (per-project config)
|
||||||
3. ~/.config/last30days/.env (global config)
|
3. ~/.config/last30days/.env (global config)
|
||||||
|
4. macOS Keychain items prefixed ``last30days-`` (Darwin only)
|
||||||
"""
|
"""
|
||||||
# Load from global config file
|
# Load from global config file
|
||||||
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
|
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
|
||||||
@@ -222,9 +280,14 @@ def get_config() -> dict[str, Any]:
|
|||||||
project_env_path = _find_project_env()
|
project_env_path = _find_project_env()
|
||||||
project_env = load_env_file(project_env_path) if project_env_path else {}
|
project_env = load_env_file(project_env_path) if project_env_path else {}
|
||||||
|
|
||||||
# Merge: project overrides global
|
# Merge file sources: project > global
|
||||||
merged_env = {**file_env, **project_env}
|
merged_env = {**file_env, **project_env}
|
||||||
|
|
||||||
|
# Keychain is the lowest-priority source (Darwin only; no-op elsewhere).
|
||||||
|
# Loaded before openai_auth so OPENAI_API_KEY can come from Keychain too.
|
||||||
|
keychain_env = _load_keychain(list(KEYCHAIN_KEYS))
|
||||||
|
merged_env = {**keychain_env, **merged_env}
|
||||||
|
|
||||||
openai_auth = get_openai_auth(merged_env)
|
openai_auth = get_openai_auth(merged_env)
|
||||||
|
|
||||||
# Build config: Codex/OpenAI auth + process.env > project .env > global .env
|
# Build config: Codex/OpenAI auth + process.env > project .env > global .env
|
||||||
@@ -270,11 +333,14 @@ def get_config() -> dict[str, Any]:
|
|||||||
for key, default in keys:
|
for key, default in keys:
|
||||||
config[key] = os.environ.get(key) or merged_env.get(key, default)
|
config[key] = os.environ.get(key) or merged_env.get(key, default)
|
||||||
|
|
||||||
# Track which config source was used
|
# Track which config source was used (highest-priority file source wins
|
||||||
|
# the label; keychain is only reported when nothing else is configured).
|
||||||
if project_env_path:
|
if project_env_path:
|
||||||
config['_CONFIG_SOURCE'] = f'project:{project_env_path}'
|
config['_CONFIG_SOURCE'] = f'project:{project_env_path}'
|
||||||
elif CONFIG_FILE and CONFIG_FILE.exists():
|
elif CONFIG_FILE and CONFIG_FILE.exists():
|
||||||
config['_CONFIG_SOURCE'] = f'global:{CONFIG_FILE}'
|
config['_CONFIG_SOURCE'] = f'global:{CONFIG_FILE}'
|
||||||
|
elif keychain_env:
|
||||||
|
config['_CONFIG_SOURCE'] = 'keychain'
|
||||||
else:
|
else:
|
||||||
config['_CONFIG_SOURCE'] = 'env_only'
|
config['_CONFIG_SOURCE'] = 'env_only'
|
||||||
|
|
||||||
|
|||||||
Executable
+122
@@ -0,0 +1,122 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Store last30days API keys in the macOS Keychain.
|
||||||
|
#
|
||||||
|
# Keys are stored as generic passwords with service name `last30days-<KEY>`
|
||||||
|
# for the current user. The lib/env.py loader picks them up automatically as
|
||||||
|
# the lowest-priority credential source on Darwin.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./setup-keychain.sh # interactive: prompts for each key
|
||||||
|
# ./setup-keychain.sh KEY [KEY..] # prompt only for the listed keys
|
||||||
|
# ./setup-keychain.sh --list # list which last30days-* items exist
|
||||||
|
# ./setup-keychain.sh --delete KEY # remove a stored key
|
||||||
|
#
|
||||||
|
# Existing values are shown as "(set)" and skipped unless --replace is passed.
|
||||||
|
# Skip any prompt with empty input.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PREFIX="last30days-"
|
||||||
|
# Mirrors lib/env.py::KEYCHAIN_KEYS — kept in sync via
|
||||||
|
# tests/test_env_keychain.py::test_keychain_keys_match_setup_script.
|
||||||
|
ALL_KEYS=(
|
||||||
|
OPENAI_API_KEY
|
||||||
|
XAI_API_KEY
|
||||||
|
GOOGLE_API_KEY
|
||||||
|
GEMINI_API_KEY
|
||||||
|
GOOGLE_GENAI_API_KEY
|
||||||
|
SCRAPECREATORS_API_KEY
|
||||||
|
APIFY_API_TOKEN
|
||||||
|
AUTH_TOKEN
|
||||||
|
CT0
|
||||||
|
BSKY_HANDLE
|
||||||
|
BSKY_APP_PASSWORD
|
||||||
|
TRUTHSOCIAL_TOKEN
|
||||||
|
BRAVE_API_KEY
|
||||||
|
EXA_API_KEY
|
||||||
|
SERPER_API_KEY
|
||||||
|
OPENROUTER_API_KEY
|
||||||
|
PARALLEL_API_KEY
|
||||||
|
XQUIK_API_KEY
|
||||||
|
XIAOHONGSHU_API_BASE
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "${OSTYPE:-}" != darwin* ]]; then
|
||||||
|
echo "setup-keychain.sh requires macOS (security command). Got: $OSTYPE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! command -v security >/dev/null 2>&1; then
|
||||||
|
echo "security command not found on PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPLACE=0
|
||||||
|
ACTION="prompt"
|
||||||
|
TARGETS=()
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--list) ACTION="list"; shift ;;
|
||||||
|
--delete) ACTION="delete"; shift ;;
|
||||||
|
--replace) REPLACE=1; shift ;;
|
||||||
|
--help|-h) sed -n '2,/^$/p' "$0" | sed 's/^# //; s/^#//'; exit 0 ;;
|
||||||
|
-*) echo "unknown flag: $1" >&2; exit 2 ;;
|
||||||
|
*) TARGETS+=("$1"); shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
case "$ACTION" in
|
||||||
|
list)
|
||||||
|
echo "Stored last30days-* keychain items:"
|
||||||
|
for key in "${ALL_KEYS[@]}"; do
|
||||||
|
if security find-generic-password -a "$USER" -s "${PREFIX}${key}" -w >/dev/null 2>&1; then
|
||||||
|
echo " $key"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
delete)
|
||||||
|
if [[ ${#TARGETS[@]} -eq 0 ]]; then
|
||||||
|
echo "--delete needs at least one KEY name" >&2; exit 2
|
||||||
|
fi
|
||||||
|
for key in "${TARGETS[@]}"; do
|
||||||
|
if security delete-generic-password -a "$USER" -s "${PREFIX}${key}" >/dev/null 2>&1; then
|
||||||
|
echo "deleted: $key"
|
||||||
|
else
|
||||||
|
echo "not found: $key"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [[ ${#TARGETS[@]} -eq 0 ]]; then
|
||||||
|
TARGETS=("${ALL_KEYS[@]}")
|
||||||
|
fi
|
||||||
|
|
||||||
|
added=0; skipped=0; replaced=0
|
||||||
|
for key in "${TARGETS[@]}"; do
|
||||||
|
existing="$(security find-generic-password -a "$USER" -s "${PREFIX}${key}" -w 2>/dev/null || true)"
|
||||||
|
if [[ -n "$existing" && "$REPLACE" -eq 0 ]]; then
|
||||||
|
printf " %-28s (set, skipping — use --replace to overwrite)\n" "$key"
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
printf " %-28s " "$key"
|
||||||
|
IFS= read -rs value
|
||||||
|
echo
|
||||||
|
if [[ -z "$value" ]]; then
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
security add-generic-password -U -a "$USER" -s "${PREFIX}${key}" -w "$value"
|
||||||
|
if [[ -n "$existing" ]]; then
|
||||||
|
replaced=$((replaced + 1))
|
||||||
|
else
|
||||||
|
added=$((added + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done. added=$added replaced=$replaced skipped=$skipped"
|
||||||
|
echo "Verify with: $0 --list"
|
||||||
@@ -114,9 +114,10 @@ class TestGetConfigCookieIntegration:
|
|||||||
@patch("lib.cookie_extract.extract_cookies")
|
@patch("lib.cookie_extract.extract_cookies")
|
||||||
@patch("lib.env._find_project_env", return_value=None)
|
@patch("lib.env._find_project_env", return_value=None)
|
||||||
@patch("lib.env.load_env_file", return_value={})
|
@patch("lib.env.load_env_file", return_value={})
|
||||||
|
@patch("lib.env._load_keychain", return_value={})
|
||||||
@patch("lib.env.get_openai_auth")
|
@patch("lib.env.get_openai_auth")
|
||||||
def test_get_config_injects_cookies(
|
def test_get_config_injects_cookies(
|
||||||
self, mock_openai, mock_load, mock_proj, mock_extract
|
self, mock_openai, mock_keychain, mock_load, mock_proj, mock_extract
|
||||||
):
|
):
|
||||||
from lib.env import get_config, OpenAIAuth
|
from lib.env import get_config, OpenAIAuth
|
||||||
mock_openai.return_value = OpenAIAuth(
|
mock_openai.return_value = OpenAIAuth(
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""Tests for macOS Keychain credential source in lib/env.py.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- non-Darwin returns {}
|
||||||
|
- missing `security` binary returns {}
|
||||||
|
- successful lookups return parsed key/value pairs
|
||||||
|
- subprocess timeout / OSError are swallowed
|
||||||
|
- get_config merges keychain at lowest priority and labels _CONFIG_SOURCE
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "skills" / "last30days" / "scripts"))
|
||||||
|
|
||||||
|
from lib import env # noqa: E402
|
||||||
|
|
||||||
|
SETUP_KEYCHAIN_SH = Path(__file__).resolve().parents[1] / "skills" / "last30days" / "scripts" / "setup-keychain.sh"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _load_keychain unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_keychain_returns_empty_on_non_darwin():
|
||||||
|
with mock.patch("platform.system", return_value="Linux"):
|
||||||
|
assert env._load_keychain(["XAI_API_KEY"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_keychain_returns_empty_when_security_missing():
|
||||||
|
with mock.patch("platform.system", return_value="Darwin"), \
|
||||||
|
mock.patch("shutil.which", return_value=None):
|
||||||
|
assert env._load_keychain(["XAI_API_KEY"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _run_result(returncode: int, stdout: str = "") -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="")
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_keychain_loads_present_keys_skips_missing():
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
service = cmd[cmd.index("-s") + 1]
|
||||||
|
if service == "last30days-XAI_API_KEY":
|
||||||
|
return _run_result(0, "xai-abc\n")
|
||||||
|
if service == "last30days-BRAVE_API_KEY":
|
||||||
|
return _run_result(0, "brv-xyz\n")
|
||||||
|
return _run_result(44) # security's "not found" exit code
|
||||||
|
|
||||||
|
with mock.patch("platform.system", return_value="Darwin"), \
|
||||||
|
mock.patch("shutil.which", return_value="/usr/bin/security"), \
|
||||||
|
mock.patch("subprocess.run", side_effect=fake_run):
|
||||||
|
result = env._load_keychain(["XAI_API_KEY", "BRAVE_API_KEY", "OPENAI_API_KEY"])
|
||||||
|
|
||||||
|
assert result == {"XAI_API_KEY": "xai-abc", "BRAVE_API_KEY": "brv-xyz"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_keychain_strips_whitespace_and_newlines():
|
||||||
|
with mock.patch("platform.system", return_value="Darwin"), \
|
||||||
|
mock.patch("shutil.which", return_value="/usr/bin/security"), \
|
||||||
|
mock.patch("subprocess.run", return_value=_run_result(0, " hello-key \n")):
|
||||||
|
result = env._load_keychain(["FOO"])
|
||||||
|
assert result == {"FOO": "hello-key"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_keychain_swallows_subprocess_errors():
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
raise subprocess.TimeoutExpired(cmd=cmd, timeout=5)
|
||||||
|
|
||||||
|
with mock.patch("platform.system", return_value="Darwin"), \
|
||||||
|
mock.patch("shutil.which", return_value="/usr/bin/security"), \
|
||||||
|
mock.patch("subprocess.run", side_effect=fake_run):
|
||||||
|
assert env._load_keychain(["XAI_API_KEY"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_keychain_swallows_oserror():
|
||||||
|
with mock.patch("platform.system", return_value="Darwin"), \
|
||||||
|
mock.patch("shutil.which", return_value="/usr/bin/security"), \
|
||||||
|
mock.patch("subprocess.run", side_effect=OSError("boom")):
|
||||||
|
assert env._load_keychain(["XAI_API_KEY"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_keychain_skips_empty_stdout():
|
||||||
|
with mock.patch("platform.system", return_value="Darwin"), \
|
||||||
|
mock.patch("shutil.which", return_value="/usr/bin/security"), \
|
||||||
|
mock.patch("subprocess.run", return_value=_run_result(0, "")):
|
||||||
|
assert env._load_keychain(["XAI_API_KEY"]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_config integration tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def clean_env(monkeypatch, tmp_path):
|
||||||
|
"""Hide every key get_config might touch and point CONFIG_FILE at a
|
||||||
|
non-existent path so no real user config bleeds in."""
|
||||||
|
for var in [
|
||||||
|
"OPENAI_API_KEY", "XAI_API_KEY", "BRAVE_API_KEY", "AUTH_TOKEN", "CT0",
|
||||||
|
"SCRAPECREATORS_API_KEY", "APIFY_API_TOKEN", "BSKY_HANDLE",
|
||||||
|
"BSKY_APP_PASSWORD", "TRUTHSOCIAL_TOKEN", "EXA_API_KEY",
|
||||||
|
"SERPER_API_KEY", "OPENROUTER_API_KEY", "PARALLEL_API_KEY",
|
||||||
|
"XQUIK_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_KEY",
|
||||||
|
"GOOGLE_GENAI_API_KEY", "INCLUDE_SOURCES", "FROM_BROWSER",
|
||||||
|
]:
|
||||||
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env")
|
||||||
|
monkeypatch.chdir(tmp_path) # no project .env in this tree either
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_reports_keychain_source(clean_env):
|
||||||
|
with mock.patch.object(env, "_load_keychain", return_value={"XAI_API_KEY": "xai-from-kc"}):
|
||||||
|
cfg = env.get_config()
|
||||||
|
assert cfg["_CONFIG_SOURCE"] == "keychain"
|
||||||
|
assert cfg["XAI_API_KEY"] == "xai-from-kc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_env_var_overrides_keychain(clean_env, monkeypatch):
|
||||||
|
monkeypatch.setenv("XAI_API_KEY", "xai-from-env")
|
||||||
|
with mock.patch.object(env, "_load_keychain", return_value={"XAI_API_KEY": "xai-from-kc"}):
|
||||||
|
cfg = env.get_config()
|
||||||
|
assert cfg["XAI_API_KEY"] == "xai-from-env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_reports_env_only_when_keychain_empty(clean_env):
|
||||||
|
with mock.patch.object(env, "_load_keychain", return_value={}):
|
||||||
|
cfg = env.get_config()
|
||||||
|
assert cfg["_CONFIG_SOURCE"] == "env_only"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_global_file_outranks_keychain(clean_env, tmp_path, monkeypatch):
|
||||||
|
cfg_file = tmp_path / "global.env"
|
||||||
|
cfg_file.write_text("XAI_API_KEY=xai-from-file\n")
|
||||||
|
monkeypatch.setattr(env, "CONFIG_FILE", cfg_file)
|
||||||
|
with mock.patch.object(env, "_load_keychain", return_value={"XAI_API_KEY": "xai-from-kc"}):
|
||||||
|
cfg = env.get_config()
|
||||||
|
assert cfg["XAI_API_KEY"] == "xai-from-file"
|
||||||
|
assert cfg["_CONFIG_SOURCE"].startswith("global:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_config_openai_key_can_come_from_keychain(clean_env):
|
||||||
|
"""OPENAI_API_KEY must be visible to get_openai_auth via the keychain
|
||||||
|
merge — wiring regression test."""
|
||||||
|
with mock.patch.object(env, "_load_keychain", return_value={"OPENAI_API_KEY": "sk-from-kc"}):
|
||||||
|
cfg = env.get_config()
|
||||||
|
assert cfg["OPENAI_API_KEY"] == "sk-from-kc"
|
||||||
|
assert cfg["OPENAI_AUTH_SOURCE"] == "api_key"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Drift guard: lib/env.py KEYCHAIN_KEYS and setup-keychain.sh ALL_KEYS must
|
||||||
|
# stay in lockstep. A mismatch means users storing a key via the helper script
|
||||||
|
# wouldn't see it picked up by the loader, or vice versa.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_all_keys_from_shell(script: Path) -> list[str]:
|
||||||
|
text = script.read_text(encoding="utf-8")
|
||||||
|
match = re.search(r"ALL_KEYS=\(\s*(.*?)\s*\)", text, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
raise AssertionError(f"ALL_KEYS=( ... ) array not found in {script}")
|
||||||
|
body = match.group(1)
|
||||||
|
# Strip shell comments and split on whitespace
|
||||||
|
body = re.sub(r"#[^\n]*", "", body)
|
||||||
|
return [tok for tok in body.split() if tok]
|
||||||
|
|
||||||
|
|
||||||
|
def test_keychain_keys_match_setup_script():
|
||||||
|
shell_keys = _parse_all_keys_from_shell(SETUP_KEYCHAIN_SH)
|
||||||
|
python_keys = list(env.KEYCHAIN_KEYS)
|
||||||
|
assert shell_keys == python_keys, (
|
||||||
|
"lib/env.py::KEYCHAIN_KEYS and scripts/setup-keychain.sh::ALL_KEYS "
|
||||||
|
f"have drifted.\n python: {python_keys}\n shell: {shell_keys}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user