claude-dev-env 8.37.3 → 8.38.0

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.
@@ -0,0 +1,501 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import io
5
+ import subprocess
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ import account_broker
12
+ from account_broker import (
13
+ Account,
14
+ Meters,
15
+ Product,
16
+ ProductAdapter,
17
+ Reading,
18
+ choose_from_readings,
19
+ run_job,
20
+ )
21
+ from dev_env_scripts_constants.account_broker_constants import WAIT_EXIT_CODE
22
+
23
+
24
+ NOW = datetime(2026, 10, 3, tzinfo=timezone.utc)
25
+
26
+
27
+ @pytest.fixture(autouse=True)
28
+ def isolated_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
29
+ monkeypatch.setattr(account_broker, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
30
+ monkeypatch.setattr(account_broker.support, "broker_state_path", lambda: tmp_path / "broker" / "state.json")
31
+
32
+
33
+ def _account(name: str, product: Product = Product.CODEX, main: bool = False) -> Account:
34
+ return Account(product, name, Path("/profiles") / name, main, name)
35
+
36
+
37
+ def _meters(
38
+ short_left: float | None,
39
+ weekly_left: float | None,
40
+ *,
41
+ short_reset: datetime | None = None,
42
+ weekly_reset: datetime | None = None,
43
+ ) -> Meters:
44
+ return Meters(
45
+ short_left,
46
+ short_reset or NOW + timedelta(hours=5),
47
+ weekly_left,
48
+ weekly_reset or NOW + timedelta(days=3),
49
+ )
50
+
51
+
52
+ def _adapter(accounts: tuple[Account, ...], meters: dict[str, Meters | None]) -> ProductAdapter:
53
+ return ProductAdapter(
54
+ load_accounts=lambda: accounts,
55
+ read_meters=lambda account: meters[account.name],
56
+ environment_variable="CODEX_HOME",
57
+ usage_limit_signatures=("rate limit",),
58
+ main_guard=False,
59
+ )
60
+
61
+
62
+ def test_should_rank_by_room_in_the_tighter_window() -> None:
63
+ readings = (
64
+ Reading(_account("first"), _meters(12, 80)),
65
+ Reading(_account("second"), _meters(70, 40)),
66
+ )
67
+
68
+ decision = choose_from_readings(Product.CODEX, readings, now=NOW)
69
+
70
+ assert decision.account == readings[1].account
71
+ assert decision.tier == "normal"
72
+
73
+
74
+ def test_should_put_unread_accounts_after_readable_accounts() -> None:
75
+ readings = (
76
+ Reading(_account("unread", Product.CLAUDE), None),
77
+ Reading(_account("readable", Product.CLAUDE), _meters(20, 20)),
78
+ )
79
+
80
+ decision = choose_from_readings(Product.CLAUDE, readings, now=NOW)
81
+
82
+ assert decision.account == readings[1].account
83
+
84
+
85
+ def test_should_keep_list_order_when_tighter_windows_tie() -> None:
86
+ readings = (
87
+ Reading(_account("first"), _meters(40, 60)),
88
+ Reading(_account("second"), _meters(70, 40)),
89
+ )
90
+
91
+ decision = choose_from_readings(Product.CODEX, readings, now=NOW)
92
+
93
+ assert decision.account == readings[0].account
94
+
95
+
96
+ @pytest.mark.parametrize(
97
+ ("weekly_reset", "weekly_left", "short_left"),
98
+ (
99
+ (NOW + timedelta(days=3), 20, 80),
100
+ (NOW + timedelta(hours=4), 10, 80),
101
+ (NOW + timedelta(hours=4), 20, 50),
102
+ ),
103
+ )
104
+ def test_should_guard_main_at_each_limit(
105
+ weekly_reset: datetime, weekly_left: float, short_left: float
106
+ ) -> None:
107
+ main = Reading(
108
+ _account("main", Product.CLAUDE, main=True),
109
+ _meters(short_left, weekly_left, weekly_reset=weekly_reset),
110
+ )
111
+ extra = Reading(_account("extra", Product.CLAUDE), _meters(20, 20))
112
+
113
+ decision = choose_from_readings(Product.CLAUDE, (main, extra), now=NOW)
114
+
115
+ assert decision.account == extra.account
116
+
117
+
118
+ def test_should_prioritize_main_when_its_guard_passes() -> None:
119
+ main = Reading(
120
+ _account("main", Product.CLAUDE, main=True),
121
+ _meters(80, 20, weekly_reset=NOW + timedelta(hours=4)),
122
+ )
123
+ extra = Reading(_account("extra", Product.CLAUDE), _meters(90, 90))
124
+
125
+ decision = choose_from_readings(Product.CLAUDE, (main, extra), now=NOW)
126
+
127
+ assert decision.account == main.account
128
+
129
+
130
+ def test_should_try_next_account_after_usage_limit(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
131
+ accounts = (_account("first"), _account("second"))
132
+ adapter = _adapter(accounts, {"first": _meters(80, 80), "second": _meters(70, 70)})
133
+ attempted: list[str] = []
134
+
135
+ def runner(command: object, **options: object) -> subprocess.CompletedProcess[str]:
136
+ selected = Path(options["env"]["CODEX_HOME"]).name
137
+ attempted.append(selected)
138
+ if selected == "first":
139
+ return subprocess.CompletedProcess(command, 1, "", "rate limit")
140
+ return subprocess.CompletedProcess(command, 0, "served", "")
141
+
142
+ report_path = tmp_path / "report.json"
143
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, adapter)
144
+ with account_broker.override_subprocess_runner(runner):
145
+ outcome, report = account_broker._execute(
146
+ Product.CODEX,
147
+ ("job",),
148
+ now=NOW,
149
+ )
150
+ account_broker._write_report(report_path, report)
151
+
152
+ report = json.loads(report_path.read_text(encoding="utf-8"))
153
+ assert outcome.returncode == 0
154
+ assert attempted == ["first", "second"]
155
+ assert [event["type"] for event in report["events"]] == ["pick", "attempt", "pick", "attempt"]
156
+ assert report["final_decision"]["account"] == "second"
157
+
158
+
159
+ def test_should_wait_for_soonest_reset_and_exit_three(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
160
+ accounts = (_account("first"), _account("second"))
161
+ first_reset = NOW + timedelta(hours=5)
162
+ second_reset = NOW + timedelta(hours=3)
163
+ adapter = _adapter(
164
+ accounts,
165
+ {
166
+ "first": _meters(0, 0, short_reset=first_reset, weekly_reset=first_reset),
167
+ "second": _meters(0, 0, short_reset=second_reset, weekly_reset=second_reset),
168
+ },
169
+ )
170
+
171
+ report_path = tmp_path / "report.json"
172
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, adapter)
173
+ with account_broker.override_subprocess_runner(lambda command, **options: pytest.fail("a waiting job ran")):
174
+ outcome, report = account_broker._execute(
175
+ Product.CODEX,
176
+ ("job",),
177
+ now=NOW,
178
+ )
179
+ account_broker._write_report(report_path, report)
180
+
181
+ report = json.loads(report_path.read_text(encoding="utf-8"))
182
+ assert outcome.returncode == WAIT_EXIT_CODE
183
+ assert report["final_decision"]["action"] == "wait"
184
+ assert report["final_decision"]["resets_at"] == second_reset.isoformat()
185
+
186
+
187
+ def test_should_wait_until_both_blocking_windows_reset() -> None:
188
+ short_reset = NOW + timedelta(hours=2)
189
+ weekly_reset = NOW + timedelta(days=2)
190
+ reading = Reading(
191
+ _account("spent"),
192
+ _meters(0, 0, short_reset=short_reset, weekly_reset=weekly_reset),
193
+ )
194
+
195
+ decision = choose_from_readings(Product.CODEX, (reading,), now=NOW)
196
+
197
+ assert decision.resets_at == weekly_reset
198
+
199
+
200
+ def test_should_use_claude_extra_floors_and_wait_for_unread() -> None:
201
+ blocked = Reading(
202
+ _account("blocked", Product.CLAUDE), _meters(10, 5)
203
+ )
204
+ unread = Reading(_account("unread", Product.CLAUDE), None)
205
+
206
+ decision = choose_from_readings(Product.CLAUDE, (blocked, unread), now=NOW)
207
+
208
+ assert decision.action == "wait"
209
+ assert decision.account is None
210
+
211
+
212
+ @pytest.mark.parametrize("count", (1, 5))
213
+ def test_should_consider_every_account_in_the_roster(count: int) -> None:
214
+ readings = tuple(
215
+ Reading(_account(f"account_{index}"), _meters(30 + index, 30 + index))
216
+ for index in range(count)
217
+ )
218
+
219
+ decision = choose_from_readings(Product.CODEX, readings, now=NOW)
220
+
221
+ assert decision.account == readings[-1].account
222
+
223
+
224
+ def test_should_use_luna_floor_without_choosing_a_model() -> None:
225
+ reading = Reading(_account("low"), _meters(30, 8))
226
+
227
+ decision = choose_from_readings(Product.CODEX, (reading,), now=NOW)
228
+
229
+ assert decision.account == reading.account
230
+ assert decision.tier == "luna"
231
+ assert not hasattr(decision, "model")
232
+
233
+
234
+ def test_should_keep_resume_affinity_when_account_has_room() -> None:
235
+ readings = (
236
+ Reading(_account("roomy", Product.CLAUDE), _meters(80, 80)),
237
+ Reading(_account("origin", Product.CLAUDE), _meters(20, 20)),
238
+ )
239
+
240
+ decision = choose_from_readings(
241
+ Product.CLAUDE, readings, now=NOW, preferred_command="origin"
242
+ )
243
+
244
+ assert decision.account == readings[1].account
245
+
246
+
247
+ def test_should_exit_three_for_check_while_all_accounts_wait(monkeypatch: pytest.MonkeyPatch) -> None:
248
+ account = _account("spent")
249
+ monkeypatch.setitem(
250
+ account_broker.all_product_adapters,
251
+ Product.CODEX,
252
+ _adapter((account,), {"spent": _meters(0, 0)}),
253
+ )
254
+
255
+ assert account_broker.main(("check", "--product", "codex")) == WAIT_EXIT_CODE
256
+
257
+
258
+ def test_should_print_decision_and_meters_for_choose(
259
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
260
+ ) -> None:
261
+ account = _account("roomy")
262
+ monkeypatch.setitem(
263
+ account_broker.all_product_adapters,
264
+ Product.CODEX,
265
+ _adapter((account,), {"roomy": _meters(80, 70)}),
266
+ )
267
+
268
+ assert account_broker.main(("choose", "--product", "codex")) == 0
269
+ payload = json.loads(capsys.readouterr().out)
270
+ assert payload["decision"]["account"] == "roomy"
271
+ assert payload["accounts"][0]["meters"]["session_percent_left"] == 80
272
+ assert payload["state_path"].endswith("state.json")
273
+
274
+
275
+ def test_should_count_failed_meter_read_as_spent() -> None:
276
+ readings = (
277
+ Reading(_account("unread"), None),
278
+ Reading(_account("roomy"), _meters(70, 70)),
279
+ )
280
+
281
+ decision = choose_from_readings(Product.CODEX, readings, now=NOW)
282
+
283
+ assert decision.account == readings[1].account
284
+
285
+
286
+ def test_should_serialize_readings_with_named_fields() -> None:
287
+ reading = Reading(_account("one"), _meters(80, 70))
288
+
289
+ payload = account_broker.readings_payload((reading,))
290
+
291
+ assert payload[0]["name"] == "one"
292
+ assert payload[0]["meters"]["weekly_percent_left"] == 70
293
+
294
+
295
+ def test_should_wait_when_all_meter_reads_fail() -> None:
296
+ readings = (Reading(_account("first"), None), Reading(_account("second"), None))
297
+
298
+ decision = choose_from_readings(Product.CODEX, readings, now=NOW)
299
+
300
+ assert decision.action == "wait"
301
+ assert decision.resets_at == NOW + timedelta(hours=1)
302
+
303
+
304
+ def test_should_replay_stdin_bytes_and_only_print_served_stdout(
305
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
306
+ ) -> None:
307
+ accounts = (_account("first"), _account("second"))
308
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
309
+ "first": _meters(80, 80), "second": _meters(70, 70)
310
+ }))
311
+ original = b"first\r\nsecond\x00\xff"
312
+ received: list[bytes] = []
313
+
314
+ def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
315
+ received.append(options["input"])
316
+ if len(received) == 1:
317
+ return subprocess.CompletedProcess(argv, 1, "discard this", "rate limit")
318
+ return subprocess.CompletedProcess(argv, 0, "command output", "")
319
+
320
+ monkeypatch.setattr(account_broker.sys, "stdin", io.TextIOWrapper(io.BytesIO(original), encoding="utf-8"))
321
+ report_path = tmp_path / "report.json"
322
+ with account_broker.override_subprocess_runner(runner):
323
+ code = account_broker.main(("run", "--product", "codex", "--report", str(report_path), "--", "job"))
324
+
325
+ captured_streams = capsys.readouterr()
326
+ assert code == 0
327
+ assert received == [original, original]
328
+ assert captured_streams.out == "command output"
329
+ assert json.loads(report_path.read_text(encoding="utf-8"))["final_decision"]["account"] == "second"
330
+
331
+
332
+ def test_should_write_wait_report_without_stdout(
333
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
334
+ ) -> None:
335
+ account = _account("spent")
336
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"spent": _meters(0, 0)}))
337
+ monkeypatch.setattr(account_broker.sys, "stdin", io.TextIOWrapper(io.BytesIO(b""), encoding="utf-8"))
338
+ report_path = tmp_path / "report.json"
339
+
340
+ code = account_broker.main(("run", "--product", "codex", "--report", str(report_path), "--", "job"))
341
+
342
+ assert code == WAIT_EXIT_CODE
343
+ assert capsys.readouterr().out == ""
344
+ final = json.loads(report_path.read_text(encoding="utf-8"))["final_decision"]
345
+ assert final["action"] == "wait"
346
+ assert final["resets_at"] is not None
347
+
348
+
349
+ def test_should_keep_spent_mark_until_reset(
350
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
351
+ ) -> None:
352
+ accounts = (_account("first"), _account("second"))
353
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
354
+ "first": _meters(80, 80), "second": _meters(70, 70)
355
+ }))
356
+ reset = int((datetime.now(timezone.utc) + timedelta(hours=2)).timestamp())
357
+
358
+ assert account_broker.main(("choose", "--product", "codex", "--spent", f"first:{reset}")) == 0
359
+ assert json.loads(capsys.readouterr().out)["decision"]["account"] == "second"
360
+ assert account_broker.main(("choose", "--product", "codex")) == 0
361
+ assert json.loads(capsys.readouterr().out)["decision"]["account"] == "second"
362
+
363
+
364
+ @pytest.mark.parametrize(
365
+ ("start_error", "expected_status"),
366
+ ((OSError("missing command"), "start_failed"), (subprocess.TimeoutExpired("job", 1), "timeout")),
367
+ )
368
+ def test_should_leave_later_choices_open_after_start_failure(
369
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], start_error: Exception, expected_status: str
370
+ ) -> None:
371
+ accounts = (_account("first"), _account("second"))
372
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
373
+ "first": _meters(80, 80), "second": _meters(70, 70)
374
+ }))
375
+
376
+ def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
377
+ raise start_error
378
+
379
+ with account_broker.override_subprocess_runner(runner):
380
+ outcome, _ = account_broker._execute(Product.CODEX, ("job",), now=datetime.now(timezone.utc))
381
+
382
+ assert outcome.attempts == (("first", expected_status), ("second", expected_status))
383
+ assert account_broker.main(("choose", "--product", "codex")) == 0
384
+ assert json.loads(capsys.readouterr().out)["decision"]["account"] == "first"
385
+
386
+
387
+ def test_should_keep_usage_limited_account_spent_for_later_choices(
388
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
389
+ ) -> None:
390
+ accounts = (_account("first"), _account("second"))
391
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter(accounts, {
392
+ "first": _meters(80, 80), "second": _meters(70, 70)
393
+ }))
394
+
395
+ def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
396
+ if Path(options["env"]["CODEX_HOME"]).name == "first":
397
+ return subprocess.CompletedProcess(argv, 1, "", "rate limit")
398
+ return subprocess.CompletedProcess(argv, 0, "served", "")
399
+
400
+ with account_broker.override_subprocess_runner(runner):
401
+ account_broker._execute(Product.CODEX, ("job",), now=datetime.now(timezone.utc))
402
+
403
+ assert account_broker.main(("choose", "--product", "codex")) == 0
404
+ assert json.loads(capsys.readouterr().out)["decision"]["account"] == "second"
405
+
406
+
407
+ def test_should_list_accounts_without_reading_meters(
408
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
409
+ ) -> None:
410
+ account = _account("listed")
411
+ adapter = ProductAdapter(lambda: (account,), lambda _: pytest.fail("meter read"), "CODEX_HOME", (), False)
412
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, adapter)
413
+
414
+ assert account_broker.main(("accounts", "--product", "codex")) == 0
415
+ assert json.loads(capsys.readouterr().out) == {
416
+ "accounts": [{"name": "listed", "home": str(account.home), "is_main": False}]
417
+ }
418
+
419
+
420
+ def test_should_use_default_codex_home_without_roster(
421
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
422
+ ) -> None:
423
+ monkeypatch.delenv("CODEX_ACCOUNT_PROFILES", raising=False)
424
+ monkeypatch.setattr(account_broker.support.codex_account_choice, "default_profiles_root", lambda: tmp_path / "profiles")
425
+ monkeypatch.setenv("CODEX_HOME", str(tmp_path / "default"))
426
+
427
+ assert account_broker.load_codex_accounts() == ()
428
+ decision = choose_from_readings(Product.CODEX, (), now=NOW)
429
+ assert decision.account.name == "default"
430
+ assert decision.account.home == (tmp_path / "default").resolve()
431
+ assert "no roster" in decision.reason
432
+
433
+
434
+ def test_should_reuse_meter_cache_for_60_seconds() -> None:
435
+ account = _account("cached")
436
+ calls: list[str] = []
437
+
438
+ def read_meter(selected: Account) -> Meters:
439
+ calls.append(selected.name)
440
+ return _meters(80, 80)
441
+
442
+ adapter = ProductAdapter(lambda: (account,), read_meter, "CODEX_HOME", (), False)
443
+ state = account_broker._load_state(account_broker.broker_state_path())
444
+
445
+ account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW)
446
+ account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW + timedelta(seconds=59))
447
+ account_broker.read_accounts(Product.CODEX, adapter, all_state=state, now=NOW + timedelta(seconds=60))
448
+ assert calls == ["cached", "cached"]
449
+
450
+
451
+ def test_should_route_resume_to_bound_account(
452
+ monkeypatch: pytest.MonkeyPatch
453
+ ) -> None:
454
+ accounts = (_account("first", Product.CLAUDE), _account("second", Product.CLAUDE))
455
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CLAUDE, _adapter(accounts, {
456
+ "first": _meters(80, 80), "second": _meters(70, 70)
457
+ }))
458
+ chosen: list[str] = []
459
+
460
+ def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
461
+ chosen.append(Path(options["env"]["CODEX_HOME"]).name)
462
+ return subprocess.CompletedProcess(argv, 0, '{"session_id":"session-1"}', "")
463
+
464
+ with account_broker.override_subprocess_runner(runner):
465
+ first = run_job(Product.CLAUDE, ("job",))
466
+ second = run_job(Product.CLAUDE, ("job", "--resume", "session-1"))
467
+
468
+ assert first.session_id == "session-1"
469
+ assert second.account_name == "first"
470
+ assert chosen == ["first", "first"]
471
+
472
+
473
+ def test_should_raise_configuration_error_for_broken_list(
474
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
475
+ ) -> None:
476
+ monkeypatch.setattr(Path, "home", lambda: tmp_path)
477
+ directory = tmp_path / ".claude"
478
+ directory.mkdir()
479
+ (directory / "claude-chain.json").write_text('{"chain": "broken"}', encoding="utf-8")
480
+
481
+ with pytest.raises(account_broker.BrokerConfigurationError):
482
+ account_broker.load_claude_accounts()
483
+
484
+
485
+ def test_should_run_job_through_override(
486
+ monkeypatch: pytest.MonkeyPatch
487
+ ) -> None:
488
+ account = _account("only")
489
+ monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"only": _meters(80, 80)}))
490
+ calls: list[bytes] = []
491
+
492
+ def runner(argv: object, **options: object) -> subprocess.CompletedProcess[str]:
493
+ calls.append(options["input"])
494
+ return subprocess.CompletedProcess(argv, 0, "done", "")
495
+
496
+ with account_broker.override_subprocess_runner(runner):
497
+ outcome = run_job(Product.CODEX, ("job",), stdin_text=b"input")
498
+
499
+ assert outcome.status == "served"
500
+ assert outcome.attempts == (("only", "served"),)
501
+ assert calls == [b"input"]
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import Path
5
+
6
+
7
+ SCRIPTS = Path(__file__).resolve().parent
8
+ ALLOWED_LEGACY_MODULES = frozenset(
9
+ {
10
+ "claude_account_choice.py",
11
+ "claude_account_worker.py",
12
+ "claude_chain_runner.py",
13
+ "claude_chain_usage.py",
14
+ "codex_account_choice.py",
15
+ "codex_account_meters.py",
16
+ }
17
+ )
18
+ ACCOUNT_LIST_CALLS = frozenset(
19
+ {
20
+ "load_chain",
21
+ "extra_config_directories",
22
+ "read_extra_accounts",
23
+ "codex_account_names",
24
+ "saved_codex_account_names",
25
+ }
26
+ )
27
+ ACCOUNT_LIST_MARKERS = frozenset(
28
+ {
29
+ "claude-chain.json",
30
+ "extra-profiles.json",
31
+ "account-launchers.json",
32
+ "EXTRA_PROFILES_FILE_NAME",
33
+ "CODEX_ACCOUNT_LAUNCHERS_FILE_NAME",
34
+ }
35
+ )
36
+ ACCOUNT_ENVIRONMENT_MARKERS = frozenset(
37
+ {
38
+ "CLAUDE_CONFIG_DIR",
39
+ "CODEX_HOME",
40
+ "CLAUDE_CONFIG_DIR_ENV_VAR",
41
+ "CODEX_HOME_ENVIRONMENT_VARIABLE",
42
+ }
43
+ )
44
+
45
+
46
+ def _called_name(node: ast.Call) -> str | None:
47
+ if isinstance(node.func, ast.Name):
48
+ return node.func.id
49
+ if isinstance(node.func, ast.Attribute):
50
+ return node.func.attr
51
+ return None
52
+
53
+
54
+ def _sets_account_home(node: ast.AST) -> bool:
55
+ if isinstance(node, ast.Assign):
56
+ targets = node.targets
57
+ elif isinstance(node, ast.AnnAssign):
58
+ targets = (node.target,)
59
+ else:
60
+ return False
61
+ for target in targets:
62
+ if isinstance(target, ast.Subscript) and any(
63
+ marker in ast.unparse(target.slice) for marker in ACCOUNT_ENVIRONMENT_MARKERS
64
+ ):
65
+ return True
66
+ return False
67
+
68
+
69
+ def _node_reads_account_list(node: ast.AST) -> bool:
70
+ if isinstance(node, ast.Call):
71
+ return _called_name(node) in ACCOUNT_LIST_CALLS
72
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
73
+ body = ast.unparse(node)
74
+ return "read_text" in body and any(
75
+ marker in body for marker in ACCOUNT_LIST_MARKERS
76
+ )
77
+ return False
78
+
79
+
80
+ def _reads_account_list(tree: ast.AST) -> bool:
81
+ return any(_node_reads_account_list(node) for node in ast.walk(tree))
82
+
83
+
84
+ def _chooses_account_in_module(path: Path) -> bool:
85
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
86
+ if _reads_account_list(tree):
87
+ return True
88
+ for node in ast.walk(tree):
89
+ if _sets_account_home(node):
90
+ return True
91
+ if isinstance(node, ast.Dict) and any(
92
+ key is not None
93
+ and any(marker in ast.unparse(key) for marker in ACCOUNT_ENVIRONMENT_MARKERS)
94
+ for key in node.keys
95
+ ):
96
+ return True
97
+ return False
98
+
99
+
100
+ def test_should_reject_new_account_pickers_outside_broker() -> None:
101
+ all_pickers = {
102
+ path.name
103
+ for path in SCRIPTS.rglob("*.py")
104
+ if not path.name.startswith("test_")
105
+ and "tests" not in path.parts
106
+ and _chooses_account_in_module(path)
107
+ }
108
+
109
+ assert all_pickers <= ALLOWED_LEGACY_MODULES | {"account_broker.py", "account_broker_support.py"}