fix(models): skip undecodable Claude descriptor handoffs (#6323)

This commit is contained in:
Guo-Yixin 2026-10-05 07:21:31 -05:00 committed by GitHub
parent 7abb973f72
commit 9f8a59da80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 107 additions and 2 deletions

View File

@ -311,6 +311,7 @@ For Google's official Gemini OpenAI-compatible endpoint, use the
- Codex CLI reads `~/.codex/auth.json`
- The Codex model provider returns completed responses without waiting for the SSE connection to close. Failed or incomplete responses report the provider's error or reason; partial output is not returned as a successful answer. Non-object error details are reported as text.
- Claude Code accepts `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`, `CLAUDE_CODE_CREDENTIALS_PATH`, or `~/.claude/.credentials.json`
- `CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` accepts a UTF-8 token handoff and reuses it for later model instances in the same process. Undecodable handoffs are skipped so Claude Code can still try its override or default credentials file.
- CLI credential JSON files accept UTF-8 with or without a BOM, independently of the host locale. `make doctor` accepts the same files when checking CLI authentication. Invalid text encoding is treated as an unreadable source; Claude Code can still try its default file after an invalid override.
- ACP agent entries are separate from model providers — if you configure `acp_agents.codex`, point it at a Codex ACP adapter such as `npx -y @zed-industries/codex-acp`
- Each ACP agent's `timeout_seconds` (default: 1800) is one shared budget for initialization, session creation, and the prompt, starting after the subprocess launches. On timeout, DeerFlow aborts the invocation and closes the subprocess before returning an error. Workspace/MCP preparation and subprocess cleanup are outside this budget. A `TimeoutError` raised by the SDK before this deadline expires retains its own error message.

View File

@ -90,7 +90,7 @@ Offline HTTP-stream coverage: `tests/test_codex_stream_terminal_events.py`.
### Claude Code Credentials (`packages/harness/deerflow/models/credential_loader.py`)
- `ClaudeChatModel.model_post_init` calls `load_claude_code_credential()` for every instance, and `create_chat_model` builds fresh instances per run (lead agent, title, summarization, subagents)
- `$CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` is a one-shot handoff: a pipe returns EOF and a file keeps its advanced offset. `_read_secret_from_file_descriptor` therefore caches a non-empty secret per `(env_var, fd)` under a lock held across the read. Do not drop the cache or the lock — later instances would get no credential, and the Anthropic SDK raises `TypeError: Could not resolve authentication method` before sending. Empty reads and `OSError` are not cached. The key is the descriptor number on purpose — a closed handoff keeps serving its token, and a secret placed on a recycled number in-process is not re-read unless the cache is cleared. The cache is per process, so a new process (e.g. a uvicorn `--reload` worker) cannot recover a drained descriptor. Pinned by `tests/test_credential_loader.py`, including a two-instance `ClaudeChatModel` test
- `$CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR` is a one-shot handoff: a pipe returns EOF and a file keeps its advanced offset. `_read_secret_from_file_descriptor` therefore caches a non-empty secret per `(env_var, fd)` under a lock held across the read. Do not drop the cache or the lock — later instances would get no credential, and the Anthropic SDK raises `TypeError: Could not resolve authentication method` before sending. Empty reads, `OSError`, and UTF-8 decode failures are not cached; unreadable handoffs return `None` so the loader can try credential files. Warnings must not include token contents. The key is the descriptor number on purpose — a closed handoff keeps serving its token, and a secret placed on a recycled number in-process is not re-read unless the cache is cleared. The cache is per process, so a new process (e.g. a uvicorn `--reload` worker) cannot recover a drained descriptor. Pinned by `tests/test_credential_loader.py` and `tests/test_claude_fd_encoding.py`, including two-instance `ClaudeChatModel` tests
### Claude Prompt Caching (`packages/harness/deerflow/models/claude_provider.py`)

View File

@ -117,7 +117,7 @@ def _read_secret_from_file_descriptor(env_var: str) -> str | None:
try:
secret = os.read(fd, 1024 * 1024).decode().strip()
except OSError as e:
except (OSError, UnicodeError) as e:
logger.warning(f"Failed to read {env_var}: {e}")
return None

View File

@ -0,0 +1,104 @@
import json
import logging
import os
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
import pytest
from deerflow.models import credential_loader
from deerflow.models.claude_provider import ClaudeChatModel
@pytest.fixture(autouse=True)
def _isolate_credentials(tmp_path, monkeypatch):
for name in (
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
"CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR",
"CLAUDE_CODE_CREDENTIALS_PATH",
):
monkeypatch.delenv(name, raising=False)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setattr(credential_loader, "_fd_secret_cache", {})
@contextmanager
def _handoff(payload: bytes, monkeypatch) -> Iterator[int]:
read_fd, write_fd = os.pipe()
try:
try:
os.write(write_fd, payload)
finally:
os.close(write_fd)
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR", str(read_fd))
yield read_fd
finally:
os.close(read_fd)
def _credential_file(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"claudeAiOauth": {"accessToken": "sk-ant-oat01-file-fallback"}}), encoding="utf-8")
return path
@pytest.mark.parametrize(
"payload",
[b"sk-ant-oat01-invalid-byte\xff", b"\xff\xfe" + "sk-ant-oat01-utf16".encode("utf-16-le"), b"sk-ant-oat01-truncated\xe2\x82"],
ids=["invalid-byte", "utf16", "truncated-utf8"],
)
@pytest.mark.parametrize("fallback", ["none", "override", "default"])
def test_undecodable_descriptor_skips_to_file_credentials(tmp_path, monkeypatch, caplog, payload, fallback):
if fallback == "override":
monkeypatch.setenv("CLAUDE_CODE_CREDENTIALS_PATH", str(_credential_file(tmp_path / "override.json")))
elif fallback == "default":
_credential_file(tmp_path / ".claude" / ".credentials.json")
with caplog.at_level(logging.WARNING, logger=credential_loader.__name__), _handoff(payload, monkeypatch) as read_fd:
# A failed handoff must not strand either the first model or later loads.
credentials = [credential_loader.load_claude_code_credential() for _ in range(2)]
os.fstat(read_fd) # The loader does not own or close the descriptor.
if fallback == "none":
assert credentials == [None, None]
else:
assert all(cred is not None and cred.access_token == "sk-ant-oat01-file-fallback" and cred.source == "claude-cli-file" for cred in credentials)
assert credential_loader._fd_secret_cache == {}
assert "Failed to read CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR" in caplog.text
assert "sk-ant-oat01" not in caplog.text
def test_claude_models_use_file_fallback_after_undecodable_descriptor(tmp_path, monkeypatch):
_credential_file(tmp_path / ".claude" / ".credentials.json")
with _handoff(b"sk-ant-oat01-model-handoff\xff", monkeypatch):
models = [ClaudeChatModel(model="claude-sonnet-4-6") for _ in range(2)]
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-file-fallback"
def test_valid_descriptor_still_wins_over_file_and_reuses_cached_token(tmp_path, monkeypatch):
_credential_file(tmp_path / ".claude" / ".credentials.json")
with _handoff(b" sk-ant-oat01-valid-handoff\n", monkeypatch):
credentials = [credential_loader.load_claude_code_credential() for _ in range(2)]
assert all(cred is not None and cred.access_token == "sk-ant-oat01-valid-handoff" and cred.source == "claude-cli-fd" for cred in credentials)
def test_direct_environment_token_precedes_undecodable_descriptor(monkeypatch, caplog):
monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat01-direct")
with _handoff(b"sk-ant-oat01-bad-handoff\xff", monkeypatch) as read_fd:
cred = credential_loader.load_claude_code_credential()
assert os.read(read_fd, 1024) == b"sk-ant-oat01-bad-handoff\xff"
assert cred is not None and cred.access_token == "sk-ant-oat01-direct" and cred.source == "claude-cli-env"
assert "Failed to read" not in caplog.text