anthropics / anthropics/anthropic-sdk-python

Windows: os.fchmod in credential write-back bricks the OAuth refresh token

Đang mở
#1,868 1 bình luận 0 reaction 0 người được giao Xem trên GitHub
Ngôn ngữ chính
Python
Star
3.9k
Fork
853
Merge trung bình
1 ngày 18 giờ
Pull request đã merge (30 ngày)
11

Mô tả

**SDK:** `anthropic` 0.116.0 · **Python:** 3.10.11 · **OS:** Windows 11 (26200)

## Summary

`CredentialsFile._atomic_write_credentials` calls `os.fchmod(fd, 0o600)` as the first statement of its write (`anthropic/lib/credentials/_providers.py:419`). `os.fchmod` is POSIX-only and does not exist on Windows, so **every** credential write-back raises `AttributeError` before any bytes are written.

On its own that would be a portability bug. What makes it destructive is the ordering in `_call_user_oauth`: the `refresh_token` grant is POSTed and **succeeds** first — the server rotates the refresh token and returns a new pair — and only then does the write-back crash and discard it. The token left on disk is the one the server has just invalidated.

The practical result on Windows is that **any `user_oauth` profile is permanently bricked by its own first token refresh.** Every subsequent call fails with `400 invalid_grant: Refresh token not found or invalid`, and the only recovery is an interactive `ant auth login` — which buys roughly one access-token lifetime (~8h) before the same thing happens again. From the user's side this presents as "I have to sign in again every day", with no indication that the SDK is the cause.

Note that the directory-`fsync` twenty lines below the offending call *is* guarded, with the comment "Windows and some POSIX flavours don't support directory fds". The `fchmod` above it is not.

## Reproduction (no credentials required)

```python
import json, os, pathlib, tempfile

sandbox = pathlib.Path(tempfile.mkdtemp())
os.environ["ANTHROPIC_CONFIG_DIR"] = str(sandbox)
(sandbox / "configs").mkdir()
(sandbox / "credentials").mkdir()
(sandbox / "configs" / "default.json").write_text(json.dumps(
{"version": "1.0", "authentication": {"type": "user_oauth", "client_id": "x"}}))
creds = sandbox / "credentials" / "default.json"
creds.write_text(json.dumps({"version": "1.0", "refresh_token": "x"}))

from anthropic.lib.credentials import CredentialsFile

p = CredentialsFile()
p._config = json.loads((sandbox / "configs" / "default.json").read_text())
p._credentials_path = creds
p._atomic_write_credentials({"refresh_token": "new"})
print("wrote OK:", json.loads(creds.read_text()))
```

Actual, on Windows:

```
File "...\anthropic\lib\credentials\_providers.py", line 419, in _atomic_write_credentials
os.fchmod(fd, 0o600)
AttributeError: module 'os' has no attribute 'fchmod'. Did you mean: 'chmod'?
```

Expected: `wrote OK: {'refresh_token': 'new'}`.

## Reproduction of the destructive behaviour (real profile)

1. `ant auth login` — note the mtime of `%APPDATA%\Anthropic\credentials\default.json`.
2. Force one refresh: `CredentialsFile()(force_refresh=True)` → raises the `AttributeError` above.
3. The credentials file mtime has **not** changed.
4. POST the still-stored `refresh_token` to `/v1/oauth/token` → `400 invalid_grant: Refresh token not found or invalid`.

Observed here: a credential minted at 09:14:13 was dead by 09:15, from a single refresh, with one consumer and no concurrency.

## Suggested fix

Guard the call the same way the directory-`fsync` below it is guarded:

```python
if hasattr(os, "fchmod"):
os.fchmod(fd, 0o600)
```

A no-op is the correct behaviour on Windows — the call exists to keep the credentials file at `0600`, and POSIX permission bits have no meaning on NTFS. If restricting access on Windows is wanted, that needs an ACL (e.g. `icacls`), not a mode.

Two adjacent points, take or leave:

- **Consider persisting before the old token is spent, or treating write-back failure as fatal to the refresh.** Any exception between the successful POST and the completed write silently destroys the credential. Today that is `fchmod`; tomorrow it could be a full disk or a locked file.
- **`_atomic_write_credentials` has no read-modify-write lock.** `mkstemp` + `os.replace` is atomic per writer, but two processes can each load refresh token A, both present it, and the loser is poisoned permanently. The docstring at `:412` anticipates concurrent writers and calls last-write-wins "fine for a best-effort cache" — which holds for a cache, but a rotating single-use refresh token is not one. Not what bit us, but reachable.

## Workaround for other Windows users

Install a no-op before constructing any client:

```python
import os
if not hasattr(os, "fchmod"):
os.fchmod = lambda fd, mode: None
```

Verified: with this in place, two consecutive forced refreshes both rotate and persist, the file mtime advances, and inference succeeds on the surviving credential.

Hướng dẫn đóng góp

Mở hướng dẫn đóng góp

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.