74a387b093
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.
153 lines
6.1 KiB
Python
153 lines
6.1 KiB
Python
"""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"
|