fix(doctor): validate CLI credential contents (#5567)

* fix(doctor): validate CLI credential contents

* fix(doctor): keep provider results when CLI auth state is undecodable

A credential file holding non-UTF-8 bytes made read_text raise
UnicodeDecodeError, which _load_json_object did not catch. check_llm_auth
wraps the whole model loop in one handler, so a single bad file replaced
every provider result with one generic failure and dropped the
provider-specific fix hints. Catch it alongside the malformed-state cases
and pin the behaviour with a two-model regression test.

Also cover the acceptance branches the change claims but did not test
(malformed JSON, non-object JSON, a directory as the auth path, blank
tokens, non-numeric expiresAt) and record credential_loader.py as the
source of truth for the mirrored rules, including the one place doctor is
deliberately stricter.
This commit is contained in:
Zheng Haoran 2026-09-20 19:29:47 +08:00 committed by GitHub
parent 19266a5eac
commit 2529260c19
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 197 additions and 5 deletions

View File

@ -7,6 +7,7 @@ Run from repo root:
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
@ -246,6 +247,33 @@ class TestCheckLLMAuth:
results = doctor.check_llm_auth(cfg)
assert any(result.status == "fail" and "Codex CLI auth available" in result.label for result in results)
def test_codex_auth_file_without_token_fails(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: codex\n use: deerflow.models.openai_codex_provider:CodexChatModel\n model: gpt-5.4\n")
auth_path = tmp_path / "auth.json"
auth_path.write_text("{}")
monkeypatch.setenv("CODEX_AUTH_PATH", str(auth_path))
results = doctor.check_llm_auth(cfg)
assert any(result.status == "fail" and "Codex CLI auth available" in result.label for result in results)
def test_codex_auth_file_with_supported_token_shapes_passes(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: codex\n use: deerflow.models.openai_codex_provider:CodexChatModel\n model: gpt-5.4\n")
auth_path = tmp_path / "auth.json"
monkeypatch.setenv("CODEX_AUTH_PATH", str(auth_path))
for payload in (
'{"access_token": "codex-token"}',
'{"token": "codex-token"}',
'{"tokens": {"access_token": "codex-token"}}',
):
auth_path.write_text(payload)
results = doctor.check_llm_auth(cfg)
assert any(result.status == "ok" and "Codex CLI auth available" in result.label for result in results)
def test_claude_oauth_env_passes(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: claude\n use: deerflow.models.claude_provider:ClaudeChatModel\n model: claude-sonnet-4-6\n")
@ -253,6 +281,124 @@ class TestCheckLLMAuth:
results = doctor.check_llm_auth(cfg)
assert any(result.status == "ok" and "Claude auth available" in result.label for result in results)
def test_claude_credentials_file_without_token_fails(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: claude\n use: deerflow.models.claude_provider:ClaudeChatModel\n model: claude-sonnet-4-6\n")
credentials_path = tmp_path / "credentials.json"
credentials_path.write_text("{}")
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(credentials_path))
monkeypatch.setenv("HOME", str(tmp_path))
for name in ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"):
monkeypatch.delenv(name, raising=False)
results = doctor.check_llm_auth(cfg)
assert any(result.status == "fail" and "Claude auth available" in result.label for result in results)
def test_claude_expired_credentials_file_fails(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: claude\n use: deerflow.models.claude_provider:ClaudeChatModel\n model: claude-sonnet-4-6\n")
credentials_path = tmp_path / "credentials.json"
credentials_path.write_text('{"claudeAiOauth": {"accessToken": "expired-token", "expiresAt": 1}}')
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(credentials_path))
monkeypatch.setenv("HOME", str(tmp_path))
for name in ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"):
monkeypatch.delenv(name, raising=False)
results = doctor.check_llm_auth(cfg)
assert any(result.status == "fail" and "Claude auth available" in result.label for result in results)
def test_claude_credentials_file_with_token_passes(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: claude\n use: deerflow.models.claude_provider:ClaudeChatModel\n model: claude-sonnet-4-6\n")
credentials_path = tmp_path / "credentials.json"
credentials_path.write_text('{"claudeAiOauth": {"accessToken": "claude-token"}}')
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(credentials_path))
monkeypatch.setenv("HOME", str(tmp_path))
for name in ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"):
monkeypatch.delenv(name, raising=False)
results = doctor.check_llm_auth(cfg)
assert any(result.status == "ok" and "Claude auth available" in result.label for result in results)
def test_codex_auth_file_with_malformed_json_fails(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: codex\n use: deerflow.models.openai_codex_provider:CodexChatModel\n model: gpt-5.4\n")
auth_path = tmp_path / "auth.json"
auth_path.write_text("not json")
monkeypatch.setenv("CODEX_AUTH_PATH", str(auth_path))
results = doctor.check_llm_auth(cfg)
assert any(result.status == "fail" and "Codex CLI auth available" in result.label for result in results)
def test_codex_auth_file_with_non_object_json_fails(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: codex\n use: deerflow.models.openai_codex_provider:CodexChatModel\n model: gpt-5.4\n")
auth_path = tmp_path / "auth.json"
auth_path.write_text('["access_token"]')
monkeypatch.setenv("CODEX_AUTH_PATH", str(auth_path))
results = doctor.check_llm_auth(cfg)
assert any(result.status == "fail" and "Codex CLI auth available" in result.label for result in results)
def test_codex_auth_path_pointing_at_directory_fails(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: codex\n use: deerflow.models.openai_codex_provider:CodexChatModel\n model: gpt-5.4\n")
auth_path = tmp_path / "auth.json"
auth_path.mkdir()
monkeypatch.setenv("CODEX_AUTH_PATH", str(auth_path))
results = doctor.check_llm_auth(cfg)
assert any(result.status == "fail" and "Codex CLI auth available" in result.label for result in results)
def test_codex_auth_file_with_blank_token_fails(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: codex\n use: deerflow.models.openai_codex_provider:CodexChatModel\n model: gpt-5.4\n")
auth_path = tmp_path / "auth.json"
auth_path.write_text('{"access_token": " "}')
monkeypatch.setenv("CODEX_AUTH_PATH", str(auth_path))
results = doctor.check_llm_auth(cfg)
assert any(result.status == "fail" and "Codex CLI auth available" in result.label for result in results)
def test_claude_credentials_file_with_invalid_expires_at_fails(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n - name: claude\n use: deerflow.models.claude_provider:ClaudeChatModel\n model: claude-sonnet-4-6\n")
credentials_path = tmp_path / "credentials.json"
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(credentials_path))
monkeypatch.setenv("HOME", str(tmp_path))
for name in ("ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR"):
monkeypatch.delenv(name, raising=False)
for expires_at in ("soon", True, [1]):
credentials_path.write_text(json.dumps({"claudeAiOauth": {"accessToken": "claude-token", "expiresAt": expires_at}}))
results = doctor.check_llm_auth(cfg)
assert any(result.status == "fail" and "Claude auth available" in result.label for result in results), expires_at
def test_undecodable_auth_file_keeps_other_model_results(self, tmp_path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text(
"config_version: 5\nmodels:\n"
" - name: codex\n use: deerflow.models.openai_codex_provider:CodexChatModel\n model: gpt-5.4\n"
" - name: claude\n use: deerflow.models.claude_provider:ClaudeChatModel\n model: claude-sonnet-4-6\n"
)
auth_path = tmp_path / "auth.json"
auth_path.write_bytes(b'{"access_token": "\xff\xfe"}')
monkeypatch.setenv("CODEX_AUTH_PATH", str(auth_path))
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "claude-token")
results = doctor.check_llm_auth(cfg)
assert any(result.status == "fail" and "Codex CLI auth available" in result.label for result in results)
assert any(result.status == "ok" and "Claude auth available" in result.label for result in results)
def test_commented_out_models_block_returns_empty(self, tmp_path):
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\nmodels:\n # - name: default\n")

View File

@ -11,10 +11,12 @@ Exit codes:
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import time
from importlib import import_module
from pathlib import Path
from typing import Literal
@ -101,6 +103,48 @@ def _split_use_path(use: str) -> tuple[str, str] | None:
return module_name, attr_name
def _load_json_object(path: Path) -> dict | None:
"""Load a JSON object without letting malformed CLI state abort doctor."""
if not path.is_file():
return None
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
return None
return value if isinstance(value, dict) else None
def _has_non_empty_token(value: object) -> bool:
return isinstance(value, str) and bool(value.strip())
# Acceptance rules mirror backend/packages/harness/deerflow/models/credential_loader.py
# (``load_codex_cli_credential``, ``_extract_claude_code_credential`` + ``is_expired``), which
# stays the source of truth; keep the two in lockstep when the loader changes. The mirror is
# deliberate: importing the loader could consume the one-shot
# CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR handoff. Strictness differs on purpose in one
# place -- doctor rejects a whitespace-only token that the runtime's truthiness check would
# accept before failing at the provider.
def _codex_auth_file_has_access_token(path: Path) -> bool:
data = _load_json_object(path)
if data is None:
return False
tokens = data.get("tokens")
nested_access_token = tokens.get("access_token") if isinstance(tokens, dict) else None
return any(_has_non_empty_token(value) for value in (data.get("access_token"), data.get("token"), nested_access_token))
def _claude_credentials_file_has_access_token(path: Path) -> bool:
data = _load_json_object(path)
oauth = data.get("claudeAiOauth") if data is not None else None
if not isinstance(oauth, dict) or not _has_non_empty_token(oauth.get("accessToken")):
return False
expires_at = oauth.get("expiresAt", 0)
if isinstance(expires_at, bool) or not isinstance(expires_at, (int, float)):
return False
return expires_at <= 0 or time.time() * 1000 <= expires_at - 60_000
# ---------------------------------------------------------------------------
# Check result container
# ---------------------------------------------------------------------------
@ -414,7 +458,7 @@ def check_llm_auth(config_path: Path) -> list[CheckResult]:
if use == "deerflow.models.openai_codex_provider:CodexChatModel":
auth_path = Path(os.environ.get("CODEX_AUTH_PATH", "~/.codex/auth.json")).expanduser()
if auth_path.exists():
if _codex_auth_file_has_access_token(auth_path):
results.append(CheckResult(f"Codex CLI auth available (model: {model_name})", "ok", str(auth_path)))
else:
results.append(
@ -422,7 +466,7 @@ def check_llm_auth(config_path: Path) -> list[CheckResult]:
f"Codex CLI auth available (model: {model_name})",
"fail",
str(auth_path),
fix="Run `codex login`, or set CODEX_AUTH_PATH to a valid auth.json",
fix="Run `codex login`, or set CODEX_AUTH_PATH to an auth.json containing a valid access token",
)
)
@ -430,7 +474,7 @@ def check_llm_auth(config_path: Path) -> list[CheckResult]:
credential_paths = [Path(os.environ["CLAUDE_CODE_CREDENTIALS_PATH"]).expanduser() for env_name in ("CLAUDE_CODE_CREDENTIALS_PATH",) if os.environ.get(env_name)]
credential_paths.append(Path("~/.claude/.credentials.json").expanduser())
has_oauth_env = any(
os.environ.get(name)
(os.environ.get(name) or "").strip()
for name in (
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN",
@ -439,14 +483,16 @@ def check_llm_auth(config_path: Path) -> list[CheckResult]:
)
)
existing_path = next((path for path in credential_paths if path.exists()), None)
if has_oauth_env or existing_path is not None:
detail = "env var set" if has_oauth_env else str(existing_path)
valid_path = next((path for path in credential_paths if _claude_credentials_file_has_access_token(path)), None)
if has_oauth_env or valid_path is not None:
detail = "env var set" if has_oauth_env else str(valid_path)
results.append(CheckResult(f"Claude auth available (model: {model_name})", "ok", detail))
else:
results.append(
CheckResult(
f"Claude auth available (model: {model_name})",
"fail",
str(existing_path) if existing_path is not None else "",
fix=("Set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN, or place credentials at ~/.claude/.credentials.json"),
)
)