deer-flow/backend/tests/test_credential_loader.py
Hyeonsang Cho 1dd48d14d2
fix(models): reuse the Claude Code OAuth token read from a file descriptor (#5411)
* fix(models): reuse the Claude Code OAuth token read from a file descriptor

ClaudeChatModel accepts a Claude Code OAuth token through
CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR, but that handoff can be drained
only once: a pipe returns EOF after the first read and a file descriptor
keeps its advanced offset. _read_secret_from_file_descriptor read it again
on every call and kept nothing. Every ClaudeChatModel instance loads
credentials in model_post_init, and create_chat_model builds fresh
instances per run, so with a descriptor-only handoff the first model
authenticated and every model after it -- including the title model of
the very first run -- had no credential. The Anthropic SDK then raised
"Could not resolve authentication method" before sending a request.

A secret read from a descriptor is now kept for the life of the process,
keyed by (env_var, fd), so a different descriptor is still read fresh.
The read happens under a lock so two threads building their first model
concurrently cannot race one of them to EOF. Empty reads and OSError are
not cached and behave as before; lookup order, config keys, and log
messages are unchanged.

* docs(changelog): reference #5411 in the Claude Code OAuth descriptor fix entry

* test(models): pin that a closed descriptor handoff keeps its token

Review follow-up on #5411: the descriptor secret cache is keyed on the
fd number, which the OS recycles. Folding os.fstat identity into the key
would break the property the cache exists for -- once the handoff fd is
closed after the first read, fstat raises EBADF and every later model
would lose the token again -- and it would still miss a regular file
rewritten in place, which keeps its st_dev/st_ino.

The handoff is fixed at process start, so keep the number as the key and
state the invariant instead: a closed handoff keeps serving its token, a
secret placed on a recycled number is not re-read, and anything handing
over a new secret in-process must clear the cache. A new test pins the
closed-handoff behavior; an fstat-fingerprinted key fails exactly that
test.
2026-09-14 09:23:12 +08:00

283 lines
9.2 KiB
Python

import json
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import pytest
from deerflow.models import credential_loader
from deerflow.models.claude_provider import ClaudeChatModel
from deerflow.models.credential_loader import (
load_claude_code_credential,
load_codex_cli_credential,
)
@pytest.fixture(autouse=True)
def _isolate_file_descriptor_secret_cache(monkeypatch):
# Descriptor numbers are recycled across tests, so a shared cache would leak tokens.
monkeypatch.setattr(credential_loader, "_fd_secret_cache", {})
def _clear_claude_code_env(monkeypatch) -> None:
for env_var in (
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
"CLAUDE_CODE_CREDENTIALS_PATH",
):
monkeypatch.delenv(env_var, raising=False)
def test_load_claude_code_credential_from_direct_env(monkeypatch):
_clear_claude_code_env(monkeypatch)
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", " sk-ant-oat01-env ")
cred = load_claude_code_credential()
assert cred is not None
assert cred.access_token == "sk-ant-oat01-env"
assert cred.refresh_token == ""
assert cred.source == "claude-cli-env"
def test_load_claude_code_credential_from_anthropic_auth_env(monkeypatch):
_clear_claude_code_env(monkeypatch)
monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "sk-ant-oat01-anthropic-auth")
cred = load_claude_code_credential()
assert cred is not None
assert cred.access_token == "sk-ant-oat01-anthropic-auth"
assert cred.source == "claude-cli-env"
def test_load_claude_code_credential_from_file_descriptor(monkeypatch):
_clear_claude_code_env(monkeypatch)
read_fd, write_fd = os.pipe()
try:
os.write(write_fd, b"sk-ant-oat01-fd")
os.close(write_fd)
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", str(read_fd))
cred = load_claude_code_credential()
finally:
os.close(read_fd)
assert cred is not None
assert cred.access_token == "sk-ant-oat01-fd"
assert cred.refresh_token == ""
assert cred.source == "claude-cli-fd"
def _pipe_with_secret(secret: bytes) -> int:
read_fd, write_fd = os.pipe()
os.write(write_fd, secret)
os.close(write_fd)
return read_fd
def test_load_claude_code_credential_reuses_drained_file_descriptor(tmp_path, monkeypatch):
_clear_claude_code_env(monkeypatch)
monkeypatch.setenv("HOME", str(tmp_path))
read_fd = _pipe_with_secret(b"sk-ant-oat01-fd")
try:
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", str(read_fd))
first = load_claude_code_credential()
second = load_claude_code_credential()
finally:
os.close(read_fd)
assert first is not None
assert second is not None
assert second.access_token == first.access_token == "sk-ant-oat01-fd"
assert second.source == "claude-cli-fd"
def test_load_claude_code_credential_rereads_when_file_descriptor_changes(monkeypatch):
_clear_claude_code_env(monkeypatch)
first_fd = _pipe_with_secret(b"sk-ant-oat01-first")
second_fd = _pipe_with_secret(b"sk-ant-oat01-second")
try:
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", str(first_fd))
first = load_claude_code_credential()
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", str(second_fd))
second = load_claude_code_credential()
finally:
os.close(first_fd)
os.close(second_fd)
assert first is not None and first.access_token == "sk-ant-oat01-first"
assert second is not None and second.access_token == "sk-ant-oat01-second"
def test_load_claude_code_credential_survives_closed_file_descriptor(tmp_path, monkeypatch):
_clear_claude_code_env(monkeypatch)
monkeypatch.setenv("HOME", str(tmp_path))
read_fd = _pipe_with_secret(b"sk-ant-oat01-fd")
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", str(read_fd))
first = load_claude_code_credential()
# Closing a drained handoff must not strand the models built after it.
os.close(read_fd)
second = load_claude_code_credential()
assert first is not None and first.access_token == "sk-ant-oat01-fd"
assert second is not None and second.access_token == "sk-ant-oat01-fd"
def test_concurrent_loads_drain_file_descriptor_once(tmp_path, monkeypatch):
_clear_claude_code_env(monkeypatch)
monkeypatch.setenv("HOME", str(tmp_path))
read_fd = _pipe_with_secret(b"sk-ant-oat01-fd")
real_read = os.read
handoff_reads = []
def slow_read(fd, length):
if fd == read_fd:
handoff_reads.append(fd)
# Hold the first reader inside os.read so an unguarded second reader drains EOF.
time.sleep(0.1)
return real_read(fd, length)
monkeypatch.setattr(os, "read", slow_read)
barrier = threading.Barrier(2)
def load():
barrier.wait()
return load_claude_code_credential()
try:
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", str(read_fd))
with ThreadPoolExecutor(max_workers=2) as pool:
creds = list(pool.map(lambda _: load(), range(2)))
finally:
os.close(read_fd)
assert [cred.access_token if cred else None for cred in creds] == ["sk-ant-oat01-fd"] * 2
assert handoff_reads == [read_fd]
def test_claude_chat_model_instances_share_file_descriptor_token(tmp_path, monkeypatch):
_clear_claude_code_env(monkeypatch)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.setenv("HOME", str(tmp_path))
read_fd = _pipe_with_secret(b"sk-ant-oat01-fd")
try:
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", str(read_fd))
# Each run builds fresh models (lead agent, title, subagents) from the same handoff.
models = [ClaudeChatModel(model="claude-sonnet-4-6") for _ in range(2)]
finally:
os.close(read_fd)
for model in models:
assert model._is_oauth is True
assert model._client.api_key is None
assert model._client.auth_token == "sk-ant-oat01-fd"
def test_load_claude_code_credential_from_override_path(tmp_path, monkeypatch):
_clear_claude_code_env(monkeypatch)
cred_path = tmp_path / "claude-credentials.json"
cred_path.write_text(
json.dumps(
{
"claudeAiOauth": {
"accessToken": "sk-ant-oat01-test",
"refreshToken": "sk-ant-ort01-test",
"expiresAt": 4_102_444_800_000,
}
}
)
)
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(cred_path))
cred = load_claude_code_credential()
assert cred is not None
assert cred.access_token == "sk-ant-oat01-test"
assert cred.refresh_token == "sk-ant-ort01-test"
assert cred.source == "claude-cli-file"
def test_load_claude_code_credential_ignores_directory_path(tmp_path, monkeypatch):
_clear_claude_code_env(monkeypatch)
# Redirect HOME so the default ~/.claude/.credentials.json doesn't exist
monkeypatch.setenv("HOME", str(tmp_path))
cred_dir = tmp_path / "claude-creds-dir"
cred_dir.mkdir()
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(cred_dir))
assert load_claude_code_credential() is None
def test_load_claude_code_credential_falls_back_to_default_file_when_override_is_invalid(tmp_path, monkeypatch):
_clear_claude_code_env(monkeypatch)
monkeypatch.setenv("HOME", str(tmp_path))
cred_dir = tmp_path / "claude-creds-dir"
cred_dir.mkdir()
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(cred_dir))
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,
}
}
)
)
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(
json.dumps(
{
"tokens": {
"access_token": "codex-access-token",
"account_id": "acct_123",
}
}
)
)
monkeypatch.setenv("CODEX_AUTH_PATH", str(auth_path))
cred = load_codex_cli_credential()
assert cred is not None
assert cred.access_token == "codex-access-token"
assert cred.account_id == "acct_123"
assert cred.source == "codex-cli"
def test_load_codex_cli_credential_supports_legacy_top_level_shape(tmp_path, monkeypatch):
auth_path = tmp_path / "auth.json"
auth_path.write_text(json.dumps({"access_token": "legacy-access-token"}))
monkeypatch.setenv("CODEX_AUTH_PATH", str(auth_path))
cred = load_codex_cli_credential()
assert cred is not None
assert cred.access_token == "legacy-access-token"
assert cred.account_id == ""