Skip to content

[bug] Claude credential loader raises TypeError instead of skipping the source when claudeAiOauth.expiresAt is a string or null #5589

Description

@BlueX888

Problem summary

The Claude credential loader raises TypeError instead of skipping the source when a credentials file's claudeAiOauth.expiresAt is a string or null, which breaks every ClaudeChatModel construction.

Affected area(s)

  • Agents / LangGraph (graph, prompts, langgraph.json)
  • Config / setup (make, config.yaml, env)

What happened?

load_claude_code_credential() documents a lookup order that degrades gracefully — a source that cannot be used is skipped and the next one is tried. That contract holds for a malformed container (merged in #5494 / bec423188), but not for a malformed expiresAt value.

_extract_claude_code_credential copies expiresAt out of the JSON with no type check:

expires_at=oauth.get("expiresAt", 0),

and then evaluates cred.is_expired:

@property
def is_expired(self) -> bool:
    if self.expires_at <= 0:
        return False
    return time.time() * 1000 > self.expires_at - 60_000  # 1 min buffer

If the file contains "expiresAt": "1773430695128" (a string) or "expiresAt": null, line 58 (if self.expires_at <= 0:) raises TypeError out of load_claude_code_credential(). It is not caught, so the loop never advances to ~/.claude/.credentials.json.

This is also internally inconsistent: a missing expiresAt falls back to the dataclass default 0, which is_expired explicitly tolerates (if self.expires_at <= 0: return False), while an explicit null — which means the same "unknown expiry" — raises.

Reachability: load_claude_code_credential() is called from ClaudeChatModel.model_post_init (backend/packages/harness/deerflow/models/claude_provider.py:91) with no try/except, and per AGENTS.md a fresh model instance is built per run (lead agent, title, summarization, subagents). One such credentials file therefore makes every Claude model construction raise instead of degrading to the next source.

Expected behavior

A source whose expiresAt is not a number should be treated like the other malformed inputs in the same function: skip that source (debug log) and continue down the documented lookup order, so load_claude_code_credential() either returns a credential from a later source or returns None. It should not raise.

Basis in the repo:

  • _extract_claude_code_credential was hardened in bec423188 (merged fix(models): guard Claude credential loader against malformed claudeAiOauth (#5473) #5494, issue [bug] Claude credential loader raises AttributeError when ~/.claude/.credentials.json has a non-object claudeAiOauth container #5473) to skip malformed input rather than abort: if not isinstance(data, dict): ... return None and if not isinstance(oauth, dict): logger.debug("Claude Code credentials source %s has a non-object claudeAiOauth container; skipping", source); return None. The PR body states the invariant — a bad source must become "no credential from this source ... so load_claude_code_credential() falls through to the next source".
  • The function's return annotation is ClaudeCodeCredential | None (line 150).
  • The module docstring (lines 186-193) documents the shape with "expiresAt": 1773430695128 as an int.
  • backend/tests/test_credential_loader.py pins the skip-and-fall-through contract for the container case: test_load_claude_code_credential_ignores_malformed_oauth_container asserts load_claude_code_credential() is None, and test_load_claude_code_credential_falls_back_to_default_when_override_container_is_malformed asserts the loader moves on to ~/.claude/.credentials.json. Neither parametrization includes a non-numeric expiresAt.

Steps to reproduce

No network or real credentials needed; CLAUDE_CODE_CREDENTIALS_PATH points at a scratch file.

import json, os, tempfile
from pathlib import Path

tmp = Path(tempfile.mkdtemp())
os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None)
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
os.environ["HOME"] = str(tmp)

override = tmp / "credentials.json"
# also try None, and the baseline int / absent cases
override.write_text(json.dumps({"claudeAiOauth": {"accessToken": "sk-ant-oat01-override",
                                                  "refreshToken": "sk-ant-ort01-override",
                                                  "expiresAt": "1773430695128"}}))
os.environ["CLAUDE_CODE_CREDENTIALS_PATH"] = str(override)

from deerflow.models.credential_loader import load_claude_code_credential
print(load_claude_code_credential())

Repro scripts used by the reporters: /tmp/dfbug/repro_claude_expiresat.py, /tmp/dfbug/my_repro_expiresat.py (the second also writes a valid ~/.claude/.credentials.json so the fall-through path is observable).

Running #5473-style direct construction shows the same failure escapes model construction:

ClaudeChatModel(model="claude-sonnet-4-5", anthropic_api_key="your-anthropic-api-key")

Relevant logs

Verbatim output, verifier 1 (repro_claude_expiresat.py, HEAD aa4e43a, backend uv env):

int(baseline): returned source=claude-cli-file token=sk-ant-oat01-default
string: RAISED TypeError: '<=' not supported between instances of 'str' and 'int'
null: RAISED TypeError: '<=' not supported between instances of 'NoneType' and 'int'
missing: returned source=claude-cli-file token=sk-ant-oat01-override
  File "credential_loader.py", line 217, in load_claude_code_credential
    cred = _extract_claude_code_credential(data, "claude-cli-file")
  File "credential_loader.py", line 170, in _extract_claude_code_credential
    if cred.is_expired:
  File "credential_loader.py", line 58, in is_expired
    if self.expires_at <= 0:
TypeError: '<=' not supported between instances of 'str' and 'int'

Verbatim output, verifier 2 (my_repro_expiresat.py, pristine HEAD aa4e43a, backend uv env, no network/key):

=== claim repro, pristine HEAD aa4e43a (backend uv env, no network/key) ===
string expiresAt: RAISED TypeError: '<=' not supported between instances of 'str' and 'int'   at if self.expires_at <= 0:
null expiresAt: RAISED TypeError: '<=' not supported between instances of 'NoneType' and 'int'   at if self.expires_at <= 0:
ClaudeChatModel(...) -> RAISED TypeError: '<=' not supported between instances of 'str' and 'int'

=== my own repro (/tmp/dfbug/my_repro_expiresat.py, fallback file present) ===
expiresAt=str  : RAISED TypeError ... credential_loader.py:58 if self.expires_at <= 0:
expiresAt=null : RAISED TypeError ... credential_loader.py:58
expiresAt=int  : -> ('claude-cli-file','sk-ant-oat01-override',4102444800000);  expiresAt=absent -> (...,0)

Reproduced a third time by the reporter of this issue with uv run python /tmp/dfbug/repro_claude_expiresat.py at the same HEAD, same three lines.

Root cause

backend/packages/harness/deerflow/models/credential_loader.py:166 — expires_at=oauth.get("expiresAt", 0) copies an unvalidated JSON value into ClaudeCodeCredential.expires_at, and credential_loader.py:58 (if self.expires_at <= 0:) then compares it to an int. A non-numeric expiresAt raises out of load_claude_code_credential() instead of the source being skipped.

Environment

  • DeerFlow: main @ aa4e43a2bcc8ffc3838b012c4583f561a0e199be
  • How are you running DeerFlow?: Other (backend unit/dev environment; no sandbox or gateway involved)
  • OS: macOS 26.6.2 (arm64, zsh)
  • Python: 3.12.14 (backend uv environment); the repro does not depend on the Python version
  • Node.js / pnpm: not involved
  • uv: backend uv environment used via uv run
  • Support bundle: not applicable — this is a pure library-level loader bug reproducible from a single JSON file, with no local config, sandbox, or gateway state involved

Proposed approach

Validate/coerce expiresAt at the point of extraction, mirroring the sibling guards already in this function, so a malformed value degrades to "unknown expiry" (the existing expires_at <= 0 path) and the loop continues to the next source; and add {"expiresAt": "..."} / {"expiresAt": None} to the parametrized malformed test.

Happy to open a PR with that change against credential_loader.py if it's useful — say the word and I'll send it.

Related issues / PRs

AI assistance disclosure

This issue was drafted with Claude Code assistance; the failure was independently reproduced by three separate runs on the same commit, and a human has read, understands, and takes responsibility for this report.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triageAwaiting maintainer triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions