claude-dev-env 8.82.0 → 8.82.1
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/package.json +1 -1
- package/scripts/account_broker_support.py +5 -4
- package/scripts/claude_chain_usage.py +28 -2
- package/scripts/dev_env_scripts_constants/account_broker_constants.py +27 -1
- package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +6 -0
- package/scripts/test_account_broker_support.py +35 -3
- package/scripts/test_claude_chain_usage.py +26 -3
package/package.json
CHANGED
|
@@ -26,7 +26,7 @@ else:
|
|
|
26
26
|
import codex_account_choice
|
|
27
27
|
import codex_account_meters
|
|
28
28
|
from claude_account_profile import default_profile_home, validate_profile_name
|
|
29
|
-
from claude_chain_usage import WeeklyUtilizationProbeError, probe_account_meters
|
|
29
|
+
from claude_chain_usage import ClaudeSignedOutError, WeeklyUtilizationProbeError, probe_account_meters
|
|
30
30
|
from dev_env_scripts_constants.account_broker_constants import (
|
|
31
31
|
ALL_BATCH_FILE_EXTENSIONS,
|
|
32
32
|
ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS,
|
|
@@ -41,6 +41,7 @@ from dev_env_scripts_constants.account_broker_constants import (
|
|
|
41
41
|
CMD_SHELL_METACHARACTERS,
|
|
42
42
|
Decision,
|
|
43
43
|
JobOutcome,
|
|
44
|
+
ALL_LOGIN_REFRESH_ENVIRONMENT,
|
|
44
45
|
LOGIN_REFRESH_TIMEOUT_SECONDS,
|
|
45
46
|
METER_READ_ATTEMPTS,
|
|
46
47
|
Meters,
|
|
@@ -253,7 +254,7 @@ def refresh_claude_login(account_home: Path) -> bool:
|
|
|
253
254
|
claude_path = resolve_claude_path()
|
|
254
255
|
if claude_path is None:
|
|
255
256
|
return False
|
|
256
|
-
refresh_environment = {**os.environ, CLAUDE_CONFIG_DIR_ENV_VAR: str(account_home)}
|
|
257
|
+
refresh_environment = {**os.environ, **ALL_LOGIN_REFRESH_ENVIRONMENT, CLAUDE_CONFIG_DIR_ENV_VAR: str(account_home)}
|
|
257
258
|
try:
|
|
258
259
|
subprocess.run(
|
|
259
260
|
[str(claude_path), *ALL_LOGIN_REFRESH_ARGUMENTS],
|
|
@@ -325,7 +326,7 @@ def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], ac
|
|
|
325
326
|
Returns:
|
|
326
327
|
The meters from the first read that succeeds, or None with the last
|
|
327
328
|
failure text once every attempt failed. A missing Codex executable
|
|
328
|
-
fails at once, without a retry.
|
|
329
|
+
or a signed-out Claude account fails at once, without a retry.
|
|
329
330
|
"""
|
|
330
331
|
unread_reason = None
|
|
331
332
|
for each_attempt in range(METER_READ_ATTEMPTS):
|
|
@@ -333,7 +334,7 @@ def read_meters_with_backoff(read_meters: Callable[[Account], Meters | None], ac
|
|
|
333
334
|
time.sleep(meter_read_delay_seconds(each_attempt))
|
|
334
335
|
try:
|
|
335
336
|
return read_meters(account), None
|
|
336
|
-
except codex_account_meters.CodexExecutableNotFoundError as error:
|
|
337
|
+
except (codex_account_meters.CodexExecutableNotFoundError, ClaudeSignedOutError) as error:
|
|
337
338
|
return None, str(error)
|
|
338
339
|
except (
|
|
339
340
|
codex_account_meters.CodexMeterUnreadError,
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import http.client
|
|
6
6
|
import importlib.util
|
|
7
|
+
import json
|
|
7
8
|
import sys
|
|
8
9
|
import urllib.error
|
|
9
10
|
from dataclasses import dataclass
|
|
@@ -13,6 +14,8 @@ from types import ModuleType
|
|
|
13
14
|
from typing import Callable
|
|
14
15
|
|
|
15
16
|
from dev_env_scripts_constants.claude_chain_usage_constants import (
|
|
17
|
+
CREDENTIALS_OAUTH_SECTION_KEY,
|
|
18
|
+
CREDENTIALS_REFRESH_TOKEN_KEY,
|
|
16
19
|
NO_ACCESS_TOKEN_ERROR_TEMPLATE,
|
|
17
20
|
RESOLVE_USAGE_WINDOW_FILENAME,
|
|
18
21
|
RESOLVE_USAGE_WINDOW_MISSING_ERROR_TEMPLATE,
|
|
@@ -29,6 +32,10 @@ class WeeklyUtilizationProbeError(Exception):
|
|
|
29
32
|
"""Raised when an account's usage meters cannot be measured."""
|
|
30
33
|
|
|
31
34
|
|
|
35
|
+
class ClaudeSignedOutError(WeeklyUtilizationProbeError):
|
|
36
|
+
"""Raised when an account has no usable token and no way to get one, so a retry cannot help."""
|
|
37
|
+
|
|
38
|
+
|
|
32
39
|
def _usage_pause_scripts_directory(module_file: Path) -> Path:
|
|
33
40
|
"""Return the usage-pause scripts directory beside this scripts tree.
|
|
34
41
|
|
|
@@ -111,6 +118,24 @@ class AccountUsageMeters:
|
|
|
111
118
|
weekly_resets_at: datetime | None
|
|
112
119
|
|
|
113
120
|
|
|
121
|
+
def _holds_refresh_token(credentials_path: Path) -> bool:
|
|
122
|
+
"""Report whether a credential file holds a refresh token the CLI can renew a login from.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
credentials_path: The account's CLI credential file.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True when the file's OAuth section carries a non-empty refresh token.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
credentials_payload = json.loads(credentials_path.read_text(encoding="utf-8"))
|
|
132
|
+
except (OSError, ValueError):
|
|
133
|
+
return False
|
|
134
|
+
oauth_section = credentials_payload.get(CREDENTIALS_OAUTH_SECTION_KEY) if isinstance(credentials_payload, dict) else None
|
|
135
|
+
refresh_token = oauth_section.get(CREDENTIALS_REFRESH_TOKEN_KEY) if isinstance(oauth_section, dict) else None
|
|
136
|
+
return isinstance(refresh_token, str) and bool(refresh_token)
|
|
137
|
+
|
|
138
|
+
|
|
114
139
|
def _read_access_token(
|
|
115
140
|
usage_window_resolver: ModuleType,
|
|
116
141
|
credentials_path: Path,
|
|
@@ -123,6 +148,7 @@ def _read_access_token(
|
|
|
123
148
|
|
|
124
149
|
credential file holds a token -> that token
|
|
125
150
|
token expired, refresh_login saves a new one -> the new token
|
|
151
|
+
no token and no refresh token (signed out) -> no refresh_login call
|
|
126
152
|
no token, the session's own credential path -> the session ingress token
|
|
127
153
|
no token, another account's credential path -> None
|
|
128
154
|
|
|
@@ -142,7 +168,7 @@ def _read_access_token(
|
|
|
142
168
|
file_token = usage_window_resolver.read_oauth_access_token(credentials_path, now)
|
|
143
169
|
if file_token is not None:
|
|
144
170
|
return file_token
|
|
145
|
-
if refresh_login is not None and credentials_path
|
|
171
|
+
if refresh_login is not None and _holds_refresh_token(credentials_path):
|
|
146
172
|
refresh_login(credentials_path.parent)
|
|
147
173
|
refreshed_token = usage_window_resolver.read_oauth_access_token(credentials_path, datetime.now().astimezone())
|
|
148
174
|
if refreshed_token is not None:
|
|
@@ -162,7 +188,7 @@ def _fetch_account_usage_payload(
|
|
|
162
188
|
try:
|
|
163
189
|
access_token = _read_access_token(usage_window_resolver, credentials_path, now, refresh_login)
|
|
164
190
|
if access_token is None:
|
|
165
|
-
raise
|
|
191
|
+
raise ClaudeSignedOutError(
|
|
166
192
|
NO_ACCESS_TOKEN_ERROR_TEMPLATE.format(credentials_path=credentials_path)
|
|
167
193
|
)
|
|
168
194
|
return usage_window_resolver._fetch_usage_payload(access_token)
|
|
@@ -68,7 +68,33 @@ ALL_CLAUDE_BINARY_CANDIDATE_RELATIVE_PARTS: tuple[tuple[str, ...], ...] = (
|
|
|
68
68
|
(".local", "bin", "claude.exe"),
|
|
69
69
|
(".local", "bin", "claude"),
|
|
70
70
|
)
|
|
71
|
-
ALL_LOGIN_REFRESH_ARGUMENTS: tuple[str, ...] = (
|
|
71
|
+
ALL_LOGIN_REFRESH_ARGUMENTS: tuple[str, ...] = (
|
|
72
|
+
"-p",
|
|
73
|
+
"ok",
|
|
74
|
+
"--model",
|
|
75
|
+
"haiku",
|
|
76
|
+
"--setting-sources",
|
|
77
|
+
"local",
|
|
78
|
+
"--settings",
|
|
79
|
+
'{"enabledPlugins": {}, "autoMemoryEnabled": false}',
|
|
80
|
+
"--strict-mcp-config",
|
|
81
|
+
"--mcp-config",
|
|
82
|
+
'{"mcpServers": {}}',
|
|
83
|
+
"--no-session-persistence",
|
|
84
|
+
"--disable-slash-commands",
|
|
85
|
+
"--tools",
|
|
86
|
+
"",
|
|
87
|
+
"--max-turns",
|
|
88
|
+
"1",
|
|
89
|
+
)
|
|
90
|
+
ALL_LOGIN_REFRESH_ENVIRONMENT: dict[str, str] = {
|
|
91
|
+
"CLAUDE_CODE_DISABLE_CLAUDE_MDS": "1",
|
|
92
|
+
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
|
|
93
|
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
|
94
|
+
"DISABLE_TELEMETRY": "1",
|
|
95
|
+
"DISABLE_ERROR_REPORTING": "1",
|
|
96
|
+
"DISABLE_AUTOUPDATER": "1",
|
|
97
|
+
}
|
|
72
98
|
LOGIN_REFRESH_TIMEOUT_SECONDS = 120.0
|
|
73
99
|
ALL_PARENT_CLAUDE_SESSION_VARIABLES: frozenset[str] = frozenset({
|
|
74
100
|
"CLAUDE_CODE_SESSION_ID",
|
|
@@ -42,6 +42,12 @@ JSON_ERROR_KEY: str = "error"
|
|
|
42
42
|
CLI_CONFIG_PATH_FLAG: str = "--config-path"
|
|
43
43
|
"""CLI flag that overrides the chain configuration file path."""
|
|
44
44
|
|
|
45
|
+
CREDENTIALS_OAUTH_SECTION_KEY: str = "claudeAiOauth"
|
|
46
|
+
"""Credential file key holding the CLI's OAuth login."""
|
|
47
|
+
|
|
48
|
+
CREDENTIALS_REFRESH_TOKEN_KEY: str = "refreshToken"
|
|
49
|
+
"""OAuth section key holding the token the CLI renews a login from; empty when signed out."""
|
|
50
|
+
|
|
45
51
|
NO_ACCESS_TOKEN_ERROR_TEMPLATE: str = (
|
|
46
52
|
"no usable bearer token from the OAuth credential file at {credentials_path}"
|
|
47
53
|
)
|
|
@@ -348,6 +348,22 @@ def test_should_cap_the_meter_read_delay(monkeypatch: pytest.MonkeyPatch, tmp_pa
|
|
|
348
348
|
assert all_delays == [1.0, 2.0, 4.0, 8.0, 8.0]
|
|
349
349
|
|
|
350
350
|
|
|
351
|
+
def test_should_read_a_signed_out_claude_account_once(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
352
|
+
all_delays = _recorded_sleeps(monkeypatch)
|
|
353
|
+
all_reads: list[Account] = []
|
|
354
|
+
|
|
355
|
+
def signed_out_reader(account: Account) -> Meters:
|
|
356
|
+
all_reads.append(account)
|
|
357
|
+
raise support.ClaudeSignedOutError("no usable bearer token")
|
|
358
|
+
|
|
359
|
+
meters, unread_reason = support.read_meters_with_backoff(signed_out_reader, Account(Product.CLAUDE, "main", tmp_path))
|
|
360
|
+
|
|
361
|
+
assert meters is None
|
|
362
|
+
assert unread_reason == "no usable bearer token"
|
|
363
|
+
assert len(all_reads) == 1
|
|
364
|
+
assert all_delays == []
|
|
365
|
+
|
|
366
|
+
|
|
351
367
|
def test_should_cache_account_reading_under_injected_state_path(
|
|
352
368
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
353
369
|
) -> None:
|
|
@@ -604,19 +620,35 @@ def test_should_match_order_names_to_an_account_named_by_its_windows_launcher_pa
|
|
|
604
620
|
|
|
605
621
|
|
|
606
622
|
def test_should_run_the_cli_against_the_account_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
607
|
-
all_calls: list[tuple[list[str], str]] = []
|
|
623
|
+
all_calls: list[tuple[list[str], dict[str, str]]] = []
|
|
608
624
|
|
|
609
625
|
def fake_run(command: list[str], **keyword_arguments: object) -> subprocess.CompletedProcess[bytes]:
|
|
610
626
|
environment = keyword_arguments["env"]
|
|
611
627
|
assert isinstance(environment, dict)
|
|
612
|
-
all_calls.append((command, environment
|
|
628
|
+
all_calls.append((command, environment))
|
|
613
629
|
return subprocess.CompletedProcess(command, 0)
|
|
614
630
|
|
|
615
631
|
monkeypatch.setattr(support, "resolve_claude_path", lambda: Path("claude"))
|
|
616
632
|
monkeypatch.setattr(support.subprocess, "run", fake_run)
|
|
617
633
|
|
|
618
634
|
assert support.refresh_claude_login(tmp_path) is True
|
|
619
|
-
|
|
635
|
+
[(command, environment)] = all_calls
|
|
636
|
+
assert command[:5] == ["claude", "-p", "ok", "--model", "haiku"]
|
|
637
|
+
assert command[command.index("--setting-sources") + 1] == "local"
|
|
638
|
+
assert json.loads(command[command.index("--settings") + 1]) == {"enabledPlugins": {}, "autoMemoryEnabled": False}
|
|
639
|
+
assert "--strict-mcp-config" in command
|
|
640
|
+
assert json.loads(command[command.index("--mcp-config") + 1]) == {"mcpServers": {}}
|
|
641
|
+
assert "--no-session-persistence" in command
|
|
642
|
+
assert "--disable-slash-commands" in command
|
|
643
|
+
assert command[command.index("--tools") + 1] == ""
|
|
644
|
+
assert command[command.index("--max-turns") + 1] == "1"
|
|
645
|
+
assert environment["CLAUDE_CONFIG_DIR"] == str(tmp_path)
|
|
646
|
+
assert environment["CLAUDE_CODE_DISABLE_CLAUDE_MDS"] == "1"
|
|
647
|
+
assert environment["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] == "1"
|
|
648
|
+
assert environment["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] == "1"
|
|
649
|
+
assert environment["DISABLE_TELEMETRY"] == "1"
|
|
650
|
+
assert environment["DISABLE_ERROR_REPORTING"] == "1"
|
|
651
|
+
assert environment["DISABLE_AUTOUPDATER"] == "1"
|
|
620
652
|
|
|
621
653
|
|
|
622
654
|
def test_should_skip_the_refresh_without_a_cli(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import subprocess
|
|
6
7
|
import sys
|
|
7
8
|
import urllib.error
|
|
@@ -212,11 +213,18 @@ def _expiring_resolver(all_tokens: list[str | None], all_fetched_tokens: list[st
|
|
|
212
213
|
return Resolver()
|
|
213
214
|
|
|
214
215
|
|
|
216
|
+
def _write_credentials(credentials_path: Path, refresh_token: str) -> None:
|
|
217
|
+
credentials_path.write_text(
|
|
218
|
+
json.dumps({"claudeAiOauth": {"accessToken": "", "refreshToken": refresh_token, "expiresAt": 0}}),
|
|
219
|
+
encoding="utf-8",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
215
223
|
def test_should_refresh_an_expired_login_and_read_the_meter(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
216
224
|
account_home = tmp_path / "ev"
|
|
217
225
|
account_home.mkdir()
|
|
218
226
|
credentials_path = account_home / ".credentials.json"
|
|
219
|
-
credentials_path
|
|
227
|
+
_write_credentials(credentials_path, "refresh-value")
|
|
220
228
|
all_refreshed_homes: list[Path] = []
|
|
221
229
|
all_fetched_tokens: list[str] = []
|
|
222
230
|
monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None, "fresh-token"], all_fetched_tokens))
|
|
@@ -230,8 +238,23 @@ def test_should_refresh_an_expired_login_and_read_the_meter(monkeypatch: pytest.
|
|
|
230
238
|
|
|
231
239
|
def test_should_report_no_token_when_the_refresh_saves_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
232
240
|
credentials_path = tmp_path / ".credentials.json"
|
|
233
|
-
credentials_path
|
|
241
|
+
_write_credentials(credentials_path, "refresh-value")
|
|
234
242
|
monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None, None], []))
|
|
235
243
|
|
|
236
|
-
with pytest.raises(usage.
|
|
244
|
+
with pytest.raises(usage.ClaudeSignedOutError, match="bearer token"):
|
|
237
245
|
usage.probe_account_meters(credentials_path, lambda home: True)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pytest.mark.parametrize("credentials_text", ["{}", '{"claudeAiOauth": {"accessToken": "", "refreshToken": "", "expiresAt": 0}}', "not json"])
|
|
249
|
+
def test_should_skip_the_refresh_for_a_signed_out_account(
|
|
250
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, credentials_text: str
|
|
251
|
+
) -> None:
|
|
252
|
+
credentials_path = tmp_path / ".credentials.json"
|
|
253
|
+
credentials_path.write_text(credentials_text, encoding="utf-8")
|
|
254
|
+
all_refreshed_homes: list[Path] = []
|
|
255
|
+
monkeypatch.setattr(usage, "_load_resolve_usage_window_module", lambda: _expiring_resolver([None], []))
|
|
256
|
+
|
|
257
|
+
with pytest.raises(usage.ClaudeSignedOutError, match="bearer token"):
|
|
258
|
+
usage.probe_account_meters(credentials_path, all_refreshed_homes.append)
|
|
259
|
+
|
|
260
|
+
assert all_refreshed_homes == []
|