Merge pull request #407 from DamienStevens/feat/macos-keychain-source
feat(env): macOS Keychain credential source
This commit is contained in:
@@ -29,6 +29,23 @@ else:
|
||||
|
||||
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"]
|
||||
AuthStatus = Literal["ok", "missing", "expired", "missing_account_id"]
|
||||
|
||||
@@ -91,6 +108,46 @@ def load_env_file(path: Path) -> dict[str, str]:
|
||||
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:
|
||||
"""Decode JWT payload without verification."""
|
||||
try:
|
||||
@@ -214,6 +271,7 @@ def get_config() -> dict[str, Any]:
|
||||
1. Environment variables (os.environ)
|
||||
2. .claude/last30days.env (per-project config)
|
||||
3. ~/.config/last30days/.env (global config)
|
||||
4. macOS Keychain items prefixed ``last30days-`` (Darwin only)
|
||||
"""
|
||||
# Load from global config file
|
||||
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 = 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}
|
||||
|
||||
# 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)
|
||||
|
||||
# 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:
|
||||
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:
|
||||
config['_CONFIG_SOURCE'] = f'project:{project_env_path}'
|
||||
elif CONFIG_FILE and CONFIG_FILE.exists():
|
||||
config['_CONFIG_SOURCE'] = f'global:{CONFIG_FILE}'
|
||||
elif keychain_env:
|
||||
config['_CONFIG_SOURCE'] = 'keychain'
|
||||
else:
|
||||
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"
|
||||
Reference in New Issue
Block a user