From 74a387b09381c11ce8e576e80491252b6df4db96 Mon Sep 17 00:00:00 2001 From: Damien Stevens Date: Sat, 16 May 2026 09:01:28 -0400 Subject: [PATCH] feat(env): macOS Keychain credential source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the macOS Keychain as the lowest-priority credential source on Darwin. Items stored as generic passwords with service name "last30days-" 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. --- README.md | 18 +++ skills/last30days/scripts/lib/env.py | 61 +++++++- skills/last30days/scripts/setup-keychain.sh | 118 +++++++++++++++ tests/test_env_cookies.py | 3 +- tests/test_env_keychain.py | 152 ++++++++++++++++++++ 5 files changed, 349 insertions(+), 3 deletions(-) create mode 100755 skills/last30days/scripts/setup-keychain.sh create mode 100644 tests/test_env_keychain.py diff --git a/README.md b/README.md index c2758e4..dfdfbf2 100644 --- a/README.md +++ b/README.md @@ -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 | | 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-` 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 1. **You type a topic.** Person, company, product, technology, "X vs Y." Anything. diff --git a/skills/last30days/scripts/lib/env.py b/skills/last30days/scripts/lib/env.py index e78f012..70fbaf9 100644 --- a/skills/last30days/scripts/lib/env.py +++ b/skills/last30days/scripts/lib/env.py @@ -29,6 +29,11 @@ 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-" + AuthSource = Literal["api_key", "codex", "none"] AuthStatus = Literal["ok", "missing", "expired", "missing_account_id"] @@ -91,6 +96,42 @@ 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 + 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: """Decode JWT payload without verification.""" try: @@ -214,6 +255,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 +264,21 @@ 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([ + '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) # 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: 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' diff --git a/skills/last30days/scripts/setup-keychain.sh b/skills/last30days/scripts/setup-keychain.sh new file mode 100755 index 0000000..e0b75eb --- /dev/null +++ b/skills/last30days/scripts/setup-keychain.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Store last30days API keys in the macOS Keychain. +# +# Keys are stored as generic passwords with service name `last30days-` +# 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" diff --git a/tests/test_env_cookies.py b/tests/test_env_cookies.py index 17a74b7..a270119 100644 --- a/tests/test_env_cookies.py +++ b/tests/test_env_cookies.py @@ -114,9 +114,10 @@ class TestGetConfigCookieIntegration: @patch("lib.cookie_extract.extract_cookies") @patch("lib.env._find_project_env", return_value=None) @patch("lib.env.load_env_file", return_value={}) + @patch("lib.env._load_keychain", return_value={}) @patch("lib.env.get_openai_auth") 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 mock_openai.return_value = OpenAIAuth( diff --git a/tests/test_env_keychain.py b/tests/test_env_keychain.py new file mode 100644 index 0000000..47db302 --- /dev/null +++ b/tests/test_env_keychain.py @@ -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"