Skip to content

Commit d060747

Browse files
Merge pull request #428 from staszewski/fix/macos-keychain-claude-auth
fix: read Claude Code OAuth credentials from macOS Keychain
2 parents e9a62ba + 386a37d commit d060747

4 files changed

Lines changed: 85 additions & 6 deletions

File tree

gauss_cli/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@
1010
- gauss status - Show status of all components
1111
"""
1212

13-
__version__ = "0.2.0"
14-
__release_date__ = "2026.3.12"
13+
__version__ = "0.2.1"
14+
__release_date__ = "2026.3.26"

gauss_cli/autoformalize.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import shlex
88
import shutil
99
import subprocess
10+
import sys
1011
import time
1112
from collections.abc import Mapping, Sequence
1213
from dataclasses import dataclass
@@ -529,6 +530,31 @@ def _has_local_claude_auth(real_home: Path) -> bool:
529530
return _has_local_claude_login(real_home) or _has_local_claude_api_key(real_home)
530531

531532

533+
def _read_keychain_claude_credentials() -> str | None:
534+
"""Return the Claude Code OAuth credentials JSON from the macOS Keychain, or ``None``."""
535+
if sys.platform != "darwin":
536+
return None
537+
try:
538+
result = subprocess.run(
539+
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
540+
capture_output=True,
541+
text=True,
542+
timeout=5,
543+
)
544+
if result.returncode != 0:
545+
return None
546+
payload = result.stdout.strip()
547+
if not payload:
548+
return None
549+
data = json.loads(payload)
550+
oauth = data.get("claudeAiOauth")
551+
if isinstance(oauth, Mapping) and str(oauth.get("accessToken", "")).strip():
552+
return payload
553+
except Exception:
554+
pass
555+
return None
556+
557+
532558
def _has_local_claude_login(real_home: Path) -> bool:
533559
credentials = real_home / ".claude" / ".credentials.json"
534560
if credentials.exists():
@@ -539,7 +565,7 @@ def _has_local_claude_login(real_home: Path) -> bool:
539565
return True
540566
except Exception:
541567
pass
542-
return False
568+
return _read_keychain_claude_credentials() is not None
543569

544570

545571
def _load_local_claude_config(real_home: Path) -> dict[str, Any]:
@@ -1164,8 +1190,13 @@ def _stage_claude_credentials(
11641190
destination_credentials = managed_claude_dir / ".credentials.json"
11651191
if destination_credentials.exists():
11661192
destination_credentials.unlink()
1167-
if copy_oauth_credentials and source_credentials.exists():
1168-
shutil.copy2(source_credentials, destination_credentials)
1193+
if copy_oauth_credentials:
1194+
if source_credentials.exists():
1195+
shutil.copy2(source_credentials, destination_credentials)
1196+
else:
1197+
keychain_payload = _read_keychain_claude_credentials()
1198+
if keychain_payload:
1199+
destination_credentials.write_text(keychain_payload, encoding="utf-8")
11691200

11701201
payload = _load_json_dict(managed_key_path)
11711202
payload.update(_load_local_claude_config(real_home))

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "gauss-agent"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
description = "Gauss is a focused Lean autoformalization workspace with a managed Claude Code launcher."
99
readme = "README.md"
1010
requires-python = ">=3.11"

tests/gauss_cli/test_autoformalize.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,53 @@ def test_build_claude_runtime_with_local_login_stages_managed_env_and_prompt(mon
465465
assert payload["mcpServers"]["lean-lsp"]["env"]["LEAN_PROJECT_PATH"] == str(shared_bundle.project.lean_root)
466466

467467

468+
def test_build_claude_runtime_falls_back_to_macos_keychain_when_credentials_file_absent(monkeypatch, tmp_path: Path):
469+
shared_bundle = _shared_bundle(tmp_path)
470+
workflow = _workflow("/prove", "File.lean")
471+
installed_plugin_root = (
472+
tmp_path
473+
/ "managed"
474+
/ "claude-home"
475+
/ ".claude"
476+
/ "plugins"
477+
/ "cache"
478+
/ "lean4-skills"
479+
/ "lean4"
480+
/ "4.4.0"
481+
)
482+
(installed_plugin_root / "skills" / "lean4").mkdir(parents=True)
483+
(installed_plugin_root / "skills" / "lean4" / "SKILL.md").write_text("# Lean4\n", encoding="utf-8")
484+
keychain_payload = json.dumps({"claudeAiOauth": {"accessToken": "sk-ant-oat01-test"}})
485+
monkeypatch.setattr(autoformalize, "_require_executable", lambda name, _msg, _env: f"/usr/bin/{name}")
486+
monkeypatch.setattr(autoformalize, "_claude_permission_args", lambda: ("--dangerously-skip-permissions",))
487+
monkeypatch.setattr(autoformalize, "_install_managed_claude_plugin", lambda **_kwargs: installed_plugin_root)
488+
monkeypatch.setattr(autoformalize, "_read_keychain_claude_credentials", lambda: keychain_payload)
489+
490+
runtime = autoformalize._build_claude_runtime(
491+
auth_mode="auto",
492+
user_instruction=workflow.workflow_args,
493+
workflow=workflow,
494+
base_environment={"HOME": str(shared_bundle.real_home), "PATH": "/usr/bin"},
495+
include_persisted_env=False,
496+
shared_bundle=shared_bundle,
497+
)
498+
499+
managed_credentials = runtime.managed_context.backend_home / ".claude" / ".credentials.json"
500+
assert managed_credentials.exists()
501+
staged = json.loads(managed_credentials.read_text(encoding="utf-8"))
502+
assert staged["claudeAiOauth"]["accessToken"] == "sk-ant-oat01-test"
503+
assert "ANTHROPIC_API_KEY" not in runtime.child_env
504+
505+
506+
def test_has_local_claude_login_detects_macos_keychain_when_credentials_file_absent(monkeypatch, tmp_path: Path):
507+
real_home = tmp_path / "real-home"
508+
real_home.mkdir()
509+
keychain_payload = json.dumps({"claudeAiOauth": {"accessToken": "sk-ant-oat01-test"}})
510+
monkeypatch.setattr(autoformalize, "_read_keychain_claude_credentials", lambda: keychain_payload)
511+
512+
assert autoformalize._has_local_claude_login(real_home) is True
513+
514+
468515
def test_build_claude_runtime_accepts_anthropic_api_key(monkeypatch, tmp_path: Path):
469516
shared_bundle = _shared_bundle(tmp_path)
470517
workflow = _workflow("/formalize")
@@ -483,6 +530,7 @@ def test_build_claude_runtime_accepts_anthropic_api_key(monkeypatch, tmp_path: P
483530
monkeypatch.setattr(autoformalize, "_require_executable", lambda name, _msg, _env: f"/usr/bin/{name}")
484531
monkeypatch.setattr(autoformalize, "_claude_permission_args", lambda: ("--dangerously-skip-permissions",))
485532
monkeypatch.setattr(autoformalize, "_install_managed_claude_plugin", lambda **_kwargs: installed_plugin_root)
533+
monkeypatch.setattr(autoformalize, "_read_keychain_claude_credentials", lambda: None)
486534

487535
runtime = autoformalize._build_claude_runtime(
488536
auth_mode="auto",

0 commit comments

Comments
 (0)