claude-multiacc 2.0.64 → 2.0.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/claude-accounts +15 -201
- package/bin/codex +25 -7
- package/bin/codex-accounts +16 -3
- package/docs/AUTORESUME.md +10 -1
- package/docs/CODEX.md +39 -0
- package/lib/__pycache__/audit.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-314.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-314.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-314.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-314.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_mcp_oauth.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_resume_cwd.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_speed.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-314.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-314.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-314.pyc +0 -0
- package/lib/__pycache__/mcp_mirror.cpython-314.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-314.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-314.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-314.pyc +0 -0
- package/lib/__pycache__/report.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-314.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-314.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-314.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-314.pyc +0 -0
- package/lib/autoresume.py +28 -8
- package/lib/claude_oauth_lock.py +172 -0
- package/lib/claude_oauth_refresh.py +225 -0
- package/lib/codex_speed.py +351 -0
- package/lib/codex_speed.sh +39 -0
- package/lib/common.sh +13 -3
- package/lib/model_match.py +4 -1
- package/lib/session_settings.py +25 -6
- package/lib/shim_path.py +6 -0
- package/package.json +4 -3
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_reader.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_claude_oauth_lock.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_exec_stdin.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_mcp_oauth.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror_args.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_oauth.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_mcp_registry.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-314.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-314.pyc +0 -0
- package/tests/claude_oauth_fixture.py +100 -0
- package/tests/run-tests.sh +23 -7
- package/tests/test_claude_oauth_lock.py +138 -0
- package/tests/test_claude_oauth_refresh.py +106 -0
- package/tests/test_claude_oauth_refresh_failures.py +112 -0
- package/tests/test_codex_speed.py +236 -0
- package/tests/test_mutate_lock.py +107 -0
- package/tests/test_shim_path.py +44 -4
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_dispatch.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_handoff.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_probe.cpython-312.pyc +0 -0
- package/lib/__pycache__/autoresume_receipt.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_client_error.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_reset.cpython-312.pyc +0 -0
- package/lib/__pycache__/claude_token_identity.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_autoresume_mode.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_config_edit.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_controls.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_local_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credit_selection.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_limits_lock.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_python.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_resume_cwd.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_trust.cpython-312.pyc +0 -0
- package/lib/__pycache__/codex_usage_fetch.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_mirror.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_oauth.cpython-312.pyc +0 -0
- package/lib/__pycache__/mcp_registry.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_catalog.cpython-312.pyc +0 -0
- package/lib/__pycache__/model_match.cpython-312.pyc +0 -0
- package/lib/__pycache__/report.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credit_scope.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_credits.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_funding.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/__pycache__/session_settings.cpython-312.pyc +0 -0
- package/lib/__pycache__/shim_path.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_receipt_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_environment.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_cross_unit_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/autoresume_tmux_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/packaged_command_support.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_catalog_cli.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_records.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_codex_trust.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_claude_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_guards.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_holders.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_origin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_policy.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tmux_races.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cross_tokens.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cursor.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_handoff.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_reader.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_cwd_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_fallback.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_argv.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_history.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_handoff_origins.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_memory_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_mode.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_probe.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_receipt.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_relaunch_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_settings.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_sidechain_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_tmux_modes.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_autoresume_trust_tmux.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_claude_token_identity.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_exec_stdin.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_mcp_oauth.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_credits_report.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_fetch.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_polling.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_reporting.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_codex_reset_windows.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_mirror_args.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_oauth.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_mcp_registry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_cached_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_scope.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credit_shim.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_credits.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_selector_spread.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_ceremony.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_integration.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_retry.cpython-312.pyc +0 -0
- package/tests/__pycache__/test_token_identity_sync.cpython-312.pyc +0 -0
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Temporary Claude OAuth stores and a stub provider; never load a real account."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from unittest import mock
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
17
|
+
sys.path.insert(0, str(REPO / "lib"))
|
|
18
|
+
import claude_oauth_refresh # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def file_store_class():
|
|
22
|
+
"""Load the real file store only; never execute limits or access the real pool."""
|
|
23
|
+
source = (REPO / "bin/claude-accounts").read_text(encoding="utf-8")
|
|
24
|
+
source = source.split("cmd_limits() {", 1)[1].split("<<'PYEOF'", 1)[1]
|
|
25
|
+
source = source.split("\n", 1)[1].split("\nPYEOF", 1)[0]
|
|
26
|
+
nodes = [node for node in ast.parse(source).body
|
|
27
|
+
if isinstance(node, ast.ClassDef) and node.name == "FileStore"]
|
|
28
|
+
namespace = {"json": json, "os": os}
|
|
29
|
+
exec(compile(ast.Module(body=nodes, type_ignores=[]), "limits-test-definitions", "exec"), namespace)
|
|
30
|
+
return namespace["FileStore"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AcquireCallbackLock:
|
|
34
|
+
"""Make a competing login finish exactly when our lock is acquired."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, callback):
|
|
37
|
+
self.callback = callback
|
|
38
|
+
self.compromised = False
|
|
39
|
+
self.released = False
|
|
40
|
+
|
|
41
|
+
def acquire(self):
|
|
42
|
+
self.callback()
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def release(self):
|
|
46
|
+
self.released = True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class OAuthRefreshFixture:
|
|
50
|
+
def setUp(self):
|
|
51
|
+
self.temp = tempfile.TemporaryDirectory(prefix="multiacc-oauth-refresh-")
|
|
52
|
+
self.addCleanup(self.temp.cleanup)
|
|
53
|
+
self.account = Path(self.temp.name) / "acct-test"
|
|
54
|
+
self.account.mkdir()
|
|
55
|
+
self.path = self.account / ".credentials.json"
|
|
56
|
+
self.now = time.time()
|
|
57
|
+
self.original = {"claudeAiOauth": {
|
|
58
|
+
"accessToken": "fixture-old-access", "refreshToken": "fixture-old-refresh",
|
|
59
|
+
"expiresAt": int((self.now - 3600) * 1000),
|
|
60
|
+
"refreshTokenExpiresAt": int((self.now + 86400) * 1000),
|
|
61
|
+
"scopes": ["user:inference", "user:profile"]},
|
|
62
|
+
"mcpOAuth": {"fixture-server": {"accessToken": "fixture-mcp-original"}}}
|
|
63
|
+
self.write(self.original)
|
|
64
|
+
self.store = file_store_class()(str(self.path))
|
|
65
|
+
self.posts = []
|
|
66
|
+
self.messages = []
|
|
67
|
+
self.park = mock.Mock()
|
|
68
|
+
self.on_post = lambda: None
|
|
69
|
+
self.lock = None
|
|
70
|
+
self.error = None
|
|
71
|
+
self.reply = {"access_token": "fixture-new-access", "refresh_token": "fixture-new-refresh",
|
|
72
|
+
"expires_in": 3600, "refresh_token_expires_in": 2592000}
|
|
73
|
+
patcher = mock.patch.object(urllib.request, "urlopen", side_effect=self.provider)
|
|
74
|
+
patcher.start()
|
|
75
|
+
self.addCleanup(patcher.stop)
|
|
76
|
+
|
|
77
|
+
def write(self, document):
|
|
78
|
+
self.path.write_text(json.dumps(document), encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
def read(self):
|
|
81
|
+
return json.loads(self.path.read_text(encoding="utf-8"))
|
|
82
|
+
|
|
83
|
+
def provider(self, request, **_kwargs):
|
|
84
|
+
self.assertEqual(request.full_url, "https://provider.invalid/token")
|
|
85
|
+
self.posts.append(json.loads(request.data))
|
|
86
|
+
self.on_post()
|
|
87
|
+
if self.error is not None:
|
|
88
|
+
raise self.error
|
|
89
|
+
return io.BytesIO(json.dumps(self.reply).encode())
|
|
90
|
+
|
|
91
|
+
def refresh(self):
|
|
92
|
+
options = {"now": self.now, "force": True, "token_url": "https://provider.invalid/token",
|
|
93
|
+
"client_id": "fixture-client", "say": self.messages.append,
|
|
94
|
+
"park_dead_grant": self.park, "clear_expired": lambda *_args: False}
|
|
95
|
+
if self.lock is not None:
|
|
96
|
+
patcher = mock.patch.object(claude_oauth_refresh, "RefreshLock", return_value=self.lock)
|
|
97
|
+
patcher.start()
|
|
98
|
+
self.addCleanup(patcher.stop)
|
|
99
|
+
refresher = claude_oauth_refresh.OAuthRefresher(**options)
|
|
100
|
+
return refresher.refresh("acct-test", str(self.account), self.store)
|
package/tests/run-tests.sh
CHANGED
|
@@ -2632,16 +2632,27 @@ regB="$(claude-accounts list 2>&1 | grep par2@test | awk '{print $1}')"
|
|
|
2632
2632
|
[ -n "$regB" ] && claude-accounts remove "$regB" --yes >/dev/null 2>&1
|
|
2633
2633
|
|
|
2634
2634
|
# ---- 15h. two parallel adds signing into the SAME email: exactly one wins ------------
|
|
2635
|
+
if python3 "$REPO_DIR/tests/test_mutate_lock.py"; then
|
|
2636
|
+
t_ok "mutation lock: missing-stat race preserves the replacement owner"
|
|
2637
|
+
else
|
|
2638
|
+
t_fail "mutation lock ownership suite" "see unittest output above"
|
|
2639
|
+
fi
|
|
2635
2640
|
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=same@test claude-accounts add >"$WORK/s1.out" 2>&1 ) &
|
|
2636
2641
|
sA=$!
|
|
2637
2642
|
( CLAUDE_MULTIACC_FORCE_TTY=1 FAKE_EMAIL=same@test claude-accounts add >"$WORK/s2.out" 2>&1 ) &
|
|
2638
2643
|
sB=$!
|
|
2639
|
-
|
|
2644
|
+
sArc=0; sBrc=0
|
|
2645
|
+
wait "$sA" || sArc=$?
|
|
2646
|
+
wait "$sB" || sBrc=$?
|
|
2647
|
+
both="$(cat "$WORK/s1.out" "$WORK/s2.out" 2>/dev/null)"
|
|
2648
|
+
[ "$sArc:$sBrc" = "0:0" ] && t_ok "same-email parallel add: both callers succeed" \
|
|
2649
|
+
|| t_fail "same-email parallel add exit status" "first=$sArc second=$sBrc; $both"
|
|
2640
2650
|
n="$(claude-accounts list 2>&1 | grep -c same@test)"
|
|
2641
2651
|
[ "$n" = "1" ] && t_ok "same-email parallel add: exactly one registered (other skipped)" \
|
|
2642
2652
|
|| t_fail "same-email parallel add" "count=$n (expected 1)"
|
|
2643
|
-
|
|
2644
|
-
&& t_ok "same-email parallel add: loser skipped gracefully"
|
|
2653
|
+
grep -qE "already added as|skipping" "$WORK/s1.out" "$WORK/s2.out" \
|
|
2654
|
+
&& t_ok "same-email parallel add: loser skipped gracefully" \
|
|
2655
|
+
|| t_fail "same-email skip msg" "first=$sArc second=$sBrc; no skip message in: $both"
|
|
2645
2656
|
regS="$(claude-accounts list 2>&1 | grep same@test | awk '{print $1}')"
|
|
2646
2657
|
[ -n "$regS" ] && claude-accounts remove "$regS" --yes >/dev/null 2>&1
|
|
2647
2658
|
|
|
@@ -7797,18 +7808,23 @@ fi
|
|
|
7797
7808
|
|
|
7798
7809
|
# two parallel imports get distinct ids and both land in the manifest (lock coverage;
|
|
7799
7810
|
# codex-review finding)
|
|
7800
|
-
codex-accounts import p1@cx --no-sync
|
|
7811
|
+
codex-accounts import p1@cx --no-sync >"$WORK/cx-import1.out" 2>&1 &
|
|
7801
7812
|
imp1=$!
|
|
7802
|
-
codex-accounts import p2@cx --no-sync
|
|
7813
|
+
codex-accounts import p2@cx --no-sync >"$WORK/cx-import2.out" 2>&1 &
|
|
7803
7814
|
imp2=$!
|
|
7804
|
-
|
|
7815
|
+
imp1rc=0; imp2rc=0
|
|
7816
|
+
wait "$imp1" || imp1rc=$?
|
|
7817
|
+
wait "$imp2" || imp2rc=$?
|
|
7818
|
+
both="$(cat "$WORK/cx-import1.out" "$WORK/cx-import2.out" 2>/dev/null)"
|
|
7819
|
+
[ "$imp1rc:$imp2rc" = "0:0" ] && t_ok "codex: both parallel import callers succeed" \
|
|
7820
|
+
|| t_fail "codex parallel import exit status" "first=$imp1rc second=$imp2rc; $both"
|
|
7805
7821
|
n1="$(grep -c '"email": "p1@cx"' "$CX/accounts.json")"
|
|
7806
7822
|
n2="$(grep -c '"email": "p2@cx"' "$CX/accounts.json")"
|
|
7807
7823
|
id1="$(python3 -c "import json,sys; print(next((a['id'] for a in json.load(open('$CX/accounts.json'))['accounts'] if a['email']=='p1@cx'), ''))")"
|
|
7808
7824
|
id2="$(python3 -c "import json,sys; print(next((a['id'] for a in json.load(open('$CX/accounts.json'))['accounts'] if a['email']=='p2@cx'), ''))")"
|
|
7809
7825
|
{ [ "$n1" = "1" ] && [ "$n2" = "1" ] && [ -n "$id1" ] && [ -n "$id2" ] && [ "$id1" != "$id2" ]; } \
|
|
7810
7826
|
&& t_ok "codex: parallel imports get distinct ids ($id1, $id2)" \
|
|
7811
|
-
|| t_fail "codex parallel import" "p1=$n1($id1) p2=$n2($id2)"
|
|
7827
|
+
|| t_fail "codex parallel import" "p1=$n1($id1) p2=$n2($id2); first=$imp1rc second=$imp2rc; $both"
|
|
7812
7828
|
codex-accounts remove "$id1" --yes >/dev/null 2>&1
|
|
7813
7829
|
codex-accounts remove "$id2" --yes >/dev/null 2>&1
|
|
7814
7830
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Native-shaped OAuth locks in temporary directories; no credentials or network."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest import mock
|
|
13
|
+
|
|
14
|
+
sys.dont_write_bytecode = True
|
|
15
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'lib'))
|
|
16
|
+
import claude_oauth_lock as locks
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RefreshLockTests(unittest.TestCase):
|
|
20
|
+
def setUp(self):
|
|
21
|
+
temporary = tempfile.TemporaryDirectory(prefix='claude-oauth-lock-test-')
|
|
22
|
+
self.addCleanup(temporary.cleanup)
|
|
23
|
+
self.root = Path(temporary.name)
|
|
24
|
+
self.account = self.root / 'acct-02'
|
|
25
|
+
self.account.mkdir()
|
|
26
|
+
self.lock = locks.RefreshLock(self.account, timeout=0.1)
|
|
27
|
+
self.addCleanup(self.lock.release)
|
|
28
|
+
|
|
29
|
+
def test_holds_native_and_legacy_without_storage_lock(self):
|
|
30
|
+
self.assertTrue(self.lock.acquire())
|
|
31
|
+
self.assertEqual(self.lock.paths, (self.account / '.oauth_refresh.lock',
|
|
32
|
+
Path(str(self.account.resolve()) + '.lock')))
|
|
33
|
+
self.assertTrue(all(path.is_dir() for path in self.lock.paths))
|
|
34
|
+
self.assertFalse((self.account / '.storage-write.lock').exists())
|
|
35
|
+
self.assertFalse(self.lock.compromised)
|
|
36
|
+
self.lock.release()
|
|
37
|
+
self.assertTrue(all(not path.exists() for path in self.lock.paths))
|
|
38
|
+
|
|
39
|
+
def test_either_native_lock_blocks_and_partial_acquisition_is_released(self):
|
|
40
|
+
for blocked in self.lock.paths:
|
|
41
|
+
with self.subTest(path=blocked.name):
|
|
42
|
+
blocked.mkdir()
|
|
43
|
+
identity = blocked.stat().st_ino
|
|
44
|
+
self.assertFalse(self.lock.acquire())
|
|
45
|
+
self.assertEqual(blocked.stat().st_ino, identity)
|
|
46
|
+
self.assertFalse(next(path for path in self.lock.paths if path != blocked).exists())
|
|
47
|
+
blocked.rmdir()
|
|
48
|
+
|
|
49
|
+
def test_stale_empty_native_locks_are_recovered(self):
|
|
50
|
+
for path in self.lock.paths:
|
|
51
|
+
path.mkdir()
|
|
52
|
+
old = time.time() - locks.STALE_SECONDS - 10
|
|
53
|
+
os.utime(path, (old, old))
|
|
54
|
+
self.lock.timeout = 0
|
|
55
|
+
self.assertTrue(self.lock.acquire())
|
|
56
|
+
self.assertFalse(self.lock.compromised)
|
|
57
|
+
|
|
58
|
+
def test_symlinks_and_nonempty_stale_locks_are_preserved(self):
|
|
59
|
+
target = self.root / 'unrelated'
|
|
60
|
+
target.mkdir()
|
|
61
|
+
for path in self.lock.paths:
|
|
62
|
+
with self.subTest(path=path.name):
|
|
63
|
+
path.symlink_to(target, target_is_directory=True)
|
|
64
|
+
self.assertFalse(self.lock.acquire())
|
|
65
|
+
self.assertTrue(path.is_symlink())
|
|
66
|
+
path.unlink()
|
|
67
|
+
path = self.lock.paths[0]
|
|
68
|
+
path.mkdir()
|
|
69
|
+
(path / 'foreign-file').write_text('preserve')
|
|
70
|
+
old = time.time() - locks.STALE_SECONDS - 10
|
|
71
|
+
os.utime(path, (old, old))
|
|
72
|
+
self.assertFalse(self.lock.acquire())
|
|
73
|
+
self.assertEqual((path / 'foreign-file').read_text(), 'preserve')
|
|
74
|
+
|
|
75
|
+
def test_replaced_directory_is_not_released(self):
|
|
76
|
+
self.assertTrue(self.lock.acquire())
|
|
77
|
+
current = self.lock.paths[0]
|
|
78
|
+
previous = self.account / 'previous-lock'
|
|
79
|
+
current.rename(previous)
|
|
80
|
+
current.mkdir()
|
|
81
|
+
identity = current.stat().st_ino
|
|
82
|
+
self.assertTrue(self.lock.compromised)
|
|
83
|
+
self.lock.release()
|
|
84
|
+
self.assertEqual(current.stat().st_ino, identity)
|
|
85
|
+
self.assertTrue(previous.is_dir())
|
|
86
|
+
self.assertFalse(self.lock.paths[1].exists())
|
|
87
|
+
|
|
88
|
+
def test_changed_timestamp_is_not_released_or_renewed(self):
|
|
89
|
+
self.assertTrue(self.lock.acquire())
|
|
90
|
+
current = self.lock.paths[0]
|
|
91
|
+
changed = current.stat().st_mtime_ns + 1_000_000
|
|
92
|
+
os.utime(current, ns=(changed, changed))
|
|
93
|
+
self.assertTrue(self.lock.compromised)
|
|
94
|
+
self.assertFalse(self.lock._renew())
|
|
95
|
+
self.lock.release()
|
|
96
|
+
self.assertEqual(current.stat().st_mtime_ns, changed)
|
|
97
|
+
|
|
98
|
+
def test_heartbeat_updates_both_locks_and_stops_on_release(self):
|
|
99
|
+
renewed = threading.Event()
|
|
100
|
+
original = self.lock._renew
|
|
101
|
+
with mock.patch.object(locks, 'UPDATE_SECONDS', 0.01), mock.patch.object(
|
|
102
|
+
self.lock, '_renew', side_effect=lambda: (original(), renewed.set())[0]
|
|
103
|
+
):
|
|
104
|
+
self.assertTrue(self.lock.acquire())
|
|
105
|
+
before = [path.stat().st_mtime_ns for path in self.lock.paths]
|
|
106
|
+
self.assertTrue(renewed.wait(1))
|
|
107
|
+
self.assertTrue(all(path.stat().st_mtime_ns > stamp
|
|
108
|
+
for path, stamp in zip(self.lock.paths, before)))
|
|
109
|
+
self.assertFalse(self.lock.compromised)
|
|
110
|
+
thread = self.lock._thread
|
|
111
|
+
self.lock.release()
|
|
112
|
+
self.assertFalse(thread.is_alive())
|
|
113
|
+
|
|
114
|
+
def test_symlinked_account_uses_resolved_legacy_lock(self):
|
|
115
|
+
alias = self.root / 'alias'
|
|
116
|
+
alias.symlink_to(self.account, target_is_directory=True)
|
|
117
|
+
peer = locks.RefreshLock(alias, timeout=0)
|
|
118
|
+
self.addCleanup(peer.release)
|
|
119
|
+
self.assertEqual(peer.paths[1], self.lock.paths[1])
|
|
120
|
+
self.assertTrue(self.lock.acquire())
|
|
121
|
+
self.assertFalse(peer.acquire())
|
|
122
|
+
self.assertTrue(alias.is_symlink())
|
|
123
|
+
|
|
124
|
+
def test_second_helper_can_acquire_only_after_release(self):
|
|
125
|
+
peer = locks.RefreshLock(self.account, timeout=0)
|
|
126
|
+
self.addCleanup(peer.release)
|
|
127
|
+
self.assertTrue(self.lock.acquire())
|
|
128
|
+
self.assertFalse(peer.acquire())
|
|
129
|
+
self.lock.release()
|
|
130
|
+
self.assertTrue(peer.acquire())
|
|
131
|
+
peer.release()
|
|
132
|
+
with mock.patch.object(locks.os, 'open', side_effect=PermissionError):
|
|
133
|
+
self.assertFalse(peer.acquire())
|
|
134
|
+
self.assertTrue(all(not path.exists() for path in peer.paths))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == '__main__':
|
|
138
|
+
unittest.main()
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic OAuth refresh races: temporary credentials and a stub provider only."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import copy
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
from claude_oauth_fixture import AcquireCallbackLock, OAuthRefreshFixture
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClaudeOAuthRefreshTest(OAuthRefreshFixture, unittest.TestCase):
|
|
14
|
+
|
|
15
|
+
def test_native_refresh_lock_blocks_token_post(self):
|
|
16
|
+
for path in (self.account / ".oauth_refresh.lock", Path(str(self.account.resolve()) + ".lock")):
|
|
17
|
+
with self.subTest(lock=path.name):
|
|
18
|
+
self.write(self.original)
|
|
19
|
+
self.posts.clear()
|
|
20
|
+
path.mkdir()
|
|
21
|
+
try:
|
|
22
|
+
self.refresh()
|
|
23
|
+
self.assertEqual(self.posts, [], "a native refresh lock must prevent rotating the grant")
|
|
24
|
+
self.assertEqual(self.read(), self.original)
|
|
25
|
+
finally:
|
|
26
|
+
path.rmdir()
|
|
27
|
+
|
|
28
|
+
def test_rereads_login_after_acquiring_refresh_lock(self):
|
|
29
|
+
newer = copy.deepcopy(self.original)
|
|
30
|
+
newer["claudeAiOauth"].update(accessToken="fixture-native-access",
|
|
31
|
+
refreshToken="fixture-native-refresh",
|
|
32
|
+
expiresAt=int((self.now + 3600) * 1000))
|
|
33
|
+
self.lock = AcquireCallbackLock(lambda: self.write(newer))
|
|
34
|
+
result = self.refresh()
|
|
35
|
+
self.assertEqual(self.posts, [], "a refresh completed during lock acquisition must be adopted")
|
|
36
|
+
self.assertEqual(result, "fixture-native-access")
|
|
37
|
+
self.assertEqual(self.read(), newer)
|
|
38
|
+
self.assertTrue(self.lock.released)
|
|
39
|
+
|
|
40
|
+
def test_success_survives_native_invalid_grant_tombstone(self):
|
|
41
|
+
tombstone = copy.deepcopy(self.original)
|
|
42
|
+
tombstone["claudeAiOauth"].update(accessToken="", refreshToken="", expiresAt=0)
|
|
43
|
+
self.on_post = lambda: self.write(tombstone)
|
|
44
|
+
self.assertEqual(self.refresh(), "fixture-new-access")
|
|
45
|
+
self.assertEqual(len(self.posts), 1)
|
|
46
|
+
self.assertEqual(self.read()["claudeAiOauth"]["refreshToken"], "fixture-new-refresh")
|
|
47
|
+
|
|
48
|
+
def test_nonempty_newer_grant_is_preserved(self):
|
|
49
|
+
newer = copy.deepcopy(self.original)
|
|
50
|
+
newer["claudeAiOauth"].update(accessToken="fixture-other-access",
|
|
51
|
+
refreshToken="fixture-other-refresh")
|
|
52
|
+
self.on_post = lambda: self.write(newer)
|
|
53
|
+
self.refresh()
|
|
54
|
+
self.assertEqual(len(self.posts), 1)
|
|
55
|
+
self.assertEqual(self.read(), newer)
|
|
56
|
+
|
|
57
|
+
def test_partial_clearing_is_not_mistaken_for_native_tombstone(self):
|
|
58
|
+
for fields in ({"accessToken": "fixture-other-access", "refreshToken": "", "expiresAt": 0},
|
|
59
|
+
{"accessToken": "", "refreshToken": "", "expiresAt": 1000},
|
|
60
|
+
{"refreshToken": ""}):
|
|
61
|
+
with self.subTest(fields=fields):
|
|
62
|
+
self.write(self.original)
|
|
63
|
+
changed = {"claudeAiOauth": fields}
|
|
64
|
+
self.on_post = lambda document=changed: self.write(document)
|
|
65
|
+
self.assertIsNone(self.refresh())
|
|
66
|
+
self.assertEqual(self.read(), changed)
|
|
67
|
+
|
|
68
|
+
def test_deleted_document_is_not_resurrected_after_logout(self):
|
|
69
|
+
self.on_post = self.path.unlink
|
|
70
|
+
self.assertIsNone(self.refresh())
|
|
71
|
+
self.assertEqual(len(self.posts), 1)
|
|
72
|
+
self.assertFalse(self.path.exists(), "an in-flight refresh must not undo logout")
|
|
73
|
+
|
|
74
|
+
def test_removed_login_is_not_restored_into_mcp_document(self):
|
|
75
|
+
remaining = {"mcpOAuth": {"fixture-server": {"accessToken": "fixture-mcp-only"}}}
|
|
76
|
+
self.on_post = lambda: self.write(remaining)
|
|
77
|
+
self.assertIsNone(self.refresh())
|
|
78
|
+
self.assertEqual(self.read(), remaining)
|
|
79
|
+
|
|
80
|
+
def test_concurrent_mcp_changes_survive_successful_refresh(self):
|
|
81
|
+
concurrent = copy.deepcopy(self.original)
|
|
82
|
+
concurrent["mcpOAuth"]["fixture-server"]["accessToken"] = "fixture-mcp-updated"
|
|
83
|
+
concurrent["mcpOAuthClientConfig"] = {"fixture-server": {"clientId": "fixture-client-id"}}
|
|
84
|
+
self.on_post = lambda: self.write(concurrent)
|
|
85
|
+
self.assertEqual(self.refresh(), "fixture-new-access")
|
|
86
|
+
saved = self.read()
|
|
87
|
+
self.assertEqual(saved["mcpOAuth"], concurrent["mcpOAuth"])
|
|
88
|
+
self.assertEqual(saved["mcpOAuthClientConfig"], concurrent["mcpOAuthClientConfig"])
|
|
89
|
+
self.assertEqual(saved["claudeAiOauth"]["refreshToken"], "fixture-new-refresh")
|
|
90
|
+
|
|
91
|
+
def test_unknown_refresh_expiry_still_attempts_refresh(self):
|
|
92
|
+
document = copy.deepcopy(self.original)
|
|
93
|
+
del document["claudeAiOauth"]["refreshTokenExpiresAt"]
|
|
94
|
+
self.write(document)
|
|
95
|
+
self.assertEqual(self.refresh(), "fixture-new-access")
|
|
96
|
+
self.assertEqual(len(self.posts), 1)
|
|
97
|
+
|
|
98
|
+
def test_omitted_refresh_expiry_preserves_native_expiry_semantics(self):
|
|
99
|
+
del self.reply["refresh_token_expires_in"]
|
|
100
|
+
self.assertEqual(self.refresh(), "fixture-new-access")
|
|
101
|
+
self.assertEqual(self.read()["claudeAiOauth"]["refreshTokenExpiresAt"],
|
|
102
|
+
self.original["claudeAiOauth"]["refreshTokenExpiresAt"])
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
unittest.main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""A failed refresh belongs only to its posted grant, never a newer sign-in."""
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest import mock
|
|
10
|
+
from urllib.error import HTTPError
|
|
11
|
+
|
|
12
|
+
from claude_oauth_fixture import AcquireCallbackLock, OAuthRefreshFixture, claude_oauth_refresh
|
|
13
|
+
|
|
14
|
+
PRIVATE_FIXTURE = "fixture-private-provider-detail"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def invalid_grant():
|
|
18
|
+
body = json.dumps({"error": "invalid_grant", "detail": PRIVATE_FIXTURE}).encode()
|
|
19
|
+
return HTTPError("https://provider.invalid/token", 400, PRIVATE_FIXTURE, {}, io.BytesIO(body))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClaudeOAuthRefreshFailureTest(OAuthRefreshFixture, unittest.TestCase):
|
|
23
|
+
def assert_locks_released(self):
|
|
24
|
+
for path in (self.account / ".oauth_refresh.lock", Path(str(self.account.resolve()) + ".lock"),
|
|
25
|
+
self.account / ".storage-write.lock"):
|
|
26
|
+
self.assertFalse(path.exists(), f"refresh left its lock behind: {path.name}")
|
|
27
|
+
|
|
28
|
+
def test_changed_login_is_not_parked_after_old_grant_denial(self):
|
|
29
|
+
newer = copy.deepcopy(self.original)
|
|
30
|
+
newer["claudeAiOauth"].update(accessToken="fixture-new-login-access",
|
|
31
|
+
refreshToken="fixture-new-login-refresh",
|
|
32
|
+
expiresAt=int((self.now + 3600) * 1000))
|
|
33
|
+
self.on_post = lambda: self.write(newer)
|
|
34
|
+
self.error = invalid_grant()
|
|
35
|
+
self.assertIsNone(self.refresh())
|
|
36
|
+
self.park.assert_not_called()
|
|
37
|
+
self.assertFalse((self.account / ".oauth-refresh.json").exists())
|
|
38
|
+
self.assertEqual(self.read(), newer)
|
|
39
|
+
self.assert_locks_released()
|
|
40
|
+
|
|
41
|
+
def test_logout_during_denial_is_not_recreated_or_parked(self):
|
|
42
|
+
self.on_post = self.path.unlink
|
|
43
|
+
self.error = invalid_grant()
|
|
44
|
+
self.assertIsNone(self.refresh())
|
|
45
|
+
self.park.assert_not_called()
|
|
46
|
+
self.assertFalse((self.account / ".oauth-refresh.json").exists())
|
|
47
|
+
self.assertFalse(self.path.exists())
|
|
48
|
+
self.assert_locks_released()
|
|
49
|
+
|
|
50
|
+
def test_matching_invalid_grant_parks_backs_off_and_releases_locks(self):
|
|
51
|
+
self.error = invalid_grant()
|
|
52
|
+
self.assertIsNone(self.refresh())
|
|
53
|
+
self.park.assert_called_once()
|
|
54
|
+
self.assertEqual(self.park.call_args.args[2], "refresh-denied-http-400")
|
|
55
|
+
backoff = (self.account / ".oauth-refresh.json").read_text(encoding="utf-8")
|
|
56
|
+
state = json.loads(backoff)
|
|
57
|
+
self.assertEqual(state["denials"], 1)
|
|
58
|
+
self.assertEqual(state["retry_after"], int(self.now + 21600))
|
|
59
|
+
self.assertEqual(self.read(), self.original)
|
|
60
|
+
recorded = "\n".join(self.messages) + backoff + str(self.park.call_args)
|
|
61
|
+
for secret in (PRIVATE_FIXTURE, "fixture-old-access", "fixture-old-refresh"):
|
|
62
|
+
self.assertNotIn(secret, recorded)
|
|
63
|
+
self.assert_locks_released()
|
|
64
|
+
|
|
65
|
+
def test_signin_during_denial_write_lock_acquisition_is_not_parked(self):
|
|
66
|
+
newer = copy.deepcopy(self.original)
|
|
67
|
+
newer["claudeAiOauth"]["refreshToken"] = "fixture-new-login-refresh"
|
|
68
|
+
storage_lock = AcquireCallbackLock(lambda: self.write(newer))
|
|
69
|
+
self.error = invalid_grant()
|
|
70
|
+
with mock.patch.object(claude_oauth_refresh, "ClientLock", return_value=storage_lock):
|
|
71
|
+
self.assertIsNone(self.refresh())
|
|
72
|
+
self.park.assert_not_called()
|
|
73
|
+
self.assertFalse((self.account / ".oauth-refresh.json").exists())
|
|
74
|
+
self.assertEqual(self.read(), newer)
|
|
75
|
+
self.assertTrue(storage_lock.released)
|
|
76
|
+
self.assert_locks_released()
|
|
77
|
+
|
|
78
|
+
def test_transient_failure_after_changed_login_leaves_state_unchanged(self):
|
|
79
|
+
newer = copy.deepcopy(self.original)
|
|
80
|
+
newer["claudeAiOauth"]["refreshToken"] = "fixture-new-login-refresh"
|
|
81
|
+
self.on_post = lambda: self.write(newer)
|
|
82
|
+
self.error = RuntimeError(PRIVATE_FIXTURE)
|
|
83
|
+
self.assertIsNone(self.refresh())
|
|
84
|
+
self.park.assert_not_called()
|
|
85
|
+
self.assertFalse((self.account / ".oauth-refresh.json").exists())
|
|
86
|
+
self.assertEqual(self.read(), newer)
|
|
87
|
+
self.assertNotIn(PRIVATE_FIXTURE, "\n".join(self.messages))
|
|
88
|
+
self.assert_locks_released()
|
|
89
|
+
|
|
90
|
+
def test_transient_failure_never_logs_provider_secret(self):
|
|
91
|
+
self.error = RuntimeError(PRIVATE_FIXTURE)
|
|
92
|
+
self.assertIsNone(self.refresh())
|
|
93
|
+
self.park.assert_not_called()
|
|
94
|
+
backoff = (self.account / ".oauth-refresh.json").read_text(encoding="utf-8")
|
|
95
|
+
self.assertEqual(json.loads(backoff)["error"], "RuntimeError")
|
|
96
|
+
self.assertNotIn(PRIVATE_FIXTURE, "\n".join(self.messages) + backoff)
|
|
97
|
+
self.assert_locks_released()
|
|
98
|
+
|
|
99
|
+
def test_malformed_success_after_changed_login_does_not_back_off(self):
|
|
100
|
+
newer = copy.deepcopy(self.original)
|
|
101
|
+
newer["claudeAiOauth"]["refreshToken"] = "fixture-new-login-refresh"
|
|
102
|
+
self.on_post = lambda: self.write(newer)
|
|
103
|
+
self.reply = {}
|
|
104
|
+
self.assertIsNone(self.refresh())
|
|
105
|
+
self.park.assert_not_called()
|
|
106
|
+
self.assertFalse((self.account / ".oauth-refresh.json").exists())
|
|
107
|
+
self.assertEqual(self.read(), newer)
|
|
108
|
+
self.assert_locks_released()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
unittest.main()
|