feat(env): macOS Keychain credential source

Adds the macOS Keychain as the lowest-priority credential source on Darwin.
Items stored as generic passwords with service name "last30days-<KEY>" for
the current user are picked up automatically by get_config() — file env
and process env still win on collision.

No new config knob: behavior is strictly additive. On non-Darwin (or when
the `security` binary is missing) the loader is a no-op, so Linux/Windows
behavior is unchanged.

  Priority (highest wins):
    1. Environment variables
    2. .claude/last30days.env (per-project)
    3. ~/.config/last30days/.env (global)
    4. macOS Keychain items prefixed last30days- (new)

Includes:
  - lib/env.py: KEYCHAIN_SERVICE_PREFIX constant, _load_keychain helper
    (platform-gated, shutil.which-gated, subprocess-error tolerant),
    wiring into get_config before get_openai_auth so OPENAI_API_KEY can
    come from Keychain too, _CONFIG_SOURCE reports "keychain" when no
    file source is present.
  - scripts/setup-keychain.sh: bash helper with interactive set,
    --list, --delete, --replace modes. Uses `security add-generic-password`.
  - tests/test_env_keychain.py: 12 tests covering platform gate,
    missing-binary gate, success path, whitespace stripping, subprocess
    errors swallowed, get_config precedence, and an OPENAI_AUTH wiring
    regression test.
  - tests/test_env_cookies.py: existing integration test mocks the new
    _load_keychain hook so it stays hermetic on Darwin developer
    machines that have real keychain entries.
  - README.md: new "macOS Keychain (optional)" subsection under
    "Bring your own keys" documenting setup-keychain.sh and the manual
    `security add-generic-password` invocation.

Tested on macOS with a populated keychain and against the existing pytest
suite — CI-tracked tests (test_plugin_contract.py, test_version_consistency.py)
plus all env-touching tests pass. Pre-existing unrelated failures in
test_store.py / test_watchlist_commands.py / test_setup_openclaw.py /
test_footer_nudge_suppression.py are untouched.
This commit is contained in:
Damien Stevens
2026-05-16 09:01:28 -04:00
parent 4a30923892
commit 74a387b093
5 changed files with 349 additions and 3 deletions
+18
View File
@@ -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.
+59 -2
View File
@@ -29,6 +29,11 @@ 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-"
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 +96,42 @@ 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
user = os.environ.get("USER", "")
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 +255,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 +264,21 @@ 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([
'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',
])
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 +324,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'
+118
View File
@@ -0,0 +1,118 @@
#!/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-"
ALL_KEYS=(
OPENAI_API_KEY
XAI_API_KEY
GOOGLE_API_KEY
GEMINI_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
)
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"
+2 -1
View File
@@ -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(
+152
View File
@@ -0,0 +1,152 @@
"""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 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
# ---------------------------------------------------------------------------
# _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"