Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,391 @@
|
||||
"""A rotation that was consumed but never committed must stay unusable.
|
||||
|
||||
``_refresh_oauth_token()`` already refuses to return an access token whose
|
||||
refresh half was lost to a failed write. That verdict was local: the caller
|
||||
above it (``resolve_anthropic_token()``) simply continued to the next source,
|
||||
and source 5 (``_resolve_anthropic_pool_token``) enumerates read-only
|
||||
(``clear_expired=False, refresh=False``) over a pool that ``load_pool()`` has
|
||||
just re-seeded from the *unchanged* singleton file. So the very pair whose
|
||||
refresh token the POST had already spent came back as a healthy token, and
|
||||
``_refresh_provider_credentials("anthropic")`` reported the refresh as a
|
||||
success and evicted its cached clients.
|
||||
|
||||
That is the same silent-transition failure the fail-closed path exists to
|
||||
prevent, one layer up: no ``invalid_grant`` is raised until the *next* refresh,
|
||||
by which point the provenance of the failure is gone.
|
||||
|
||||
These tests take the full resolver path, not just the writer: successful POST +
|
||||
failed commit must make ``resolve_anthropic_token()`` return ``None`` (or a
|
||||
genuinely independent credential), must make
|
||||
``_refresh_provider_credentials("anthropic")`` return ``False`` when the spent
|
||||
family is the only credential, and must keep the spent fingerprint out of every
|
||||
lease.
|
||||
|
||||
Companion: ``test_anthropic_credential_persist_failure.py`` covers the writers
|
||||
and the pool quarantine; this file covers what resolution does afterwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from agent import anthropic_credentials as AA
|
||||
from agent.auxiliary_client import _refresh_provider_credentials
|
||||
from agent.credential_pool import AUTH_TYPE_OAUTH, load_pool
|
||||
|
||||
_EXPIRED_MS = 1_000
|
||||
|
||||
_STALE_ACCESS = "sk-ant-oat01-spent-stale"
|
||||
_STALE_REFRESH = "sk-ant-ort01-spent-stale"
|
||||
_ROTATED_ACCESS = "sk-ant-oat01-spent-rotated"
|
||||
_ROTATED_REFRESH = "sk-ant-ort01-spent-rotated"
|
||||
_INDEPENDENT_ACCESS = "sk-ant-oat01-independent"
|
||||
_INDEPENDENT_REFRESH = "sk-ant-ort01-independent"
|
||||
|
||||
_SINGLETON_FILENAMES = frozenset({".credentials.json", ".anthropic_oauth.json"})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_spent_registry():
|
||||
"""The consumed-rotation registry is process-global; isolate each test."""
|
||||
AA._SPENT_ROTATION_FINGERPRINTS.clear()
|
||||
yield
|
||||
AA._SPENT_ROTATION_FINGERPRINTS.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
home = tmp_path / "hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
for var in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
(home / "auth.json").write_text(
|
||||
json.dumps({"version": 1, "providers": {}}), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.is_provider_explicitly_configured", lambda pid: True
|
||||
)
|
||||
return home
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claude_credentials(tmp_path, monkeypatch):
|
||||
cred_path = tmp_path / "claude" / ".credentials.json"
|
||||
cred_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cred_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": _STALE_ACCESS,
|
||||
"refreshToken": _STALE_REFRESH,
|
||||
"expiresAt": _EXPIRED_MS,
|
||||
"scopes": ["user:inference", "user:profile"],
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(AA, "claude_code_credentials_path", lambda: cred_path)
|
||||
monkeypatch.setattr(AA, "_read_claude_code_credentials_from_keychain", lambda: None)
|
||||
return cred_path
|
||||
|
||||
|
||||
def _rotating_refresh(*_a, **_kw):
|
||||
"""Stand-in for the token endpoint: the POST always succeeds and rotates."""
|
||||
return {
|
||||
"access_token": _ROTATED_ACCESS,
|
||||
"refresh_token": _ROTATED_REFRESH,
|
||||
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
|
||||
}
|
||||
|
||||
|
||||
def _break_durable_write(monkeypatch):
|
||||
"""Only the authoritative singletons fail; auth.json must stay writable."""
|
||||
real_replace = os.replace
|
||||
|
||||
def _failing_replace(src, dst):
|
||||
if os.path.basename(os.fspath(dst)) in _SINGLETON_FILENAMES:
|
||||
raise OSError(13, "Permission denied")
|
||||
return real_replace(src, dst)
|
||||
|
||||
monkeypatch.setattr(AA.os, "replace", _failing_replace)
|
||||
|
||||
|
||||
def _add_independent_pool_entry(home):
|
||||
"""Persist a second, unrelated Anthropic OAuth credential in the pool."""
|
||||
path = home / "auth.json"
|
||||
store = json.loads(path.read_text(encoding="utf-8"))
|
||||
pool = store.setdefault("credential_pool", {})
|
||||
pool.setdefault("anthropic", []).append(
|
||||
{
|
||||
"id": "anthropic-independent",
|
||||
"label": "second subscription",
|
||||
"auth_type": AUTH_TYPE_OAUTH,
|
||||
"priority": 10,
|
||||
"source": "manual",
|
||||
"access_token": _INDEPENDENT_ACCESS,
|
||||
"refresh_token": _INDEPENDENT_REFRESH,
|
||||
"expires_at_ms": int(time.time() * 1000) + 3_600_000,
|
||||
}
|
||||
)
|
||||
path.write_text(json.dumps(store), encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The registry itself
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_matches_only_the_recorded_secret():
|
||||
AA.mark_rotation_consumed_uncommitted(_STALE_REFRESH, "", None)
|
||||
|
||||
assert AA.is_rotation_consumed_uncommitted(_STALE_REFRESH)
|
||||
assert not AA.is_rotation_consumed_uncommitted(_INDEPENDENT_REFRESH)
|
||||
assert not AA.is_rotation_consumed_uncommitted("")
|
||||
assert not AA.is_rotation_consumed_uncommitted(None)
|
||||
|
||||
|
||||
def test_registry_stays_bounded():
|
||||
for i in range(AA._SPENT_ROTATION_MAX_TRACKED * 2):
|
||||
AA.mark_rotation_consumed_uncommitted(f"sk-ant-ort01-{i}")
|
||||
|
||||
assert len(AA._SPENT_ROTATION_FINGERPRINTS) == AA._SPENT_ROTATION_MAX_TRACKED
|
||||
assert AA.is_rotation_consumed_uncommitted(
|
||||
f"sk-ant-ort01-{AA._SPENT_ROTATION_MAX_TRACKED * 2 - 1}"
|
||||
), "the most recent rotation must survive eviction"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Full resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_failed_commit_marks_the_consumed_pair(
|
||||
hermes_home, claude_credentials, monkeypatch
|
||||
):
|
||||
"""The pre-rotation pair - the copy left on disk - is what gets recorded."""
|
||||
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
||||
_break_durable_write(monkeypatch)
|
||||
|
||||
assert AA._refresh_oauth_token(AA.read_claude_code_credentials()) is None
|
||||
|
||||
assert AA.is_rotation_consumed_uncommitted(_STALE_REFRESH)
|
||||
assert AA.is_rotation_consumed_uncommitted(_STALE_ACCESS)
|
||||
|
||||
|
||||
def test_resolve_returns_none_when_the_rotation_could_not_commit(
|
||||
hermes_home, claude_credentials, monkeypatch
|
||||
):
|
||||
"""Full resolver: source 5 must not hand back the pair source 4 refused.
|
||||
|
||||
``load_pool()`` re-seeds the claude_code row straight from the unchanged
|
||||
credentials file, so without the consumed-rotation verdict this returns the
|
||||
already-spent access token and the caller sees a success.
|
||||
"""
|
||||
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
||||
_break_durable_write(monkeypatch)
|
||||
|
||||
assert AA.resolve_anthropic_token() is None, (
|
||||
"a consumed-but-uncommitted rotation must not resolve to a usable token"
|
||||
)
|
||||
|
||||
|
||||
def test_spent_fingerprint_is_never_leased_from_the_pool(
|
||||
hermes_home, claude_credentials, monkeypatch
|
||||
):
|
||||
"""Direct witness on source 5 alone, after the rotation was spent."""
|
||||
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
||||
_break_durable_write(monkeypatch)
|
||||
|
||||
assert AA._refresh_oauth_token(AA.read_claude_code_credentials()) is None
|
||||
|
||||
# The pool still holds the pre-rotation pair: nothing rewrote the file.
|
||||
pool = load_pool("anthropic")
|
||||
seeded = next(e for e in pool._entries if e.source == "claude_code")
|
||||
assert seeded.access_token == _STALE_ACCESS
|
||||
|
||||
assert AA._resolve_anthropic_pool_token() is None, (
|
||||
"the spent credential must not be leased just because it is on disk"
|
||||
)
|
||||
|
||||
|
||||
def test_auxiliary_refresh_reports_failure_for_a_lost_commit(
|
||||
hermes_home, claude_credentials, monkeypatch
|
||||
):
|
||||
"""``_refresh_provider_credentials`` must fail when this is the only credential.
|
||||
|
||||
Returning True here evicts the cached clients and tells the retry loop the
|
||||
provider recovered, which is the point at which the failure stops being
|
||||
visible anywhere.
|
||||
"""
|
||||
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
||||
_break_durable_write(monkeypatch)
|
||||
|
||||
assert _refresh_provider_credentials("anthropic") is False
|
||||
|
||||
|
||||
def test_independent_pool_credential_stays_eligible(
|
||||
hermes_home, claude_credentials, monkeypatch
|
||||
):
|
||||
"""Failing closed is scoped to the spent family, not to Anthropic as a whole."""
|
||||
_add_independent_pool_entry(hermes_home)
|
||||
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
||||
_break_durable_write(monkeypatch)
|
||||
|
||||
resolved = AA.resolve_anthropic_token()
|
||||
|
||||
assert resolved == _INDEPENDENT_ACCESS, (
|
||||
"an unrelated credential must still be selectable after the quarantine"
|
||||
)
|
||||
|
||||
|
||||
def test_successful_commit_leaves_the_credential_usable(
|
||||
hermes_home, claude_credentials, monkeypatch
|
||||
):
|
||||
"""Control: nothing is quarantined when the commit actually lands."""
|
||||
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
||||
|
||||
assert AA.resolve_anthropic_token() == _ROTATED_ACCESS
|
||||
assert AA._SPENT_ROTATION_FINGERPRINTS == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-process durability of the verdict (sidecar registry)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_failed_commit_persists_the_verdict_to_the_sidecar(
|
||||
hermes_home, claude_credentials, monkeypatch
|
||||
):
|
||||
"""The verdict must outlive this process: it lands in the sidecar file."""
|
||||
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
||||
_break_durable_write(monkeypatch)
|
||||
|
||||
assert AA._refresh_oauth_token(AA.read_claude_code_credentials()) is None
|
||||
|
||||
sidecar = AA._spent_rotation_sidecar_path(claude_credentials)
|
||||
assert sidecar.exists(), "the terminal verdict must be durably persisted"
|
||||
payload = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
fingerprints = set(payload["fingerprints"])
|
||||
from agent.credential_persistence import fingerprint_secret_value
|
||||
|
||||
assert fingerprint_secret_value(_STALE_REFRESH) in fingerprints
|
||||
assert fingerprint_secret_value(_STALE_ACCESS) in fingerprints
|
||||
# Non-secret invariant: no raw token material may reach the sidecar.
|
||||
raw = sidecar.read_text(encoding="utf-8")
|
||||
for secret in (_STALE_ACCESS, _STALE_REFRESH, _ROTATED_ACCESS, _ROTATED_REFRESH):
|
||||
assert secret not in raw
|
||||
|
||||
|
||||
_SECOND_PROCESS_WITNESS = r"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
cred_path_str, sidecar_dir = sys.argv[1], sys.argv[2]
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import agent.anthropic_credentials as AA
|
||||
|
||||
cred_path = Path(cred_path_str)
|
||||
AA.claude_code_credentials_path = lambda: cred_path
|
||||
AA._read_claude_code_credentials_from_keychain = lambda *a, **k: None
|
||||
|
||||
posted = []
|
||||
|
||||
|
||||
def _must_not_post(refresh_token, *a, **kw):
|
||||
posted.append(refresh_token)
|
||||
raise AssertionError("process B replayed a spent refresh token")
|
||||
|
||||
|
||||
AA.refresh_anthropic_oauth_pure = _must_not_post
|
||||
|
||||
creds = AA.read_claude_code_credentials()
|
||||
result = {
|
||||
"registry_empty": len(AA._SPENT_ROTATION_FINGERPRINTS) == 0,
|
||||
"sidecar_verdict_access": AA.is_rotation_consumed_uncommitted(
|
||||
creds["accessToken"], source_path=cred_path
|
||||
),
|
||||
"sidecar_verdict_refresh": AA.is_rotation_consumed_uncommitted(
|
||||
creds["refreshToken"], source_path=cred_path
|
||||
),
|
||||
"resolved": AA._resolve_claude_code_token_from_credentials(creds),
|
||||
"posted": posted,
|
||||
}
|
||||
print(json.dumps(result))
|
||||
"""
|
||||
|
||||
|
||||
def test_second_process_adopts_the_terminal_verdict(
|
||||
hermes_home, claude_credentials, monkeypatch, tmp_path
|
||||
):
|
||||
"""Two-process witness: A rotates and loses the commit; B fails closed.
|
||||
|
||||
Process B runs in a fresh interpreter whose process-local registry is
|
||||
empty, sharing only the credential file (and its sidecar). B must neither
|
||||
lease the stale access token nor POST the spent refresh token — the exact
|
||||
cross-process gap the process-local OrderedDict could not cover.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Process A: successful POST, failed durable commit.
|
||||
monkeypatch.setattr(AA, "refresh_anthropic_oauth_pure", _rotating_refresh)
|
||||
_break_durable_write(monkeypatch)
|
||||
assert AA._refresh_oauth_token(AA.read_claude_code_credentials()) is None
|
||||
assert AA._spent_rotation_sidecar_path(claude_credentials).exists()
|
||||
|
||||
# Process B: fresh interpreter, same shared credential source.
|
||||
import agent as _agent_pkg
|
||||
|
||||
repo_root = str(
|
||||
__import__("pathlib").Path(_agent_pkg.__file__).resolve().parents[1]
|
||||
)
|
||||
env = dict(os.environ)
|
||||
env["HERMES_HOME"] = str(hermes_home)
|
||||
env["PYTHONPATH"] = repo_root
|
||||
for var in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
env.pop(var, None)
|
||||
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-c", _SECOND_PROCESS_WITNESS, str(claude_credentials), str(tmp_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
assert proc.returncode == 0, f"witness failed:\n{proc.stdout}\n{proc.stderr}"
|
||||
verdict = json.loads(proc.stdout.strip().splitlines()[-1])
|
||||
|
||||
assert verdict["registry_empty"], "precondition: B must start with no local verdict"
|
||||
assert verdict["sidecar_verdict_access"], "B must see A's verdict via the sidecar"
|
||||
assert verdict["sidecar_verdict_refresh"]
|
||||
assert verdict["resolved"] is None, "B must not lease the stale access token"
|
||||
assert verdict["posted"] == [], "B must not POST the spent refresh token"
|
||||
|
||||
|
||||
def test_control_second_process_without_sidecar_still_resolves(
|
||||
hermes_home, claude_credentials, monkeypatch
|
||||
):
|
||||
"""Independent-credential control: no verdict, no quarantine.
|
||||
|
||||
With no failed rotation recorded anywhere, the shared file's (valid)
|
||||
credential resolves normally in this process — proving the sidecar gate
|
||||
only fires on a recorded verdict, not on every read.
|
||||
"""
|
||||
fresh = dict(
|
||||
json.loads(claude_credentials.read_text(encoding="utf-8"))
|
||||
)
|
||||
fresh["claudeAiOauth"]["expiresAt"] = int(time.time() * 1000) + 3_600_000
|
||||
claude_credentials.write_text(json.dumps(fresh), encoding="utf-8")
|
||||
|
||||
assert not AA._spent_rotation_sidecar_path(claude_credentials).exists()
|
||||
creds = AA.read_claude_code_credentials()
|
||||
assert AA._resolve_claude_code_token_from_credentials(creds) == _STALE_ACCESS
|
||||
Reference in New Issue
Block a user