From 9f8a59da806d0a11bef6936d76cdef4847e8b4b2 Mon Sep 17 00:00:00 2001 From: Guo-Yixin Date: Mon, 5 Oct 2026 07:21:31 -0500 Subject: [PATCH] fix(models): skip undecodable Claude descriptor handoffs (#6323) --- README.md | 1 + .../harness/deerflow/models/AGENTS.md | 2 +- .../deerflow/models/credential_loader.py | 2 +- backend/tests/test_claude_fd_encoding.py | 104 ++++++++++++++++++ 4 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_claude_fd_encoding.py diff --git a/README.md b/README.md index facb188499..7aac7a9bf7 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index c42a3391c2..3a7af77160 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -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`) diff --git a/backend/packages/harness/deerflow/models/credential_loader.py b/backend/packages/harness/deerflow/models/credential_loader.py index 649e0afcb2..094e421c23 100644 --- a/backend/packages/harness/deerflow/models/credential_loader.py +++ b/backend/packages/harness/deerflow/models/credential_loader.py @@ -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 diff --git a/backend/tests/test_claude_fd_encoding.py b/backend/tests/test_claude_fd_encoding.py new file mode 100644 index 0000000000..fa34fc959c --- /dev/null +++ b/backend/tests/test_claude_fd_encoding.py @@ -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