From 5051709343a561dd93d5ef3bb93133beb01a3276 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=93=88=E5=9F=BA=E7=B1=B3?= <140241684+BlueX888@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:46:12 +0800 Subject: [PATCH] fix(models): skip Claude credentials sources with a non-numeric expiresAt (#5591) `_extract_claude_code_credential` copied `expiresAt` straight into `ClaudeCodeCredential.expires_at`, so a credentials file whose `expiresAt` is a string, null, list or object reached `is_expired` and raised `TypeError: '<=' not supported between instances of 'str' and 'int'`. That aborted the whole lookup instead of skipping the malformed source and moving on down the documented order, the way the rest of the loader already behaves for a malformed `claudeAiOauth` container. Validate the field the way the sibling branches validate their input: log a debug line and skip the source so the next candidate is tried. --- .../deerflow/models/credential_loader.py | 7 ++- backend/tests/test_credential_loader.py | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/backend/packages/harness/deerflow/models/credential_loader.py b/backend/packages/harness/deerflow/models/credential_loader.py index 9e16c43c3..05f839ed1 100644 --- a/backend/packages/harness/deerflow/models/credential_loader.py +++ b/backend/packages/harness/deerflow/models/credential_loader.py @@ -160,10 +160,15 @@ def _extract_claude_code_credential(data: dict[str, Any], source: str) -> Claude logger.debug("Claude Code credentials container exists but no accessToken found") return None + expires_at = oauth.get("expiresAt", 0) + if not isinstance(expires_at, (int, float)): + logger.debug("Claude Code credentials source %s has a non-numeric expiresAt; skipping", source) + return None + cred = ClaudeCodeCredential( access_token=access_token, refresh_token=oauth.get("refreshToken", ""), - expires_at=oauth.get("expiresAt", 0), + expires_at=expires_at, source=source, ) diff --git a/backend/tests/test_credential_loader.py b/backend/tests/test_credential_loader.py index b9a1b4580..2df804f68 100644 --- a/backend/tests/test_credential_loader.py +++ b/backend/tests/test_credential_loader.py @@ -300,6 +300,62 @@ def test_load_claude_code_credential_falls_back_to_default_when_override_contain assert cred.source == "claude-cli-file" +@pytest.mark.parametrize( + "expires_at", + [ + "1773430695128", + None, + [], + {}, + ], +) +def test_load_claude_code_credential_ignores_non_numeric_expires_at(tmp_path, monkeypatch, expires_at): + _clear_claude_code_env(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + cred_file = tmp_path / "credentials.json" + cred_file.write_text( + json.dumps({"claudeAiOauth": {"accessToken": "sk-ant-oat01-test", "expiresAt": expires_at}}), + encoding="utf-8", + ) + monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(cred_file)) + + assert load_claude_code_credential() is None + + +def test_load_claude_code_credential_falls_back_to_default_when_override_expires_at_is_non_numeric(tmp_path, monkeypatch): + _clear_claude_code_env(monkeypatch) + monkeypatch.setenv("HOME", str(tmp_path)) + + override_path = tmp_path / "credentials.json" + override_path.write_text( + json.dumps({"claudeAiOauth": {"accessToken": "sk-ant-oat01-override", "expiresAt": "1773430695128"}}), + encoding="utf-8", + ) + monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(override_path)) + + default_path = tmp_path / ".claude" / ".credentials.json" + default_path.parent.mkdir() + default_path.write_text( + json.dumps( + { + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-default", + "refreshToken": "sk-ant-ort01-default", + "expiresAt": 4_102_444_800_000, + } + } + ), + encoding="utf-8", + ) + + cred = load_claude_code_credential() + + assert cred is not None + assert cred.access_token == "sk-ant-oat01-default" + assert cred.refresh_token == "sk-ant-ort01-default" + assert cred.source == "claude-cli-file" + + def test_load_codex_cli_credential_supports_nested_tokens_shape(tmp_path, monkeypatch): auth_path = tmp_path / "auth.json" auth_path.write_text(