Skip to content

Commit 8afebd7

Browse files
authored
Merge pull request #65 from ClaydeCode/clayde/issue-64-cli-auth-errors
Fix #64: CLI backend: auth errors surface as misleading pydantic parse failure
2 parents 3baf867 + 5d2f5d1 commit 8afebd7

2 files changed

Lines changed: 76 additions & 2 deletions

File tree

src/clayde/claude.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@
4343
"hit your limit",
4444
]
4545

46+
# Text patterns that indicate an authentication error in CLI error output.
47+
_AUTH_ERROR_PATTERNS = [
48+
"not logged in",
49+
"failed to authenticate",
50+
"authentication_error",
51+
"invalid authentication credentials",
52+
]
53+
4654

4755
@dataclasses.dataclass
4856
class InvocationResult:
@@ -446,6 +454,12 @@ def _is_limit_error(text: str) -> bool:
446454
return any(p in t for p in _LIMIT_PATTERNS)
447455

448456

457+
def _is_auth_error(text: str) -> bool:
458+
"""Return True if text contains an authentication error pattern."""
459+
t = text.lower()
460+
return any(p in t for p in _AUTH_ERROR_PATTERNS)
461+
462+
449463
def _make_cli_env() -> dict[str, str]:
450464
"""Build an environment dict for CLI subprocess calls."""
451465
env = os.environ.copy()
@@ -613,6 +627,11 @@ def invoke(
613627
exc = UsageLimitError("Claude CLI usage limit hit")
614628
span.record_exception(exc)
615629
raise exc
630+
if _is_auth_error(error_text):
631+
log.error("Claude CLI authentication failed")
632+
exc = RuntimeError("Claude CLI authentication failed")
633+
span.record_exception(exc)
634+
raise exc
616635

617636
if result.returncode != 0:
618637
log.error("Claude CLI exited with code %d", result.returncode)
@@ -652,8 +671,8 @@ def is_available(self) -> bool:
652671
if _is_limit_error(error_text):
653672
span.set_attribute("claude.available", False)
654673
return False
655-
if is_error and "not logged in" in error_text.lower():
656-
log.warning("Claude CLI is not logged in — marking unavailable")
674+
if is_error and _is_auth_error(error_text):
675+
log.warning("Claude CLI authentication failed — marking unavailable")
657676
span.set_attribute("claude.available", False)
658677
return False
659678
span.set_attribute("claude.available", True)

tests/test_claude.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,42 @@ def test_stale_session_no_conv_path_no_retry(self, tmp_path):
888888
assert result.output == ""
889889
assert mock_run.call_count == 1
890890

891+
def test_auth_error_raises_runtime_error(self, tmp_path):
892+
"""is_error=True with auth failure raises RuntimeError instead of returning error text."""
893+
(tmp_path / "CLAUDE.md").write_text("identity")
894+
auth_error_text = (
895+
'Failed to authenticate. API Error: 401 {"type":"error","error":{'
896+
'"type":"authentication_error","message":"Invalid authentication credentials"}}'
897+
)
898+
mock_result = MagicMock()
899+
mock_result.stdout = json.dumps({"is_error": True, "result": auth_error_text})
900+
mock_result.stderr = ""
901+
mock_result.returncode = 0
902+
backend = CliBackend()
903+
904+
with patch("clayde.claude.APP_DIR", tmp_path), \
905+
patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \
906+
patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \
907+
patch("clayde.claude.subprocess.run", return_value=mock_result):
908+
with pytest.raises(RuntimeError, match="authentication failed"):
909+
backend.invoke("prompt", "/repo")
910+
911+
def test_not_logged_in_raises_runtime_error(self, tmp_path):
912+
"""is_error=True with 'not logged in' text raises RuntimeError."""
913+
(tmp_path / "CLAUDE.md").write_text("identity")
914+
mock_result = MagicMock()
915+
mock_result.stdout = json.dumps({"is_error": True, "result": "Not logged in · Please run /login"})
916+
mock_result.stderr = ""
917+
mock_result.returncode = 0
918+
backend = CliBackend()
919+
920+
with patch("clayde.claude.APP_DIR", tmp_path), \
921+
patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \
922+
patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \
923+
patch("clayde.claude.subprocess.run", return_value=mock_result):
924+
with pytest.raises(RuntimeError, match="authentication failed"):
925+
backend.invoke("prompt", "/repo")
926+
891927

892928
class TestCliBackendIsAvailable:
893929
def test_available_on_success(self):
@@ -926,6 +962,25 @@ def test_unavailable_on_not_logged_in(self):
926962
patch("clayde.claude.subprocess.run", return_value=mock_result):
927963
assert backend.is_available() is False
928964

965+
def test_unavailable_on_failed_to_authenticate(self):
966+
mock_result = MagicMock()
967+
mock_result.stdout = json.dumps({
968+
"is_error": True,
969+
"result": (
970+
"Failed to authenticate. API Error: 401 "
971+
'{"type":"error","error":{"type":"authentication_error",'
972+
'"message":"Invalid authentication credentials"}}'
973+
),
974+
})
975+
mock_result.stderr = ""
976+
mock_result.returncode = 0
977+
backend = CliBackend()
978+
979+
with patch("clayde.claude.get_settings", return_value=_mock_settings(backend="cli")), \
980+
patch("clayde.claude._resolve_cli_bin", return_value="/usr/bin/claude"), \
981+
patch("clayde.claude.subprocess.run", return_value=mock_result):
982+
assert backend.is_available() is False
983+
929984
def test_available_on_exception(self):
930985
backend = CliBackend()
931986

0 commit comments

Comments
 (0)